Skip to content

Commit 12be4ff

Browse files
feat(server): expose per-request client capabilities and info on the handler context
1 parent 9ef47d4 commit 12be4ff

2 files changed

Lines changed: 126 additions & 1 deletion

File tree

packages/server/src/server/server.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,12 @@ export class Server extends Protocol<ServerContext> {
164164
elicitInput: (params, options) => this.elicitInput(params, options),
165165
requestSampling: (params, options) => this.createMessage(params, options)
166166
},
167+
// Sourced from the handshake state retained at initialize - the only source that exists today.
168+
// Before the handshake completes (only `ping` is legal there), capabilities is `{}` and info undefined.
169+
client: {
170+
capabilities: this._clientCapabilities ?? {},
171+
info: this._clientVersion
172+
},
167173
http: hasHttpInfo
168174
? {
169175
...ctx.http,
@@ -442,13 +448,17 @@ export class Server extends Protocol<ServerContext> {
442448

443449
/**
444450
* After initialization has completed, this will be populated with the client's reported capabilities.
451+
*
452+
* Inside a request handler, prefer `ctx.client.capabilities`, which reads the same facts per request.
445453
*/
446454
getClientCapabilities(): ClientCapabilities | undefined {
447455
return this._clientCapabilities;
448456
}
449457

450458
/**
451459
* After initialization has completed, this will be populated with information about the client's name and version.
460+
*
461+
* Inside a request handler, prefer `ctx.client.info`, which reads the same facts per request.
452462
*/
453463
getClientVersion(): Implementation | undefined {
454464
return this._clientVersion;
@@ -459,7 +469,7 @@ export class Server extends Protocol<ServerContext> {
459469
* with the client (the version the server responded with during the initialize handshake), or
460470
* `undefined` before initialization.
461471
*/
462-
getNegotiatedProtocolVersion(): string | undefined {
472+
override getNegotiatedProtocolVersion(): string | undefined {
463473
return this._negotiatedProtocolVersion;
464474
}
465475

packages/server/test/server/server.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core';
2+
import type { ClientCapabilities, Implementation, ServerContext } from '@modelcontextprotocol/core';
23
import {
4+
DEFAULT_NEGOTIATED_PROTOCOL_VERSION,
35
InitializeResultSchema,
46
InMemoryTransport,
57
isJSONRPCResultResponse,
@@ -130,4 +132,117 @@ describe('Server', () => {
130132
await server.close();
131133
});
132134
});
135+
136+
describe('ctx.client / ctx.mcpReq.protocolVersion on the handler context', () => {
137+
/**
138+
* Connects the server, registers a ping handler that captures its ServerContext, drives the
139+
* initialize handshake (with the given client capabilities/info), then sends a ping so the
140+
* handler runs. Returns the captured context.
141+
*/
142+
async function captureContextAfterInitialize(
143+
server: Server,
144+
requestedVersion: string,
145+
clientCapabilities: ClientCapabilities,
146+
clientInfo: Implementation
147+
): Promise<ServerContext> {
148+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
149+
await server.connect(serverTransport);
150+
151+
let captured: ServerContext | undefined;
152+
server.setRequestHandler('ping', async (_request, ctx) => {
153+
captured = ctx;
154+
return {};
155+
});
156+
157+
await clientTransport.start();
158+
159+
const initResponse = new Promise<void>(resolve => {
160+
clientTransport.onmessage = () => resolve();
161+
});
162+
await clientTransport.send({
163+
jsonrpc: '2.0',
164+
id: 1,
165+
method: 'initialize',
166+
params: { protocolVersion: requestedVersion, capabilities: clientCapabilities, clientInfo }
167+
} as JSONRPCMessage);
168+
await initResponse;
169+
170+
const pingResponse = new Promise<void>(resolve => {
171+
clientTransport.onmessage = () => resolve();
172+
});
173+
await clientTransport.send({ jsonrpc: '2.0', id: 2, method: 'ping', params: {} } as JSONRPCMessage);
174+
await pingResponse;
175+
176+
if (!captured) {
177+
throw new Error('ping handler did not run');
178+
}
179+
return captured;
180+
}
181+
182+
it('exposes the client capabilities and info the client declared at initialize', async () => {
183+
const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} });
184+
const clientCapabilities: ClientCapabilities = { sampling: {}, roots: { listChanged: true } };
185+
const clientInfo: Implementation = { name: 'declaring-client', version: '2.3.4' };
186+
187+
const ctx = await captureContextAfterInitialize(server, LATEST_PROTOCOL_VERSION, clientCapabilities, clientInfo);
188+
189+
// The declared capabilities round-trip onto the context.
190+
expect(ctx.client.capabilities.sampling).toEqual({});
191+
expect(ctx.client.capabilities.roots).toEqual({ listChanged: true });
192+
expect(ctx.client.info).toEqual(clientInfo);
193+
// The per-request facts match the connection-scoped getters (same source of truth).
194+
expect(ctx.client.capabilities).toEqual(server.getClientCapabilities());
195+
expect(ctx.client.info).toEqual(server.getClientVersion());
196+
197+
await server.close();
198+
});
199+
200+
it('exposes the negotiated protocol version, including a pinned older version', async () => {
201+
const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} });
202+
203+
const ctx = await captureContextAfterInitialize(server, OLDER_SUPPORTED_VERSION, {}, { name: 'c', version: '1.0.0' });
204+
205+
expect(ctx.mcpReq.protocolVersion).toBe(OLDER_SUPPORTED_VERSION);
206+
expect(ctx.mcpReq.protocolVersion).toBe(server.getNegotiatedProtocolVersion());
207+
208+
await server.close();
209+
});
210+
211+
it('yields {}-shaped capabilities (not undefined) when the client declares no optional capabilities', async () => {
212+
const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} });
213+
214+
const ctx = await captureContextAfterInitialize(server, LATEST_PROTOCOL_VERSION, {}, { name: 'c', version: '1.0.0' });
215+
216+
expect(ctx.client.capabilities).toEqual({});
217+
expect(ctx.client.info).toEqual({ name: 'c', version: '1.0.0' });
218+
219+
await server.close();
220+
});
221+
222+
it('pre-initialize: ping before the handshake gets {} capabilities, undefined info, and the default version', async () => {
223+
const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} });
224+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
225+
await server.connect(serverTransport);
226+
227+
let captured: ServerContext | undefined;
228+
server.setRequestHandler('ping', async (_request, ctx) => {
229+
captured = ctx;
230+
return {};
231+
});
232+
233+
await clientTransport.start();
234+
const pingResponse = new Promise<void>(resolve => {
235+
clientTransport.onmessage = () => resolve();
236+
});
237+
// No initialize handshake first - only ping is legal pre-initialize.
238+
await clientTransport.send({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} } as JSONRPCMessage);
239+
await pingResponse;
240+
241+
expect(captured?.client.capabilities).toEqual({});
242+
expect(captured?.client.info).toBeUndefined();
243+
expect(captured?.mcpReq.protocolVersion).toBe(DEFAULT_NEGOTIATED_PROTOCOL_VERSION);
244+
245+
await server.close();
246+
});
247+
});
133248
});

0 commit comments

Comments
 (0)