Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/core/src/types/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,14 @@ export const InitializeResultSchema = ResultSchema.extend({
instructions: z.string().optional()
});

/**
* SEP-2575 `server/discover` result. Same shape as {@linkcode InitializeResultSchema} but
* without the negotiated `protocolVersion` field; the client asserts the version per request
* via `_meta` instead of via a stateful handshake. Stateless: the server writes no instance
* fields when handling this.
*/
export const ServerDiscoverResultSchema = InitializeResultSchema.omit({ protocolVersion: true });

/**
* This notification is sent from the client to the server after initialization has finished.
*/
Expand All @@ -552,6 +560,17 @@ export const InitializedNotificationSchema = NotificationSchema.extend({
params: NotificationsParamsSchema.optional()
});

/* Discover (SEP-2575) */
/**
* SEP-2575 stateless capability discovery. The server responds with its capabilities,
* info, and instructions; no protocol-version negotiation occurs (the client asserts the
* version per request via `_meta`).
*/
export const ServerDiscoverRequestSchema = RequestSchema.extend({
method: z.literal('server/discover'),
params: BaseRequestParamsSchema.optional()
});

/* Ping */
/**
* A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected.
Expand Down Expand Up @@ -2079,6 +2098,7 @@ export const RootsListChangedNotificationSchema = NotificationSchema.extend({
export const ClientRequestSchema = z.union([
PingRequestSchema,
InitializeRequestSchema,
ServerDiscoverRequestSchema,
CompleteRequestSchema,
SetLevelRequestSchema,
GetPromptRequestSchema,
Expand Down Expand Up @@ -2159,6 +2179,7 @@ export const ServerResultSchema = z.union([
const resultSchemas: Record<string, z.core.$ZodType> = {
ping: EmptyResultSchema,
initialize: InitializeResultSchema,
'server/discover': ServerDiscoverResultSchema,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit: ServerDiscoverRequestSchema was added to the ClientRequestSchema union and ServerDiscoverResultSchema was added to the resultSchemas map, but ServerDiscoverResultSchema was not added to the ServerResultSchema union just above. Since ServerDiscoverResult omits the required protocolVersion field it is not covered by InitializeResultSchema, so the public ServerResult type / isSpecType.ServerResult exclude valid server/discover responses. Low impact (per-method validation goes through getResultSchema, which is wired), but worth a one-line addition for symmetry.

Extended reasoning...

What the issue is

The PR wires the new server/discover types into three of the four parallel registries in packages/core/src/types/schemas.ts but misses one:

Registry Updated?
ClientRequestSchema union ServerDiscoverRequestSchema added
resultSchemas map (used by getResultSchema) 'server/discover': ServerDiscoverResultSchema added
ResultTypeMap (types.ts) 'server/discover': ServerDiscoverResult added
ServerResultSchema union not added

ServerDiscoverResultSchema is defined as InitializeResultSchema.omit({ protocolVersion: true }). Because InitializeResultSchema requires protocolVersion: z.string() (non-optional), a discover result { capabilities, serverInfo, instructions? } is not structurally covered by the existing InitializeResultSchema member of the union, so the omission isn't redundant.

How it manifests

The ServerResultSchema union drives two public surfaces:

  1. type ServerResult in types.tsInfer<typeof ServerResultSchema>. The exported ServerResult TypeScript type does not include the ServerDiscoverResult variant.
  2. specTypeSchemas.ServerResult / isSpecType.ServerResult in specTypeSchema.ts — runtime validators built from the same union.

So a consumer using isSpecType.ServerResult(x) or narrowing on ServerResult won't see the discover variant as a first-class member.

Step-by-step

Take a valid server/discover response:

const r = { capabilities: { tools: {} }, serverInfo: { name: 'disc', version: '2.0.0' }, instructions: 'hello' };

Walk it through each ServerResultSchema union member:

  • EmptyResultSchema.strict(), rejects capabilities/serverInfo extras → ❌
  • InitializeResultSchema — requires protocolVersion: string → ❌
  • CompleteResultSchema / GetPromptResultSchema / List*ResultSchema / ReadResourceResultSchema / ListToolsResultSchema / GetTaskResultSchema / ListTasksResultSchema / CreateTaskResultSchema — each requires a field (completion, messages, prompts, resources, contents, tools, taskId/status, tasks, task) that the discover result lacks → ❌
  • CallToolResultSchema — extends loose ResultSchema with content: z.array(...).default([]), so it accepts r and injects content: [] → ✅ but mis-typed

So at runtime ServerResultSchema.safeParse(r) actually succeeds via the CallToolResultSchema branch (loose object + defaulted content), which means there's no runtime rejection — the practical effect is purely that the discover variant is missing from the static ServerResult type and from the intended union semantics.

Why existing code doesn't prevent it

There's no compile-time check tying ServerResultSchema's members to resultSchemas' values, and ServerResultSchema already has pre-existing drift (CancelTaskResultSchema and GetTaskPayloadResultSchema are in resultSchemas and in the spec mirror's ServerResult but not in this union), so the asymmetry slipped through. The vendored spec.types.ts also doesn't yet carry ServerDiscover*, so there's no spec-mirror diff to flag it either.

Impact

Low. The SDK's actual response validation path is getResultSchema(method)resultSchemas['server/discover'], which is wired, so nothing in Protocol/Dispatcher breaks. The only observable effects are on the public ServerResult type and on specTypeSchemas.ServerResult / isSpecType.ServerResult, and even there the loose CallToolResultSchema member happens to accept the payload at runtime. This is the repo's "Completeness — partial migration" pattern: a new type wired into one sibling registry but not the other.

Fix

One line — add ServerDiscoverResultSchema to the ServerResultSchema z.union([...]), e.g. right after InitializeResultSchema:

export const ServerResultSchema = z.union([
    EmptyResultSchema,
    InitializeResultSchema,
    ServerDiscoverResultSchema,
    CompleteResultSchema,
    ...
]);

'completion/complete': CompleteResultSchema,
'logging/setLevel': EmptyResultSchema,
'prompts/get': GetPromptResultSchema,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/types/specTypeSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ const SPEC_SCHEMA_KEYS = [
'SamplingMessageSchema',
'SamplingMessageContentBlockSchema',
'ServerCapabilitiesSchema',
'ServerDiscoverRequestSchema',
'ServerDiscoverResultSchema',
'ServerNotificationSchema',
'ServerRequestSchema',
'ServerResultSchema',
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ import type {
SamplingMessageContentBlockSchema,
SamplingMessageSchema,
ServerCapabilitiesSchema,
ServerDiscoverRequestSchema,
ServerDiscoverResultSchema,
ServerNotificationSchema,
ServerRequestSchema,
ServerResultSchema,
Expand Down Expand Up @@ -259,6 +261,8 @@ export type InitializeRequestParams = Infer<typeof InitializeRequestParamsSchema
export type InitializeRequest = Infer<typeof InitializeRequestSchema>;
export type ServerCapabilities = Infer<typeof ServerCapabilitiesSchema>;
export type InitializeResult = Infer<typeof InitializeResultSchema>;
export type ServerDiscoverRequest = Infer<typeof ServerDiscoverRequestSchema>;
export type ServerDiscoverResult = Infer<typeof ServerDiscoverResultSchema>;
export type InitializedNotification = Infer<typeof InitializedNotificationSchema>;

/* Ping */
Expand Down Expand Up @@ -420,6 +424,7 @@ export type NotificationTypeMap = MethodToTypeMap<ClientNotification | ServerNot
export type ResultTypeMap = {
ping: EmptyResult;
initialize: InitializeResult;
'server/discover': ServerDiscoverResult;
'completion/complete': CompleteResult;
'logging/setLevel': EmptyResult;
'prompts/get': GetPromptResult;
Expand Down
60 changes: 48 additions & 12 deletions packages/server/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@
Result,
ServerCapabilities,
ServerContext,
ServerDiscoverResult,
StandardSchemaV1,
ToolResultContent,
ToolUseContent
} from '@modelcontextprotocol/core';
import {
CallToolRequestSchema,
CallToolResultSchema,
ClientCapabilitiesSchema,
CreateMessageResultSchema,
CreateMessageResultWithToolsSchema,
ElicitResultSchema,
Expand Down Expand Up @@ -116,6 +118,7 @@
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator();

this.setRequestHandler('initialize', request => this._oninitialize(request));
this.setRequestHandler('server/discover', () => this._ondiscover());
this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.());

