mcpose
v1.1.1
Published
Composable FP (Koa-style) middleware proxy builder for any MCP server
Downloads
271
Maintainers
Readme
mcpose
Composable middleware proxy for MCP servers.
New in 1.1.1
- mirrors only upstream-advertised MCP capabilities
- forwards abort signals and progress updates through the proxy
- advertises and fans out list-changed notifications correctly
- closes active HTTP proxy sessions on shutdown
- ships a stronger
mcpose/testingmock backend
Background
mcpose was extracted from financial-elastic-mcp-server, an Elasticsearch MCP server built for financial institutions that needed PII redaction and audit logging on every tool call. Those cross-cutting concerns were originally hardcoded into a single server. mcpose lifts that pattern into a reusable, composable middleware layer that can wrap any upstream MCP server.
Concept
mcpose is a transparent proxy between an LLM client and an upstream MCP server. It mirrors the upstream MCP surface and routes supported calls through middleware. The client sees a normal MCP server; the upstream sees a normal MCP client.
Install
npm install mcposePeer dependency — must be installed separately:
npm install @modelcontextprotocol/sdk@>=1.0.0Quick Start
import { createBackendClient, startProxy } from 'mcpose';
import type { ToolMiddleware } from 'mcpose';
// 1. Connect to the upstream MCP server (stdio)
const backend = await createBackendClient({
command: 'node',
args: ['/path/to/backend-server.mjs'],
});
// 2. Define middleware
const loggingMW: ToolMiddleware = async (req, next) => {
console.error(`→ ${req.params.name}`);
const result = await next(req);
console.error(`← ${req.params.name} done`);
return result;
};
// 3. Start the proxy on stdio
await startProxy(backend, {
toolMiddleware: [loggingMW],
});Proxy model
┌──────────────┐ ┌────────────────────────────────┐ ┌────────────────────┐
│ LLM client │ ◄────► │ mcpose │ ◄────► │ Upstream MCP │
│ (Claude, │ │ · visibility filters │ │ server │
│ Cursor…) │ │ · middleware pipelines │ │ (stdio or HTTP) │
└──────────────┘ └────────────────────────────────┘ └────────────────────┘For each supported tool or resource, mcpose picks one of three routing paths:
| Path | Option | Behavior |
|---|---|---|
| Hidden | hiddenTools / hiddenResources | Omitted from list responses; rejected with an error at call time |
| Pass-through | passThroughTools / passThroughResources | Forwarded raw to upstream — all middleware skipped |
| Middleware | everything else | Routed through the full toolMiddleware / resourceMiddleware pipeline |
Prompts are forwarded as-is when the upstream supports prompts.
The proxy preserves core request semantics end to end:
- advertised capabilities are mirrored from the upstream server
- abort signals are forwarded to upstream tool, resource, and prompt calls
- upstream progress updates are relayed back to the downstream client
- list-changed notifications are advertised and fanned out when the upstream supports them
Middleware model
Middleware follows the onion model: outer layers run code before and after inner layers. Each middleware receives the request and a next function to invoke the rest of the pipeline.
request ──►
┌──────────────────────────────────────────┐
│ outerMW (enter) │
│ ┌────────────────────────────────────┐ │
│ │ innerMW (enter) │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ upstream call │ │ │
│ │ └──────────────────────────────┘ │ │
│ │ innerMW (exit) ◄── response │ │
│ └────────────────────────────────────┘ │
│ outerMW (exit) ◄── response │
└──────────────────────────────────────────┘
◄── responseArray order in ProxyOptions uses response-processing order: the first element processes the response first (innermost layer). ProxyOptions calls pipe() internally — no need to wrap manually. To guarantee audit never sees raw PII:
toolMiddleware: [piiMW, auditMW]
// Execution:
// 1. auditMW enter → capture startTime (outermost)
// 2. piiMW enter → transform request
// 3. upstream call
// 4. piiMW exit → redact PII from response (processes response first)
// 5. auditMW exit → log already-clean data (processes response last)compose([outerMW, innerMW]) uses the opposite (outermost-first) convention — ProxyOptions arrays are not interchangeable with compose() arguments.
A middleware can short-circuit by returning without calling next, or handle upstream errors by wrapping await next(req) in a try/catch.
API Reference
Middleware<Req, Res> · ToolMiddleware · ResourceMiddleware · compose()
type Middleware<Req, Res> = (
req: Req,
next: (req: Req) => Promise<Res>,
) => Promise<Res>;
// Convenience aliases for the two pipeline types:
type ToolMiddleware = Middleware<CallToolRequest, CompatibilityCallToolResult>;
type ResourceMiddleware = Middleware<ReadResourceRequest, ReadResourceResult>;
function compose<Req, Res>(
middlewares: ReadonlyArray<Middleware<Req, Res>>,
): Middleware<Req, Res>;
// Type guard — narrows CompatibilityCallToolResult to CallToolResult
// (safe access to .content and .isError without casts):
function hasToolContent(r: CompatibilityCallToolResult): r is CallToolResult;compose takes an array in outermost-first order. Use hasToolContent in middleware implementations before accessing .content or .isError, since CompatibilityCallToolResult also covers the legacy { toolResult } shape.
BackendConfig · createBackendClient()
interface BackendConfig {
command?: string; // Executable to spawn for stdio transport (e.g., "node")
args?: string[]; // Arguments for the spawned process
url?: string; // HTTP endpoint of a running MCP server (takes precedence over stdio)
}
async function createBackendClient(config: BackendConfig): Promise<BackendClient>;BackendClient is an alias for the SDK Client. It throws if neither command nor url is provided, or if the connection fails.
ProxyOptions · startProxy() · createProxyServer()
interface ProxyOptions {
toolMiddleware?: ReadonlyArray<ToolMiddleware>;
resourceMiddleware?: ReadonlyArray<ResourceMiddleware>;
passThroughTools?: ReadonlyArray<string>;
passThroughResources?: ReadonlyArray<string>;
hiddenTools?: ReadonlyArray<string>;
hiddenResources?: ReadonlyArray<string>;
}
async function startProxy(backend: BackendClient, options?: ProxyOptions): Promise<void>;
function createProxyServer(backend: BackendClient, options?: ProxyOptions): Server;| Option | Description |
|---|---|
| toolMiddleware | Middleware stack for tool calls, in response-processing order (first element processes response first). |
| resourceMiddleware | Middleware stack for resource reads, in response-processing order. |
| passThroughTools | Tool names forwarded raw to upstream — middleware skipped entirely. |
| passThroughResources | Resource URIs forwarded raw to upstream — middleware skipped entirely. |
| hiddenTools | Tool names removed from list_tools and rejected at call time with MethodNotFound. |
| hiddenResources | Resource URIs removed from list_resources and rejected at call time with InvalidRequest. |
createProxyServer mirrors only the upstream capabilities exposed by backend.getServerCapabilities(). Unsupported prompt, resource, and tool endpoints are not advertised or registered.
startProxy connects the proxy to a StdioServerTransport. createProxyServer returns the configured Server without connecting — useful for testing request handlers without a live transport.
HttpProxyOptions · startHttpProxy()
interface HttpProxyOptions {
port?: number; // Default: 3000
host?: string; // Default: all interfaces
path?: string; // Default: '/mcp'
}
function startHttpProxy(
backend: BackendClient,
options?: ProxyOptions,
httpOptions?: HttpProxyOptions,
): Promise<http.Server>;Starts the proxy over Streamable HTTP with stateful sessions. Each client connection is assigned an mcp-session-id. Upstream list-change notifications (tools/list_changed, resources/list_changed, prompts/list_changed) are fanned out to all active sessions when the upstream advertises them.
import { createBackendClient, startHttpProxy } from 'mcpose';
const backend = await createBackendClient({ url: 'http://upstream-mcp-server/mcp' });
const server = await startHttpProxy(backend, { toolMiddleware: [loggingMW] }, { port: 8080 });
// HTTP server is now listening on port 8080 at /mcpOn shutdown, active proxy sessions are closed before the underlying http.Server finishes closing.
Limitations:
- Sessions have no idle timeout.
- SSE reconnect replay is not supported (no
EventStore).
mcpose/testing
import { createMockBackendClient, runToolMiddleware } from 'mcpose/testing';createMockBackendClient() returns an in-memory backend stub with capability lookup and notification hooks. It works with both createProxyServer() and startHttpProxy() tests.
Recipe: PII redaction
The origin use case for mcpose: a financial-grade MCP server where every Elasticsearch tool response must be scrubbed of PII before it reaches the LLM or the audit log.
Use a factory to keep middleware configurable and testable:
import { hasToolContent } from 'mcpose';
import type { ToolMiddleware } from 'mcpose';
function createPiiMiddleware(patterns: RegExp[]): ToolMiddleware {
return async (req, next) => {
const result = await next(req);
if (!hasToolContent(result)) return result;
return {
...result,
content: result.content.map((item) =>
item.type === 'text'
? { ...item, text: redactPii(item.text, patterns) }
: item,
),
};
};
}
function redactPii(text: string, patterns: RegExp[]): string {
return patterns.reduce((t, re) => t.replace(re, '[REDACTED]'), text);
}Stack it with audit middleware — PII first in the array so audit always sees clean data:
await startProxy(backend, {
toolMiddleware: [
createPiiMiddleware([/\b\d{9}\b/g, /[A-Z]{2}\d{6}/g]), // SSNs, account numbers
createAuditMiddleware({ destination: auditLog }),
],
});The array order guarantees: PII is redacted before the audit layer ever sees the response. No raw PII reaches a log, satisfying financial regulatory requirements.
Reference implementation:
elastic-pii-proxyis a production example of this pattern — an Elasticsearch MCP proxy that uses mcpose with a PII redaction middleware and an audit middleware to serve financial data safely to LLM agents.
Roadmap
- [x] HTTP/SSE server transport —
startHttpProxy()adds a Streamable HTTP server-side transport with stateful sessions - [ ] ATXP protocol support — enable MCP monetization by implementing the ATXP (Agent Transaction Protocol) standard, letting tool providers attach pricing and billing metadata to responses
License
MIT
