@mcp-z/server
Shared utilities and server orchestration for building MCP (Model Context Protocol) servers
Common uses
- Parse transport config for stdio or HTTP
- Wire MCP servers to stdio or Express HTTP
- Compose auth/logging middleware
- Serve generated files (PDFs, CSVs)
- Build field/pagination/shape schemas
Install
npm install @mcp-z/server @modelcontextprotocol/server express
The example imports the MCP SDK and Express directly, so they are explicit application dependencies.
Quick start
import express from 'express';
import { McpServer } from '@modelcontextprotocol/server';
import { parseConfig, connectStdio, connectHttp } from '@mcp-z/server';
const config = parseConfig(process.argv.slice(2), process.env);
const writeLog = (...args: unknown[]) => console.error(...args);
const logger = { debug: writeLog, info: writeLog, warn: writeLog, error: writeLog };
const buildServer = () => {
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
server.registerTool('hello', { description: 'Return a greeting' }, async () => ({
content: [{ type: 'text', text: 'Hello from my server' }]
}));
return server;
};
if (config.transport.type === 'stdio') {
await connectStdio(buildServer, { logger });
} else {
const app = express();
await connectHttp(buildServer, { logger, app, port: config.transport.port });
}
Keep logs on stderr. Writing logs to stdout corrupts the stdio protocol stream.
Protocol versions
connectHttp and connectStdio serve both the 2025 and 2026-07-28 MCP protocol revisions from
the same server definitions, on the same HTTP endpoint and the same stdio connection. A legacy
2025 client keeps working unchanged; support for it is not being dropped.
Serving both revisions requires a factory, not an McpServer instance. The 2026-07-28
revision is stateless. A client speaking it sends no initialize handshake, and the SDK caches
the negotiated revision on the McpServer instance. Its own documentation puts it plainly: once a
version is negotiated, "a negotiated session never re-routes a method onto the other era." So a
single shared instance pins itself to whichever revision reaches it first and answers the other
with -32601 Method not found. A factory hands every request (HTTP) or connection (stdio) a
fresh, un-negotiated instance, so each one negotiates for itself:
const buildServer = () => {
const mcpServer = new McpServer({ name: 'my-server', version: '1.0.0' });
// register tools/resources/prompts on mcpServer
return mcpServer;
};
await connectStdio(buildServer, { logger });
// or
await connectHttp(buildServer, { logger, app, port: config.transport.port });
Passing a ready-made McpServer instance is still accepted and still compiles, but it is correct only
for a server that will speak one revision.
If both eras must work, pass a factory. A factory serves 2025-era clients exactly as an instance
did, so there is no reason to prefer an instance once you have one.
Cache hints
On 2026-07-28 the SDK stamps ttlMs and cacheScope onto every cacheable result, defaulting to
ttlMs: 0 / cacheScope: 'private'. This safe default disables caching.
defaultCacheHints is a policy for a typical mcp-z server: catalogs are cacheable and shareable,
anything derived from a user's account is not.
import { McpServer } from '@modelcontextprotocol/server';
import { defaultCacheHints } from '@mcp-z/server';
const mcpServer = new McpServer({ name: 'my-server', version: '1.0.0' }, { cacheHints: defaultCacheHints });
tools/list, prompts/list, resources/templates/list and server/discover are given a five-minute
TTL and cacheScope: 'public', because each is fixed at registration and identical for every caller.
resources/list and resources/read stay private with no TTL: those vary by account, and marking
them public would let a shared cache serve one user's data to another. Override a single operation
by spreading ({ ...defaultCacheHints, 'tools/list': { ttlMs: 0 } }). 2025-era responses are
unaffected because the fields do not exist there.
Registration helpers
registerTools(server, tools)registerResources(server, resources)registerPrompts(server, prompts)
Each registers in name order, not the order the array happened to arrive in, so two servers built
from the same modules produce identical tools/list results. That is what lets a 2026-07-28 client
keep a cached catalog valid across a reconnect. The array you pass is not mutated.
Middleware composition
Use composeMiddleware with middleware layers (auth, logging, etc.):
import { composeMiddleware, createLoggingMiddleware } from '@mcp-z/server';
const logging = createLoggingMiddleware({ logger });
const composed = composeMiddleware({ tools, resources, prompts }, [
{ withTool: authMiddleware.withToolAuth, withResource: authMiddleware.withResourceAuth, withPrompt: authMiddleware.withPromptAuth },
{ withTool: logging.withToolLogging, withResource: logging.withResourceLogging, withPrompt: logging.withPromptLogging }
]);
File serving utilities
For servers that generate files (PDFs, CSVs, images):
reserveFile()- Reserve a file path for streaming writeswriteFile()- Write a buffer directlygetFileUri()-file://orhttp://URI based on transportcreateFileServingRouter()- Express router to serve files
import { reserveFile, getFileUri, createFileServingRouter } from '@mcp-z/server';
const reservation = await reserveFile('report.csv', { resourceStoreUri: 'file:///tmp/files' });
const uri = getFileUri(reservation.storedName, transport, {
resourceStoreUri: 'file:///tmp/files',
baseUrl: 'https://example.com',
endpoint: '/files'
});
const router = createFileServingRouter({ resourceStoreUri: 'file:///tmp/files' }, { contentType: 'text/csv' });
app.use('/files', router);
Schema helpers
Helpers for consistent tool inputs and output shaping:
createFieldsSchema()/parseFields()/filterFields()createPaginationSchema()createShapeSchema()/toColumnarFormat()
Requirements
- Node.js >= 20