Merge pull request #1179 from morluto/fix/session-client-metadata-2025

fix(server): report 2025-era client identity and features in binary_session
This commit is contained in:
Kaoru0822
2026-10-08 20:51:34 -07:00
committed by GitHub
4 changed files with 122 additions and 11 deletions
+27 -7
View File
@@ -2,25 +2,45 @@ import {
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
PROTOCOL_VERSION_META_KEY,
type McpServer,
type ServerContext,
} from "@modelcontextprotocol/server";
import type { ClientFeatureAvailability } from "../contracts/toolOutputSchemaPrimitives.js";
import type { ConnectedClientIdentity } from "../serverIdentity.js";
/** Client metadata bound to the current SDK request rather than server-global state. */
export const mcpClientMetadata = (context: {
readonly mcpReq: Pick<ServerContext["mcpReq"], "envelope">;
}) => {
/** Initialize-scoped client metadata the SDK keeps for a 2025-era connection. */
export type McpConnectionClientMetadata = Pick<
McpServer["server"],
"getClientCapabilities" | "getClientVersion" | "getNegotiatedProtocolVersion"
>;
/**
* Client metadata for the current request. A 2026-era request carries it in
* its per-request envelope; a 2025-era connection sends no envelope, so its
* `initialize` handshake supplies the values.
*/
export const mcpClientMetadata = (
context: { readonly mcpReq: Pick<ServerContext["mcpReq"], "envelope"> },
connection: McpConnectionClientMetadata,
) => {
const envelope = context.mcpReq.envelope;
// The SDK deprecates these accessors only in favor of the envelope, which
// 2025-era requests do not carry; they still return initialize values.
const value = (key: string, initialized: () => unknown): unknown =>
envelope === undefined ? initialized() : mcpEnvelopeValue(envelope, key);
const client = implementation(
mcpEnvelopeValue(envelope, CLIENT_INFO_META_KEY),
value(CLIENT_INFO_META_KEY, () => connection.getClientVersion()),
);
const protocolVersion = value(PROTOCOL_VERSION_META_KEY, () =>
connection.getNegotiatedProtocolVersion(),
);
const protocolVersion = mcpEnvelopeValue(envelope, PROTOCOL_VERSION_META_KEY);
return {
...(client === undefined ? {} : { client }),
clientFeatures: capabilityFeatures(
mcpEnvelopeValue(envelope, CLIENT_CAPABILITIES_META_KEY),
value(CLIENT_CAPABILITIES_META_KEY, () =>
connection.getClientCapabilities(),
),
),
...(typeof protocolVersion === "string" ? { protocolVersion } : {}),
};
+4 -2
View File
@@ -44,8 +44,10 @@ export const registerSessionStatusTool = (
contract.name,
toolRegistrationOptions(contract),
async (input, context) => {
const { client, clientFeatures, protocolVersion } =
mcpClientMetadata(context);
const { client, clientFeatures, protocolVersion } = mcpClientMetadata(
context,
server.server,
);
let androidAvailability: ProviderAvailability;
try {
androidAvailability = await androidAnalysisAvailability(
@@ -54,3 +54,91 @@ describe("per-request client capability reporting", () => {
},
);
});
describe("initialize-scoped client metadata", () => {
const statusFor = async (
capabilities: Record<string, unknown>,
meta?: Record<string, unknown>,
) => {
const session = createTestBinarySession(createCacheProvider([]));
const server = createServer(session, session);
const client = new Client(
{ name: "initialize-test", version: "2" },
{ capabilities },
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
return (
await client.callTool({
name: "binary_session",
arguments: {},
...(meta === undefined ? {} : { _meta: meta }),
})
).structuredContent;
} finally {
await Promise.allSettled([
client.close(),
server.close(),
session.close(),
]);
}
};
const allFeatures = {
elicitation: { form: {}, url: {} },
roots: {},
sampling: {},
};
it("reports a 2025-era client from its initialize handshake", async () => {
expect(await statusFor(allFeatures)).toMatchObject({
result: {
server_identity: {
client: { name: "initialize-test", version: "2" },
negotiated_protocol_version: expect.any(String),
},
client_features: {
elicitation_form: true,
elicitation_url: true,
roots: true,
sampling: true,
},
},
});
expect(await statusFor({})).toMatchObject({
result: {
client_features: {
elicitation_form: false,
elicitation_url: false,
roots: false,
sampling: false,
},
},
});
});
it("prefers a per-request envelope over initialize values", async () => {
expect(
await statusFor(allFeatures, {
[PROTOCOL_VERSION_META_KEY]: "2026-07-28",
[CLIENT_INFO_META_KEY]: { name: "envelope-test", version: "3" },
[CLIENT_CAPABILITIES_META_KEY]: {},
}),
).toMatchObject({
result: {
server_identity: {
client: { name: "envelope-test", version: "3" },
negotiated_protocol_version: "2026-07-28",
},
client_features: {
elicitation_form: false,
elicitation_url: false,
roots: false,
sampling: false,
},
},
});
});
});
+3 -2
View File
@@ -367,7 +367,8 @@ const assertLiveIdentity = async (client: Client): Promise<void> => {
server: SDK_IDENTITY.server,
client_test: PACKAGE_METADATA.clientSdkVersion,
},
client: null,
client: { name: "identity-test", version: "9" },
negotiated_protocol_version: expect.any(String),
alignment: { state: "unknown" },
},
},
@@ -421,7 +422,7 @@ const assertSessionIdentity = async (client: Client): Promise<void> => {
}),
]),
client_features: {
elicitation_form: false,
elicitation_form: true,
elicitation_url: false,
roots: false,
sampling: false,