Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/input-required-meta-passthrough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

Preserve `_meta` on `input_required` results. The 2026-07-28 decode seam rebuilt the payload from `inputRequests` and `requestState` only, so result-level metadata a server sent on an `input_required` result (including `io.modelcontextprotocol/serverInfo`) was dropped before an `allowInputRequired: true` caller could see it. `Result._meta` is a result-level field, so `input_required` carries it exactly like any other result.
27 changes: 27 additions & 0 deletions packages/client/test/client/inputRequiredEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,4 +371,31 @@ describe('manual mode', () => {

await client.close();
});

it('carries the result-level _meta through to the manual caller', async () => {
const { clientTx } = await scriptedModernServer(() => ({
resultType: 'input_required',
inputRequests: { github_login: ELICIT_ENTRY },
requestState: 'manual-state',
_meta: {
'io.modelcontextprotocol/serverInfo': { name: 'scripted-mrtr-server', version: '1.0.0' },
'example.com/displayHint': 'inline'
}
}));

const client = makeClient({ inputRequired: { autoFulfill: false } });
await client.connect(clientTx);

const first = (await client.callTool({ name: 'deploy', arguments: {} }, { allowInputRequired: true })) as unknown as Record<
string,
unknown
>;
expect(first.resultType).toBe('input_required');
expect(first._meta).toEqual({
'io.modelcontextprotocol/serverInfo': { name: 'scripted-mrtr-server', version: '1.0.0' },
'example.com/displayHint': 'inline'
});

await client.close();
});
});
11 changes: 8 additions & 3 deletions packages/core-internal/src/shared/inputRequiredEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* `droppedInputResponseKeys` context fields), and the named extension point.
*/
import { SdkError, SdkErrorCode } from '../errors/sdkErrors';
import type { InputRequiredResult, JSONRPCRequest, RequestMeta, Result } from '../types/types';
import type { InputRequiredResult, JSONRPCRequest, RequestMeta, Result, ResultMetaObject } from '../types/types';
import type { StandardSchemaV1 } from '../util/standardSchema';
import type { WireCodec } from '../wire/codec';
import type {
Expand Down Expand Up @@ -237,10 +237,15 @@ export function runInputRequiredFlow<T extends StandardSchemaV1>(
* codec's decoded payload — what an `allowInputRequired: true` caller
* receives instead of the auto-fulfilled complete result.
*/
export function manualInputRequiredValue(decoded: { inputRequests: Record<string, unknown>; requestState?: string }): InputRequiredResult {
export function manualInputRequiredValue(decoded: {
inputRequests: Record<string, unknown>;
requestState?: string;
_meta?: ResultMetaObject;
}): InputRequiredResult {
return {
resultType: 'input_required',
inputRequests: decoded.inputRequests as InputRequiredResult['inputRequests'],
...(decoded.requestState !== undefined && { requestState: decoded.requestState })
...(decoded.requestState !== undefined && { requestState: decoded.requestState }),
...(decoded._meta !== undefined && { _meta: decoded._meta })
};
}
3 changes: 3 additions & 0 deletions packages/core-internal/src/wire/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import type {
RequestMethod,
RequestTypeMap,
Result,
ResultMetaObject,
ResultTypeMap
} from '../types/types';
import { rev2025Codec } from './rev2025-11-25/codec';
Expand Down Expand Up @@ -147,6 +148,8 @@ export type DecodedResult =
*/
inputRequests: Record<string, unknown>;
requestState?: string;
/** Result-level `_meta`, carried through so a manual caller sees it. */
_meta?: ResultMetaObject;
}
| { kind: 'invalid'; error: SdkError };

Expand Down
8 changes: 6 additions & 2 deletions packages/core-internal/src/wire/rev2026-07-28/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import type * as z from 'zod/v4';

import { SdkError, SdkErrorCode } from '../../errors/sdkErrors';
import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, LOG_LEVEL_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../types/constants';
import type { CallToolResult, Implementation, Result } from '../../types/types';
import type { CallToolResult, Implementation, Result, ResultMetaObject } from '../../types/types';
import type { DecodedResult, EnvelopeIssue, LiftedWireMaterial, OutboundEnvelopeMaterial, ValidateOutcome, WireCodec } from '../codec';
import { appendTextFallbackForNonObject } from '../textFallback';
import { buildSchemas2026 } from './buildSchemas';
Expand Down Expand Up @@ -202,6 +202,9 @@ export const rev2026Codec: WireCodec & {
const rawInputRequests = raw['inputRequests'];
const inputRequests = isPlainObject(rawInputRequests) ? rawInputRequests : {};
const requestState = raw['requestState'];
// Carry result-level `_meta` through, parsed as on complete results (malformed drops to absent).
const metaParse = raw['_meta'] === undefined ? undefined : buildSchemas2026().ResultMetaSchema.safeParse(raw['_meta']);
const meta = metaParse?.success ? (metaParse.data as ResultMetaObject) : undefined;
if (Object.keys(inputRequests).length === 0 && typeof requestState !== 'string') {
// At-least-one rule, client side: with neither inputRequests
// nor requestState there is nothing to fulfil and nothing to
Expand All @@ -220,7 +223,8 @@ export const rev2026Codec: WireCodec & {
return {
kind: 'input_required',
inputRequests,
...(typeof requestState === 'string' && { requestState })
...(typeof requestState === 'string' && { requestState }),
...(meta !== undefined && { _meta: meta })
};
}
if (rawResultType !== 'complete') {
Expand Down
23 changes: 23 additions & 0 deletions packages/core-internal/test/shared/inputRequiredFunnel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,29 @@ describe('manual mode (allowInputRequired)', () => {
await protocol.close();
});

test('preserves the result-level _meta on the handed-back value', async () => {
const protocol = await wireWithRawResult({
...INPUT_REQUIRED_BODY,
_meta: { 'io.modelcontextprotocol/serverInfo': { name: 'peer', version: '1.0.0' }, 'example.com/displayHint': 'inline' }
});

const result = await protocol.request(
{ method: 'tools/call', params: { name: 'echo', arguments: {} } },
{
allowInputRequired: true
}
);

// `Result._meta` is a result-level field, so an `input_required` result
// carries it like any other complete result would.
expect((result as Record<string, unknown>)['_meta']).toEqual({
'io.modelcontextprotocol/serverInfo': { name: 'peer', version: '1.0.0' },
'example.com/displayHint': 'inline'
});

await protocol.close();
});

test('discrimination happens on the raw body, before the consumer-provided result schema runs', async () => {
const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY);

Expand Down
Loading