if (this._capabilities.logging) {
Expand All @@ -140,18 +143,28 @@
// Only create http when there's actual HTTP transport info or auth info
const hasHttpInfo = ctx.http || transportInfo?.request || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream;
const sendOpts = (options?: RequestOptions): RequestOptions => ({ ...options, relatedRequestId: ctx.mcpReq.id });
// SEP-2575: prefer per-request peer scope (lifted from `_meta` by S4) over the
// singleton handshake state. Falls back to the singleton for the connect() path.
// Normalize the per-request value: it may arrive raw from a transport adapter
// (e.g. SessionCompat stores `initialize.params.capabilities` off the wire), and
// ElicitationCapabilitySchema's preprocess (e.g. `{}` -> `{form:{}}`) must run
// before the `_elicitInputVia` capability gate reads `caps.elicitation.form`.
const rawReqCaps = ctx.mcpReq.clientCapabilities;
const reqCaps =
rawReqCaps === undefined ? this._clientCapabilities : (ClientCapabilitiesSchema.safeParse(rawReqCaps).data ?? rawReqCaps);
const reqLogLevel = ctx.mcpReq.logLevel;
return {
...ctx,
mcpReq: {
...ctx.mcpReq,
log: async (level, data, logger) => {
if (this._capabilities.logging && !this.isMessageIgnored(level, ctx.sessionId)) {
if (this._capabilities.logging && !this.isMessageIgnored(level, ctx.sessionId, reqLogLevel)) {
await ctx.mcpReq.notify({ method: 'notifications/message', params: { level, data, logger } });
}
},
elicitInput: (params, options) => this._elicitInputVia(ctx.mcpReq.send, params, sendOpts(options)),
requestSampling: (params, options) => this._createMessageVia(ctx.mcpReq.send, params, sendOpts(options))
elicitInput: (params, options) => this._elicitInputVia(ctx.mcpReq.send, reqCaps, params, sendOpts(options)),
requestSampling: (params, options) => this._createMessageVia(ctx.mcpReq.send, reqCaps, params, sendOpts(options))
Comment thread
felixweinberger marked this conversation as resolved.
},

Check warning on line 167 in packages/server/src/server/server.ts

View check run for this annotation

Claude / Claude Code Review

Normalized reqCaps not exposed via ctx.mcpReq.clientCapabilities

nit: `buildContext` computes a normalized `reqCaps` (via `ClientCapabilitiesSchema.safeParse` + singleton fallback) and threads it into `_elicitInputVia`/`_createMessageVia`, but the returned `mcpReq` spreads `...ctx.mcpReq` without overriding `clientCapabilities` — so handlers that follow the JSDoc on `BaseContext.mcpReq.clientCapabilities` ("Prefer this over instance-level `_clientCapabilities` for per-request checks") still see the raw adapter-env value (or `undefined` on the connect() path)
Comment on lines 156 to 167
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit: buildContext computes a normalized reqCaps (via ClientCapabilitiesSchema.safeParse + singleton fallback) and threads it into _elicitInputVia/_createMessageVia, but the returned mcpReq spreads ...ctx.mcpReq without overriding clientCapabilities — so handlers that follow the JSDoc on BaseContext.mcpReq.clientCapabilities ("Prefer this over instance-level _clientCapabilities for per-request checks") still see the raw adapter-env value (or undefined on the connect() path) and can disagree with what ctx.mcpReq.elicitInput() actually allows. One-line fix: add clientCapabilities: reqCaps, after ...ctx.mcpReq,. (Residual surface of the resolved comment 3228255133 — option (c) was applied to the internal gate only.)

Extended reasoning...

What the issue is

a9cd645 fixes the cap-gate regression by computing a normalized per-request capabilities value in Server.buildContext:

const rawReqCaps = ctx.mcpReq.clientCapabilities;
const reqCaps =
    rawReqCaps === undefined ? this._clientCapabilities : (ClientCapabilitiesSchema.safeParse(rawReqCaps).data ?? rawReqCaps);

and threads reqCaps into _elicitInputVia / _createMessageVia. However, the returned context is:

mcpReq: {
    ...ctx.mcpReq,
    log: ...,
    elicitInput: ...,
    requestSampling: ...
}

The spread carries ctx.mcpReq.clientCapabilities through unchanged — reqCaps is never written back to the handler-visible field. So the internal SDK gate now sees the normalized value, but the public ctx.mcpReq.clientCapabilities field that handlers read still holds the raw dispatcher value.

Why this is the documented surface

The JSDoc on BaseContext.mcpReq.clientCapabilities (packages/core/src/shared/context.ts:182-187) explicitly tells SDK users:

Prefer this over instance-level _clientCapabilities for per-request checks on stateless servers.

So a handler that hand-checks a sub-capability before calling the helper — e.g. if (ctx.mcpReq.clientCapabilities?.elicitation?.form) { await ctx.mcpReq.elicitInput(...) } — is following the documented guidance, but reads a different value from the one ctx.mcpReq.elicitInput() itself uses.

The two divergent paths

  1. Adapter-env path (handleHttp + SessionCompat / shttpHandler): env.clientCapabilities is the raw wire object (isInitializeRequest is a boolean guard, no Zod transform), and dispatcher.ts assigns it to mcpReq.clientCapabilities unparsed. a9cd645 normalizes it into the local reqCaps, but the spread keeps the raw value in the field handlers see.
  2. connect() path: ctx.mcpReq.clientCapabilities is undefined (no _meta, no adapter env). reqCaps falls back to this._clientCapabilities (the Zod-parsed singleton), but the handler-visible field stays undefined.

In both cases the handler-visible field and the value the SDK helpers actually use can differ.

Step-by-step proof

  1. handleHttp deployment with SessionCompat. Client sends initialize with capabilities: { elicitation: {} } (spec-valid legacy shape). SessionCompat stores { elicitation: {} } raw.
  2. Same session sends tools/call. shttpHandler populates baseEnv.clientCapabilities = { elicitation: {} }; dispatcher assigns it to ctx.mcpReq.clientCapabilities unchanged.
  3. Server.buildContext runs: rawReqCaps = { elicitation: {} }; ClientCapabilitiesSchema.safeParse applies ElicitationCapabilitySchema's preprocess → reqCaps = { elicitation: { form: {} } }.
  4. Returned mcpReq: { ...ctx.mcpReq, log, elicitInput, requestSampling }mcpReq.clientCapabilities is still the raw { elicitation: {} }.
  5. Handler reads ctx.mcpReq.clientCapabilities?.elicitation?.formundefined → skips elicitation.
  6. Had the handler instead called ctx.mcpReq.elicitInput(...) directly, _elicitInputVia would evaluate !reqCaps.elicitation.form!{}false → request proceeds.

Same input, two answers. The connect()-path variant is similar: handler sees undefined, helper sees the populated singleton.

Why existing code doesn't prevent it

readMetaRequestScope (d887785) already normalizes the _meta-lifted value, so on the pure stateless-_meta path the field and reqCaps agree. The divergence is specific to (a) adapter-env (where dispatcher spreads adapter caps after readMetaRequestScope and never parses them) and (b) the singleton fallback. a9cd645 closed both for the internal gate but not for the exposed field — option (c) from review comment 3228255133 was applied to the gate's input only.

Impact

Low / nit. This is not a regression in the field itself — ctx.mcpReq.clientCapabilities was already raw on the adapter-env path and undefined on connect() before this PR. The SDK's own helpers (elicitInput/requestSampling) work correctly post-a9cd645. The only affected scenario is user code that manually inspects sub-capability shape on the documented field instead of (or before) calling the helper, which is narrow but explicitly endorsed by the JSDoc.

How to fix

One line in buildContext's returned object:

mcpReq: {
    ...ctx.mcpReq,
    clientCapabilities: reqCaps,
    log: ...,
    elicitInput: ...,
    requestSampling: ...
}

This makes the handler-visible field carry the same normalized + singleton-fallback value the SDK helpers use, keeping the documented contract consistent with actual behavior.

http: hasHttpInfo
? {
...ctx.http,
Expand Down Expand Up @@ -186,8 +199,9 @@
private readonly LOG_LEVEL_SEVERITY = new Map(LoggingLevelSchema.options.map((level, index) => [level, index]));

// Is a message with the given level ignored in the log level set for the given session id?
private isMessageIgnored = (level: LoggingLevel, sessionId?: string): boolean => {
const currentLevel = this._loggingLevels.get(sessionId);
private isMessageIgnored = (level: LoggingLevel, sessionId?: string, requestLevel?: LoggingLevel): boolean => {
// SEP-2575: per-request `_meta.logLevel` (S4) takes precedence over session-stored level.
const currentLevel = requestLevel ?? this._loggingLevels.get(sessionId);
return currentLevel ? this.LOG_LEVEL_SEVERITY.get(level)! < this.LOG_LEVEL_SEVERITY.get(currentLevel)! : false;
};

Expand Down Expand Up @@ -376,7 +390,8 @@
}

case 'ping':
case 'initialize': {
case 'initialize':
case 'server/discover': {
// No specific capability required for these methods
break;
}
Expand All @@ -395,8 +410,17 @@

this.transport?.setProtocolVersion?.(protocolVersion);

return { protocolVersion, ...this._ondiscover() };
}
Comment thread
felixweinberger marked this conversation as resolved.

/**
* SEP-2575 `server/discover` handler. Returns capabilities, server info and instructions
* without negotiating a protocol version (the client asserts the version per request via
* `_meta`). Stateless: writes no instance fields, so one Server can serve unlimited
* concurrent stateless clients on the dispatch() path.
*/
private _ondiscover(): ServerDiscoverResult {
return {
protocolVersion,
capabilities: this.getCapabilities(),
serverInfo: this._serverInfo,
...(this._instructions && { instructions: this._instructions })
Expand Down Expand Up @@ -454,7 +478,12 @@
params: CreateMessageRequest['params'],
options?: RequestOptions
): Promise<CreateMessageResult | CreateMessageResultWithTools> {
return this._createMessageVia((r, schema, opts) => this._requestWithSchema(r, schema, opts), params, options);
return this._createMessageVia(
(r, schema, opts) => this._requestWithSchema(r, schema, opts),
this._clientCapabilities,
params,
options
);
}

/**
Expand All @@ -469,13 +498,14 @@
*/
private async _createMessageVia(
send: SendWithSchema,
caps: ClientCapabilities | undefined,
Comment on lines 499 to +501
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The NOTE paragraph in this JSDoc is now stale: it still says the capability check "reads this._clientCapabilities, which is a singleton" and that "the per-request _meta.clientCapabilities flow fixes this in a follow-up", but this PR is that follow-up — the body now checks the injected caps parameter. The NOTE should be removed (or rewritten) so the doc no longer contradicts the implementation.

Extended reasoning...

What the issue is

The JSDoc on _createMessageVia (packages/server/src/server/server.ts:504-507) still contains:

NOTE: the capability check below reads this._clientCapabilities, which is a singleton (set on the most recent initialize). For multi-session handleHttp deployments this can read a different session's caps. The per-request _meta.clientCapabilities flow fixes this in a follow-up; until then, do not rely on the cap gate for isolation.

But this PR is exactly that follow-up. The diff changes the signature to:

private async _createMessageVia(
    send: SendWithSchema,
    caps: ClientCapabilities | undefined,
    ...
)

and the body now reads the injected parameter (if (!caps?.sampling), !caps.sampling.tools) rather than this._clientCapabilities. buildContext passes ctx.mcpReq.clientCapabilities ?? this._clientCapabilities into this slot, i.e. the per-request _meta.clientCapabilities flow the NOTE promised.

Why nothing prevents it

The PR edited the signature and body of _createMessageVia immediately below this comment but left the comment untouched. There's no lint or doc check that ties prose to implementation, so it silently went stale.

Step-by-step proof

  1. Before this PR, _createMessageVia had no caps parameter and the body read this._clientCapabilities?.sampling — the NOTE accurately described that.
  2. This PR adds caps: ClientCapabilities | undefined to the signature (server.ts:511) and rewrites the gate to if (!caps?.sampling) (server.ts:515).
  3. buildContext now computes reqCaps = ctx.mcpReq.clientCapabilities ?? this._clientCapabilities and threads it into _createMessageVia via requestSampling.
  4. Therefore the statement "the capability check below reads this._clientCapabilities" is false, and "fixes this in a follow-up; until then, do not rely on the cap gate for isolation" refers to a follow-up that has already landed in this very diff.

Impact

Documentation-only — no runtime effect. But the comment now actively misleads readers: it warns them not to rely on a cap gate that is, as of this PR, correctly per-request-scoped, and points them at a non-existent future fix. This is the pattern called out in the repo's Recurring Catches > Documentation & Changesets ("flag prose that now contradicts the implementation").

How to fix

Delete the NOTE paragraph (the four lines starting "NOTE: the capability check below…"). Optionally replace it with a one-liner noting that caps is the per-request resolved capabilities (_meta.clientCapabilities ?? singleton), but the new inline comment in buildContext already says that, so simply removing the stale NOTE is sufficient.

params: CreateMessageRequest['params'],
options?: RequestOptions
): Promise<CreateMessageResult | CreateMessageResultWithTools> {
// Base `sampling` capability is checked via assertCapabilityForMethod() (gated on
// enforceStrictCapabilities, which defaults to false). Only the `sampling.tools`
// sub-capability is enforced here unconditionally, matching the v1 createMessage body.
if ((params.tools || params.toolChoice) && !this._clientCapabilities?.sampling?.tools) {
if ((params.tools || params.toolChoice) && !caps?.sampling?.tools) {
throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support sampling tools capability.');
}

Expand Down Expand Up @@ -534,7 +564,12 @@
* @returns The result of the elicitation request.
*/
async elicitInput(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise<ElicitResult> {
return this._elicitInputVia((r, schema, opts) => this._requestWithSchema(r, schema, opts), params, options);
return this._elicitInputVia(
(r, schema, opts) => this._requestWithSchema(r, schema, opts),
this._clientCapabilities,
params,
options
);
}

/**
Expand All @@ -543,21 +578,22 @@
*/
private async _elicitInputVia(
send: SendWithSchema,
caps: ClientCapabilities | undefined,
params: ElicitRequestFormParams | ElicitRequestURLParams,
options?: RequestOptions
): Promise<ElicitResult> {
const mode = (params.mode ?? 'form') as 'form' | 'url';

switch (mode) {
case 'url': {
if (!this._clientCapabilities?.elicitation?.url) {
if (!caps?.elicitation?.url) {
throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support url elicitation.');
}
const urlParams = params as ElicitRequestURLParams;
return send({ method: 'elicitation/create', params: urlParams }, ElicitResultSchema, options);
}
case 'form': {
if (!this._clientCapabilities?.elicitation?.form) {
if (!caps?.elicitation?.form) {
throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support form elicitation.');
}
const formParams: ElicitRequestFormParams =
Expand Down
54 changes: 54 additions & 0 deletions packages/server/test/server/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,58 @@ describe('Server', () => {
await server.close();
});
});

describe('server/discover (SEP-2575)', () => {
it('returns capabilities/serverInfo/instructions without writing handshake state', async () => {
const server = new Server({ name: 'disc', version: '2.0.0' }, { capabilities: { tools: {} }, instructions: 'hello' });

const out: JSONRPCMessage[] = [];
for await (const o of server.dispatch({ jsonrpc: '2.0', id: 1, method: 'server/discover' })) {
out.push(o.message);
}
const last = out.at(-1) as { result?: unknown; error?: unknown };
expect(last.error).toBeUndefined();
expect(last.result).toMatchObject({
serverInfo: { name: 'disc', version: '2.0.0' },
capabilities: { tools: {} },
instructions: 'hello'
});
expect((last.result as Record<string, unknown>).protocolVersion).toBeUndefined();
expect(server.getClientCapabilities()).toBeUndefined();
});
});

describe('per-request clientCapabilities (SEP-2575)', () => {
it('ctx.mcpReq.elicitInput respects _meta.clientCapabilities over singleton', async () => {
const server = new Server({ name: 't', version: '1' }, { capabilities: { tools: {} } });
// No initialize: singleton _clientCapabilities is undefined.
let elicitErr: unknown;
server.setRequestHandler('tools/call', async (_req, ctx) => {
try {
await ctx.mcpReq.elicitInput({ message: 'm', requestedSchema: { type: 'object', properties: {} } });
} catch (e) {
elicitErr = e;
}
return { content: [] };
});

// First call: no _meta caps -> elicitInput should reject (no caps anywhere).
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _o of server.dispatch({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'x' } }));
expect(elicitErr).toBeDefined();
elicitErr = undefined;

// Second call: _meta carries elicitation.form -> capability check passes
// (the actual send will reject NotConnected since there's no env.send, but
// that proves we got past the cap check).
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _o of server.dispatch({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'x', _meta: { 'io.modelcontextprotocol/clientCapabilities': { elicitation: { form: {} } } } }
}));
expect(String(elicitErr)).not.toMatch(/CapabilityNotSupported|does not support/i);
});
});
});
Loading