Skip to content

Commit 73f75ae

Browse files
committed
feat: add custom context support to MCP SDK
- Add customContext field to MessageExtraInfo interface - Pass customContext through RequestHandlerExtra to handlers - Update InMemoryTransport to support custom context - Add unit test to verify custom context propagation - Enable transport implementations to inject arbitrary context data This allows MCP server implementations to access custom context (e.g., tenant ID, user info, feature flags) passed from the transport layer to tool/resource/prompt handlers.
1 parent 222db4a commit 73f75ae

File tree

8 files changed

+171
-16
lines changed

8 files changed

+171
-16
lines changed

src/inMemory.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { Transport } from "./shared/transport.js";
2-
import { JSONRPCMessage, RequestId } from "./types.js";
2+
import { JSONRPCMessage, RequestId, MessageExtraInfo } from "./types.js";
33
import { AuthInfo } from "./server/auth/types.js";
44

55
interface QueuedMessage {
66
message: JSONRPCMessage;
7-
extra?: { authInfo?: AuthInfo };
7+
extra?: MessageExtraInfo;
88
}
99

1010
/**
@@ -13,10 +13,11 @@ interface QueuedMessage {
1313
export class InMemoryTransport implements Transport {
1414
private _otherTransport?: InMemoryTransport;
1515
private _messageQueue: QueuedMessage[] = [];
16+
private _customContext?: Record<string, unknown>;
1617

1718
onclose?: () => void;
1819
onerror?: (error: Error) => void;
19-
onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void;
20+
onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
2021
sessionId?: string;
2122

2223
/**
@@ -34,7 +35,12 @@ export class InMemoryTransport implements Transport {
3435
// Process any messages that were queued before start was called
3536
while (this._messageQueue.length > 0) {
3637
const queuedMessage = this._messageQueue.shift()!;
37-
this.onmessage?.(queuedMessage.message, queuedMessage.extra);
38+
// Merge custom context with queued extra info
39+
const enhancedExtra: MessageExtraInfo = {
40+
...queuedMessage.extra,
41+
customContext: this._customContext
42+
};
43+
this.onmessage?.(queuedMessage.message, enhancedExtra);
3844
}
3945
}
4046

@@ -46,18 +52,45 @@ export class InMemoryTransport implements Transport {
4652
}
4753

4854
/**
49-
* Sends a message with optional auth info.
50-
* This is useful for testing authentication scenarios.
55+
* Sends a message with optional extra info.
56+
* This is useful for testing authentication scenarios and custom context.
57+
*
58+
* @deprecated The authInfo parameter is deprecated. Use MessageExtraInfo instead.
5159
*/
52-
async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId, authInfo?: AuthInfo }): Promise<void> {
60+
async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId, authInfo?: AuthInfo } | MessageExtraInfo): Promise<void> {
5361
if (!this._otherTransport) {
5462
throw new Error("Not connected");
5563
}
5664

65+
// Handle both old and new API formats
66+
let extra: MessageExtraInfo | undefined;
67+
if (options && 'authInfo' in options && !('requestInfo' in options)) {
68+
// Old API format - convert to new format
69+
extra = { authInfo: options.authInfo };
70+
} else if (options && ('requestInfo' in options || 'customContext' in options || 'authInfo' in options)) {
71+
// New API format
72+
extra = options as MessageExtraInfo;
73+
} else if (options && 'authInfo' in options) {
74+
// Old API with authInfo
75+
extra = { authInfo: options.authInfo };
76+
}
77+
5778
if (this._otherTransport.onmessage) {
58-
this._otherTransport.onmessage(message, { authInfo: options?.authInfo });
79+
// Merge the other transport's custom context with the extra info
80+
const enhancedExtra: MessageExtraInfo = {
81+
...extra,
82+
customContext: this._otherTransport._customContext
83+
};
84+
this._otherTransport.onmessage(message, enhancedExtra);
5985
} else {
60-
this._otherTransport._messageQueue.push({ message, extra: { authInfo: options?.authInfo } });
86+
this._otherTransport._messageQueue.push({ message, extra });
6187
}
6288
}
89+
90+
/**
91+
* Sets custom context data that will be passed to all message handlers.
92+
*/
93+
setCustomContext(context: Record<string, unknown>): void {
94+
this._customContext = context;
95+
}
6396
}

src/server/mcp.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1433,6 +1433,64 @@ describe("tool()", () => {
14331433
expect(result.content && result.content[0].text).toContain("Received request ID:");
14341434
});
14351435

