fib-mcp
v1.3.0
Published
MCP (Model Context Protocol) client/server for fibjs with stdio/http/sse/ws transport
Maintainers
Readme
fib-mcp
MCP (Model Context Protocol) SDK for the fibjs ecosystem.
McpServer and McpClient extend @modelcontextprotocol/sdk directly.
fib-mcp adds fibjs-native server transports (sse, ws, http),
a fibjs-native client transport for sse, and handler methods
for mounting server transports into your own http.Server.
TypeScript runs directly on fibjs; no compile step is required.
Features
McpServer extends sdk.McpServer— all SDK methods available as-isMcpClient extends sdk.Client— all SDK methods available as-is- fibjs-native server transports:
sse,ws,http - client transports: SDK
ws, SDK Streamable HTTP, fibjs-nativesse - HTTP client uses the SDK Streamable HTTP transport
- SSE automatic endpoint discovery (standard MCP SSE protocol)
- Designed to be mounted into user-managed
http.Serverroutes
Installation
fibjs --install fib-mcpQuick Start
Server - HTTP
import http from 'http';
import { McpServer } from 'fib-mcp';
const server = new McpServer({ name: 'demo-server', version: '1.0.0' });
server.tool('ping', {}, async () => ({
content: [{ type: 'text', text: 'pong' }],
}));
const svr = new http.Server(3000, {
'/mcp': server.httpHandler(),
});
svr.start();Client — HTTP
import { McpClient } from 'fib-mcp';
const client = new McpClient({ name: 'demo-client', version: '1.0.0' });
await client.connect({ transport: 'streamable-http', url: 'http://127.0.0.1:3000/mcp' });
const { tools } = await client.listTools();
const result = await client.callTool({ name: 'ping', arguments: {} });
console.log(result.content[0].text);
await client.close();Server — WebSocket
import http from 'http';
import { McpServer } from 'fib-mcp';
const server = new McpServer({ name: 'demo-server', version: '1.0.0' });
server.tool('ping', {}, async () => ({
content: [{ type: 'text', text: 'pong' }],
}));
const svr = new http.Server(3000, {
'/mcp': server.wsHandler(),
});
svr.start();Client — WebSocket
import { McpClient } from 'fib-mcp';
const client = new McpClient({ name: 'demo-client', version: '1.0.0' });
await client.connect({ transport: 'ws', url: 'ws://127.0.0.1:3000/mcp' });
const result = await client.callTool({ name: 'ping', arguments: {} });
await client.close();Server — SSE
import http from 'http';
import { McpServer } from 'fib-mcp';
const server = new McpServer({ name: 'demo-server', version: '1.0.0' });
server.tool('ping', {}, async () => ({
content: [{ type: 'text', text: 'pong' }],
}));
const svr = new http.Server(3000, {
'/mcp': server.sseHandlers(),
});
svr.start();Client — SSE
import { McpClient } from 'fib-mcp';
const client = new McpClient({ name: 'demo-client', version: '1.0.0' });
// messageUrl auto-discovered from server's `endpoint` event:
await client.connect({ transport: 'sse', url: 'http://127.0.0.1:3000/mcp/sse' });
// Or with an explicit messageUrl:
// await client.connect({ transport: 'sse', url: 'http://127.0.0.1:3000/mcp/sse', messageUrl: 'http://127.0.0.1:3000/mcp/message' });
const result = await client.callTool({ name: 'ping', arguments: {} });
await client.close();Server — stdio
import { McpServer } from 'fib-mcp';
const server = new McpServer({ name: 'demo-server', version: '1.0.0' });
server.tool('ping', {}, async () => ({
content: [{ type: 'text', text: 'pong' }],
}));
await server.listenStdio();Client — stdio
import { McpClient } from 'fib-mcp';
const client = new McpClient({ name: 'demo-client', version: '1.0.0' });
// Spawn an MCP server script via stdio:
await client.connect({ transport: 'stdio', path: './my_mcp_server.ts' });
// Or connect to an explicit command:
// await client.connect({ transport: 'stdio', command: 'fibjs', args: ['my_mcp_server.ts'] });
const result = await client.callTool({ name: 'ping', arguments: {} });
await client.close();API Reference
McpServer
Extends @modelcontextprotocol/sdk McpServer. All SDK methods (tool, resource,
prompt, registerTool, connect, close, etc.) are inherited unchanged.
fib-mcp adds the following fibjs-native transport methods:
tool vs registerTool
Both are valid, and both register MCP tools.
tool(...): high-level helper API; concise and preferred for most examples.registerTool(...): lower-level SDK API; useful when you want explicit control over registration shape (for example, full schema objects and custom handler wiring).
Why the repository has both styles:
- README quick examples use
tool(...)for readability. - Some production paths use
registerTool(...)because those handlers need the lower-level control surface.
This is expected behavior, not a protocol mismatch.
Note: a single McpServer instance should bind only one network transport (ws, sse, or http).
If you need multiple network protocols, create separate McpServer instances.
listenStdio(): Promise<void>
Connect to stdio. Used when the server is spawned by an MCP host.
wsHandler(): Handler
Returns a fibjs WebSocket upgrade handler for use in a route map.
const svr = new http.Server(3000, { '/mcp': server.wsHandler() });sseHandlers(): Record<string, Handler>
Returns SSE route handlers for nested fibjs routing (SSE GET + POST message endpoint).
const svr = new http.Server(3000, { '/mcp': server.sseHandlers() });The server automatically sends an endpoint event on connect, so clients can
discover the POST URL without it being pre-configured.
httpHandler(options?): Handler
Returns an HTTP POST handler for mounting at a route chosen by your outer router.
const svr = new http.Server(3000, { '/mcp': server.httpHandler() });Options:
timeoutMs?: request timeout in ms (default:30000)
httpHandlers(options?): Record<string, Handler>
Returns a flat fibjs route map for JSON-RPC over HTTP POST.
const svr = new http.Server(3000, server.httpHandlers({ path: '/mcp' }));Options:
path?: route path (default:/mcp)timeoutMs?: request timeout in ms (default:30000)
McpClient
Extends @modelcontextprotocol/sdk Client. All SDK methods (callTool, listTools,
readResource, listResources, getPrompt, listPrompts, listResourceTemplates,
ping, complete, connect, close, etc.) are inherited unchanged with identical
signatures and return types.
fib-mcp adds the following transport connection methods:
connect(config | transport): Promise<void>
Unified client entry point.
Use transport descriptor objects aligned with MCP Registry transport style:
{ transport: 'streamable-http', url, options? }{ transport: 'sse', url, messageUrl?, options? }{ transport: 'ws' | 'websocket', url }{ transport: 'stdio', path, options? }{ transport: 'stdio', command, args?, options? }
Passing a transport object still works and is forwarded to the SDK connect(transport).
SSE notes:
If messageUrl is omitted, the client waits for the server's endpoint SSE event
and discovers the POST URL automatically (standard MCP SSE protocol).
Options:
headers?: extra request headersmethod?: POST method override (default:POST)
Transport Notes
| Transport | Client | Server | |-----------|--------|--------| | stdio | SDK | SDK | | http | SDK Streamable HTTP | fibjs-native | | sse | fibjs-native | fibjs-native | | ws | SDK | fibjs-native |
Bidirectional Session
BidirectionalSession provides transport-agnostic bidirectional MCP over one connection:
- Forward calls: local side calls peer tools
- Reverse calls: peer calls local tools through
ctx.client - Session-scoped capability negotiation for reverse channel
- Backward compatible with plain MCP clients
- Hides internal server implementation details from the public API
Constructor (New API)
BidirectionalSession now uses a single options object.
import { BidirectionalSession } from 'fib-mcp';
const session = new BidirectionalSession({
serverInfo: { name: 'my-server', version: '1.0.0' },
clientInfo: { name: 'my-client', version: '1.0.0' },
clientOptions: {},
serverOptions: {},
});Options:
serverInfo(required): local server identityclientInfo(optional): local client identity, defaultbidirectional-client/1.0.0clientOptions(optional): forwarded to internalMcpClientserverOptions(optional): forwarded to internalMcpServer
Tool Callback Context
BidirectionalSession.tool(...) is the bidirectional wrapper and is the recommended path for reverse-call handlers.
Internally it delegates to the underlying MCP server tool registration and injects ctx.client.
If you need MCP standard registration APIs, call them directly on BidirectionalSession (registerTool, registerResource, registerPrompt, etc.).
session.tool('server.proxy', {}, async (_args, ctx) => {
const nested = await ctx.client.callTool({ name: 'peer.echo', arguments: {} });
return {
content: [{ type: 'text', text: nested.content[0].text }],
};
});Handler context:
ctx.client: peer client bound to current sessionctx.extra: MCP request metadata (includessessionId)
Connection APIs
WebSocket convenience:
wsHandler()for server route mountingconnect({ transport: 'ws', url })for active side over websocket
Stdio convenience:
connect({ transport: 'stdio', path, options? })for active side stdio script launchconnect({ transport: 'stdio', command, args?, options? })for active side stdio command launchlistenStdio()for passive side stdio accept
Generic transport:
connect(config)orconnect(transport)active sideaccept(transport)passive side
Both return BidirectionalConnection:
connection.sessionIdconnection.callTool(...)connection.listTools(...)connection.readResource(...)connection.listResources(...)connection.listPrompts(...)connection.getPrompt(...)connection.close()
Note: internal client/server instances are intentionally hidden from the public API.
Forwarding Gateway
ForwardingGateway is a client/app/server gateway that proxies MCP over WebSocket:
client -> gateway: normal MCP over WebSocket (any MCP client — browser, CLI, etc.)gateway -> server: raw JSON-RPC request / notification relay over one bidirectional sessionserver -> gateway: reverse MCP handled locally by app tools throughReverseMcpEndpoint
Default behavior:
- client-side
initializeis terminated locally by the gateway - client → server requests are forwarded as raw JSON-RPC requests
- client → server notifications are forwarded as raw JSON-RPC notifications
- server → client notifications are forwarded by the gateway default path
- server → gateway reverse MCP calls use gateway-local tools via
ctx.client
Minimal shape:
import http from 'http';
import { ForwardingGateway } from 'fib-mcp';
const gateway = new ForwardingGateway({
appInfo: { name: 'app-gateway', version: '1.0.0' },
connectServer: async () => ({ transport: 'ws', url: 'ws://127.0.0.1:9001/mcp' }),
});
gateway.tool('app.greet', {}, async () => ({
content: [{ type: 'text', text: 'hello-from-app' }],
}));
const svr = new http.Server(3000, {
'/mcp': gateway.wsHandler(),
});
svr.start();Hooks — Middleware API
All three hooks use the same (ctx, next) middleware signature:
- Call
next()— execute the default behavior (forward the original message) - Call
next(modifiedMessage)— execute the default behavior with a rewritten message - Return without calling
next()— suppress the default behavior entirely - Throw an error — send a JSON-RPC error response (attach a numeric
.codeproperty)
onClientRequest(ctx, next)
Intercepts JSON-RPC requests arriving from the downstream MCP client.
ctx fields:
ctx.session— current session state (includesserverConnection,authContext, etc.)ctx.message— the raw JSON-RPC request messagectx.reply(result)— respond locally without forwarding; alternative tonext()
const gateway = new ForwardingGateway({
appInfo: { name: 'app-gateway', version: '1.0.0' },
connectServer: async () => ({ transport: 'ws', url: 'ws://127.0.0.1:9001/mcp' }),
onClientRequest: async (ctx, next) => {
// Auth check: only allow certain methods
if ((ctx.message as any).method === 'tools/call') {
const allowed = checkPermission(ctx.session.authContext);
if (!allowed) {
const err: any = new Error('forbidden');
err.code = -32001;
throw err;
}
}
return next(); // continue with default forwarding
},
});Rewrite a request before forwarding:
onClientRequest: async (ctx, next) => {
const msg = ctx.message as any;
if (msg.method === 'agent.session.open') {
return next({
...ctx.message,
params: { ...msg.params, agentId: resolveAgentId(ctx.session.authContext) },
} as any);
}
return next();
},Respond locally without touching the upstream server:
onClientRequest: async (ctx, next) => {
if ((ctx.message as any).method === 'local.ping') {
return ctx.reply({ pong: true });
}
return next();
},onClientNotification(ctx, next)
Intercepts JSON-RPC notifications arriving from the downstream MCP client.
onClientNotification: async (ctx, next) => {
// log and pass through
console.log('client notification:', (ctx.message as any).method);
return next();
},Suppress forwarding by not calling next():
onClientNotification: async (_ctx, _next) => {
// drop all client notifications
},onServerNotification(ctx, next)
Intercepts JSON-RPC notifications arriving from the upstream MCP server.
onServerNotification: async (ctx, next) => {
// Inject an extra field before forwarding to the client
const msg = ctx.message as any;
return next({
...ctx.message,
params: { ...msg.params, _gatewayId: 'my-app' },
} as any);
},onClientDisconnect(session)
Called when a downstream client disconnects. Use for cleanup (e.g. releasing local state keyed on session.clientSessionId).
onClientDisconnect: (session) => {
releaseResources(session.clientSessionId);
},Authentication
Use authenticate to validate the connection and attach context:
const gateway = new ForwardingGateway({
appInfo: { name: 'app-gateway', version: '1.0.0' },
authenticate: async ({ request, initializeRequest }) => {
const token = request?.headers?.['authorization'];
const user = await verifyToken(token);
if (!user) throw new Error('unauthorized');
return { user };
},
connectServer: async ({ authContext }) => ({
transport: 'ws',
url: `ws://127.0.0.1:9001/mcp`,
headers: { 'x-user-id': authContext.user.id },
}),
});The resolved authContext is available on ctx.session.authContext in all hook callbacks.
Options Reference
| Option | Type | Description |
|--------|------|-------------|
| appInfo | ClientInfo | Gateway identity (name, version) |
| clientCapabilities | object? | Capabilities advertised to downstream clients |
| instructions | string? | Instructions string sent in initialize response |
| authenticate | fn? | Validate connection; return value becomes session.authContext |
| connectServer | fn? | Return transport config for the upstream server connection |
| onClientRequest | fn? | Middleware for client → server requests |
| onClientNotification | fn? | Middleware for client → server notifications |
| onServerNotification | fn? | Middleware for server → client notifications |
| onClientDisconnect | fn? | Called when a client session ends |
| serverClientInfo | ClientInfo? | Identity used for the outbound server connection |
| serverClientOptions | object? | Options forwarded to the outbound client |
| reverseServerOptions | object? | Options for the local reverse-MCP server endpoint |
WebSocket Example
import http from 'http';
import { BidirectionalSession } from 'fib-mcp';
const accepted = new BidirectionalSession({
serverInfo: { name: 'accepted-server', version: '1.0.0' },
clientInfo: { name: 'accepted-client', version: '1.0.0' },
});
accepted.tool('server.ping', {}, async () => ({
content: [{ type: 'text', text: 'pong-from-accepted' }],
}));
const host = new http.Server(3000, {
'/mcp': accepted.wsHandler(),
});
host.start();
const peer = new BidirectionalSession({
serverInfo: { name: 'peer-server', version: '1.0.0' },
clientInfo: { name: 'peer-client', version: '1.0.0' },
});
const conn = await peer.connect({ transport: 'ws', url: 'ws://127.0.0.1:3000/mcp' });
const pong = await conn.callTool({ name: 'server.ping', arguments: {} });
console.log(pong.content[0].text);Stdio Example
import { BidirectionalSession } from 'fib-mcp';
const parent = new BidirectionalSession({
serverInfo: { name: 'parent-server', version: '1.0.0' },
clientInfo: { name: 'parent-client', version: '1.0.0' },
});
parent.tool('parent.greet', {}, async () => ({
content: [{ type: 'text', text: 'hello-from-parent' }],
}));
const conn = await parent.connect({ transport: 'stdio', command: 'fibjs', args: ['./child.ts'] });
const echo = await conn.callTool({ name: 'child.echo', arguments: {} });
console.log(echo.content[0].text);In-Memory Example
import { BidirectionalSession } from 'fib-mcp';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
const left = new BidirectionalSession({
serverInfo: { name: 'left-server', version: '1.0.0' },
clientInfo: { name: 'left-client', version: '1.0.0' },
});
const right = new BidirectionalSession({
serverInfo: { name: 'right-server', version: '1.0.0' },
clientInfo: { name: 'right-client', version: '1.0.0' },
});
const [leftTransport, rightTransport] = InMemoryTransport.createLinkedPair();
const leftConn = await left.connect(leftTransport);
const rightConn = await right.accept(rightTransport);Backward Compatibility
Plain MCP clients are supported:
- Forward calls work as normal
- Reverse calls are blocked if peer does not advertise reverse capability
- Mixed plain and bidirectional clients can coexist on the same server
Transport Contract
Custom transport should implement SDK Transport behavior:
start()send(message, options?)close()onmessage(message, extra?)onerror(error)onclose()
Notifications
Notification flow works on both normal MCP transports and BidirectionalSession.
Testing
fibjs test/all.test.ts
fibjs --test test/integration_test.ts
fibjs --test test/edge_cases_test.ts
fibjs --test test/bidirectional_provider_test.ts