1436+
/***
1437+
* Test: Pass Custom Context to Tool Callback
1438+
*/
1439+
test("should pass customContext to tool callback via RequestHandlerExtra", async () => {
1440+
const mcpServer = new McpServer({
1441+
name: "test server",
1442+
version: "1.0",
1443+
});
1444+
1445+
const client = new Client({
1446+
name: "test client",
1447+
version: "1.0",
1448+
});
1449+
1450+
let receivedCustomContext: Record<string, unknown> | undefined;
1451+
mcpServer.tool("custom-context-test", async (extra) => {
1452+
receivedCustomContext = extra.customContext;
1453+
return {
1454+
content: [
1455+
{
1456+
type: "text",
1457+
text: `Custom context: ${JSON.stringify(extra.customContext)}`,
1458+
},
1459+
],
1460+
};
1461+
});
1462+
1463+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
1464+
1465+
// Use the new setCustomContext method to inject custom context
1466+
serverTransport.setCustomContext({
1467+
tenantId: "test-tenant-123",
1468+
featureFlags: { newFeature: true },
1469+
customData: "test-value"
1470+
});
1471+
1472+
await Promise.all([
1473+
client.connect(clientTransport),
1474+
mcpServer.server.connect(serverTransport),
1475+
]);
1476+
1477+
const result = await client.request(
1478+
{
1479+
method: "tools/call",
1480+
params: {
1481+
name: "custom-context-test",
1482+
},
1483+
},
1484+
CallToolResultSchema,
1485+
);
1486+
1487+
expect(receivedCustomContext).toBeDefined();
1488+
expect(receivedCustomContext?.tenantId).toBe("test-tenant-123");
1489+
expect(receivedCustomContext?.featureFlags).toEqual({ newFeature: true });
1490+
expect(receivedCustomContext?.customData).toBe("test-value");
1491+
expect(result.content && result.content[0].text).toContain("test-tenant-123");
1492+
});
1493+
14361494
/***
14371495
* Test: Send Notification within Tool Call
14381496
*/

src/server/sse.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export class SSEServerTransport implements Transport {
4141
private _sseResponse?: ServerResponse;
4242
private _sessionId: string;
4343
private _options: SSEServerTransportOptions;
44+
private _customContext?: Record<string, unknown>;
4445
onclose?: () => void;
4546
onerror?: (error: Error) => void;
4647
onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
@@ -191,7 +192,12 @@ export class SSEServerTransport implements Transport {
191192
throw error;
192193
}
193194

194-
this.onmessage?.(parsedMessage, extra);
195+
// Merge custom context with the extra info
196+
const enhancedExtra: MessageExtraInfo = {
197+
...extra,
198+
customContext: this._customContext
199+
};
200+
this.onmessage?.(parsedMessage, enhancedExtra);
195201
}
196202

197203
async close(): Promise<void> {
@@ -218,4 +224,11 @@ export class SSEServerTransport implements Transport {
218224
get sessionId(): string {
219225
return this._sessionId;
220226
}
227+
228+
/**
229+
* Sets custom context data that will be passed to all message handlers.
230+
*/
231+
setCustomContext(context: Record<string, unknown>): void {
232+
this._customContext = context;
233+
}
221234
}

src/server/stdio.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import process from "node:process";
22
import { Readable, Writable } from "node:stream";
33
import { ReadBuffer, serializeMessage } from "../shared/stdio.js";
4-
import { JSONRPCMessage } from "../types.js";
4+
import { JSONRPCMessage, MessageExtraInfo } from "../types.js";
55
import { Transport } from "../shared/transport.js";
66

77
/**
@@ -12,6 +12,7 @@ import { Transport } from "../shared/transport.js";
1212
export class StdioServerTransport implements Transport {
1313
private _readBuffer: ReadBuffer = new ReadBuffer();
1414
private _started = false;
15+
private _customContext?: Record<string, unknown>;
1516

1617
constructor(
1718
private _stdin: Readable = process.stdin,
@@ -20,7 +21,7 @@ export class StdioServerTransport implements Transport {
2021

2122
onclose?: () => void;
2223
onerror?: (error: Error) => void;
23-
onmessage?: (message: JSONRPCMessage) => void;
24+
onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
2425

2526
// Arrow functions to bind `this` properly, while maintaining function identity.
2627
_ondata = (chunk: Buffer) => {
@@ -54,7 +55,11 @@ export class StdioServerTransport implements Transport {
5455
break;
5556
}
5657

57-
this.onmessage?.(message);
58+
// Pass custom context to message handlers
59+
const extra: MessageExtraInfo = {
60+
customContext: this._customContext
61+
};
62+
this.onmessage?.(message, extra);
5863
} catch (error) {
5964
this.onerror?.(error as Error);
6065
}
@@ -89,4 +94,11 @@ export class StdioServerTransport implements Transport {
8994
}
9095
});
9196
}
97+
98+
/**
99+
* Sets custom context data that will be passed to all message handlers.
100+
*/
101+
setCustomContext(context: Record<string, unknown>): void {
102+
this._customContext = context;
103+
}
92104
}

src/server/streamableHttp.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ export class StreamableHTTPServerTransport implements Transport {
143143
private _allowedHosts?: string[];
144144
private _allowedOrigins?: string[];
145145
private _enableDnsRebindingProtection: boolean;
146+
private _customContext?: Record<string, unknown>;
146147

147148
sessionId?: string;
148149
onclose?: () => void;
@@ -487,7 +488,12 @@ export class StreamableHTTPServerTransport implements Transport {
487488

488489
// handle each message
489490
for (const message of messages) {
490-
this.onmessage?.(message, { authInfo, requestInfo });
491+
const enhancedExtra: MessageExtraInfo = {
492+
authInfo,
493+
requestInfo,
494+
customContext: this._customContext
495+
};
496+
this.onmessage?.(message, enhancedExtra);
491497
}
492498
} else if (hasRequests) {
493499
// The default behavior is to use SSE streaming
@@ -522,7 +528,12 @@ export class StreamableHTTPServerTransport implements Transport {
522528

523529
// handle each message
524530
for (const message of messages) {
525-
this.onmessage?.(message, { authInfo, requestInfo });
531+
const enhancedExtra: MessageExtraInfo = {
532+
authInfo,
533+
requestInfo,
534+
customContext: this._customContext
535+
};
536+
this.onmessage?.(message, enhancedExtra);
526537
}
527538
// The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses
528539
// This will be handled by the send() method when responses are ready
@@ -748,5 +759,12 @@ export class StreamableHTTPServerTransport implements Transport {
748759
}
749760
}
750761
}
762+
763+
/**
764+
* Sets custom context data that will be passed to all message handlers.
765+
*/
766+
setCustomContext(context: Record<string, unknown>): void {
767+
this._customContext = context;
768+
}
751769
}
752770

src/shared/protocol.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,13 @@ export type RequestHandlerExtra<SendRequestT extends Request,
141141
*/
142142
requestInfo?: RequestInfo;
143143

144+
/**
145+
* Custom context data passed from the transport layer.
146+
* This allows transport implementations to attach arbitrary data that will be
147+
* available to request handlers.
148+
*/
149+
customContext?: Record<string, unknown>;
150+
144151
/**
145152
* Sends a notification that relates to the current request being handled.
146153
*
@@ -399,7 +406,8 @@ export abstract class Protocol<
399406
this.request(r, resultSchema, { ...options, relatedRequestId: request.id }),
400407
authInfo: extra?.authInfo,
401408
requestId: request.id,
402-
requestInfo: extra?.requestInfo
409+
requestInfo: extra?.requestInfo,
410+
customContext: extra?.customContext
403411
};
404412

405413
// Starting with Promise.resolve() puts any synchronous errors into the monad as well.

src/shared/transport.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,10 @@ export interface Transport {
8282
* Sets the protocol version used for the connection (called when the initialize response is received).
8383
*/
8484
setProtocolVersion?: (version: string) => void;
85+
86+
/**
87+
* Sets custom context data that will be passed to all message handlers.
88+
* This context will be included in the MessageExtraInfo passed to handlers.
89+
*/
90+
setCustomContext?: (context: Record<string, unknown>) => void;
8591
}

src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1512,6 +1512,13 @@ export interface MessageExtraInfo {
15121512
* The authentication information.
15131513
*/
15141514
authInfo?: AuthInfo;
1515+
1516+
/**
1517+
* Custom context data that can be passed through the message handling pipeline.
1518+
* This allows transport implementations to attach arbitrary data that will be
1519+
* available to request handlers.
1520+
*/
1521+
customContext?: Record<string, unknown>;
15151522
}
15161523

15171524
/* JSON-RPC types */

0 commit comments

Comments
 (0)