Skip to content

Commit 1308739

Browse files
committed
fix(server): isolate elicitation schema normalization
1 parent 0ad602d commit 1308739

4 files changed

Lines changed: 125 additions & 88 deletions

File tree

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate.
22
export const V2_PACKAGE_VERSIONS: Record<string, string> = {
3-
'@modelcontextprotocol/client': '^2.0.0-alpha.2',
4-
'@modelcontextprotocol/server': '^2.0.0-alpha.2',
5-
'@modelcontextprotocol/node': '^2.0.0-alpha.2',
6-
'@modelcontextprotocol/express': '^2.0.0-alpha.2',
7-
'@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2',
8-
'@modelcontextprotocol/core': '^2.0.0-alpha.0'
3+
'@modelcontextprotocol/client': '^2.0.0-alpha.3',
4+
'@modelcontextprotocol/server': '^2.0.0-alpha.3',
5+
'@modelcontextprotocol/node': '^2.0.0-alpha.3',
6+
'@modelcontextprotocol/express': '^2.0.0-alpha.3',
7+
'@modelcontextprotocol/server-legacy': '^2.0.0-alpha.3',
8+
'@modelcontextprotocol/core': '^2.0.0-alpha.1'
99
};
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import type { ElicitInputFormParams, ElicitRequestFormParams, StandardSchemaWithJSON } from '@modelcontextprotocol/core-internal';
2+
import {
3+
ElicitRequestFormParamsSchema,
4+
parseSchema,
5+
ProtocolError,
6+
ProtocolErrorCode,
7+
standardSchemaToJsonSchema
8+
} from '@modelcontextprotocol/core-internal';
9+
10+
export type NormalizedElicitInputFormParams = {
11+
params: ElicitRequestFormParams;
12+
standardSchema?: StandardSchemaWithJSON;
13+
};
14+
15+
function isJsonObject(value: unknown): value is Record<string, unknown> {
16+
return typeof value === 'object' && value !== null && !Array.isArray(value);
17+
}
18+
19+
const ZOD_REDUNDANT_FORMAT_PATTERNS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
20+
['email', new Set([String.raw`^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$`])],
21+
[
22+
'date',
23+
new Set([
24+
String.raw`^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$`
25+
])
26+
],
27+
[
28+
'date-time',
29+
new Set([
30+
String.raw`^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$`
31+
])
32+
]
33+
]);
34+
35+
function isRedundantFormatPattern(original: Record<string, unknown>, parsed: Record<string, unknown>, key: string): boolean {
36+
if (
37+
key !== 'pattern' ||
38+
typeof original.pattern !== 'string' ||
39+
parsed.type !== 'string' ||
40+
typeof parsed.format !== 'string' ||
41+
original.format !== parsed.format
42+
) {
43+
return false;
44+
}
45+
46+
return ZOD_REDUNDANT_FORMAT_PATTERNS.get(parsed.format)?.has(original.pattern) === true;
47+
}
48+
49+
function findStrippedJsonSchemaPaths(original: unknown, parsed: unknown, path = ''): string[] {
50+
if (Array.isArray(original) && Array.isArray(parsed)) {
51+
return original.flatMap((item, index) => findStrippedJsonSchemaPaths(item, parsed[index], `${path}[${index}]`));
52+
}
53+
54+
if (!isJsonObject(original) || !isJsonObject(parsed)) {
55+
return [];
56+
}
57+
58+
return Object.entries(original).flatMap(([key, value]) => {
59+
const childPath = path ? `${path}.${key}` : key;
60+
if (!Object.prototype.hasOwnProperty.call(parsed, key)) {
61+
if (isRedundantFormatPattern(original, parsed, key)) {
62+
return [];
63+
}
64+
return [childPath];
65+
}
66+
return findStrippedJsonSchemaPaths(value, parsed[key], childPath);
67+
});
68+
}
69+
70+
function isElicitInputSchema(
71+
schema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON
72+
): schema is StandardSchemaWithJSON {
73+
return typeof schema === 'object' && schema !== null && '~standard' in schema;
74+
}
75+
76+
export function normalizeElicitInputFormParams(
77+
params: ElicitRequestFormParams | ElicitInputFormParams<StandardSchemaWithJSON>
78+
): NormalizedElicitInputFormParams {
79+
const formParams =
80+
params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' as const };
81+
82+
if (isElicitInputSchema(formParams.requestedSchema)) {
83+
const standardSchema = formParams.requestedSchema;
84+
const normalizedParams = {
85+
...formParams,
86+
requestedSchema: standardSchemaToJsonSchema(standardSchema, 'input')
87+
};
88+
const parsedParams = parseSchema(ElicitRequestFormParamsSchema, normalizedParams);
89+
if (!parsedParams.success) {
90+
throw new ProtocolError(
91+
ProtocolErrorCode.InvalidParams,
92+
`Elicitation requestedSchema only supports flat primitive properties (string, number, integer, boolean, and string enums): ${parsedParams.error.message}`
93+
);
94+
}
95+
const strippedSchemaPaths = findStrippedJsonSchemaPaths(normalizedParams.requestedSchema, parsedParams.data.requestedSchema);
96+
if (strippedSchemaPaths.length > 0) {
97+
throw new ProtocolError(
98+
ProtocolErrorCode.InvalidParams,
99+
`Elicitation requestedSchema contains unsupported JSON Schema keyword(s) after Standard Schema conversion: ${strippedSchemaPaths.join(', ')}`
100+
);
101+
}
102+
return { params: parsedParams.data, standardSchema };
103+
}
104+
105+
return { params: formParams };
106+
}

packages/server/src/server/server.ts

Lines changed: 3 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ import {
3939
CallToolResultSchema,
4040
CreateMessageResultSchema,
4141
CreateMessageResultWithToolsSchema,
42-
ElicitRequestFormParamsSchema,
4342
ElicitResultSchema,
4443
EmptyResultSchema,
4544
LATEST_PROTOCOL_VERSION,
@@ -52,11 +51,12 @@ import {
5251
ProtocolErrorCode,
5352
SdkError,
5453
SdkErrorCode,
55-
standardSchemaToJsonSchema,
5654
validateStandardSchema
5755
} from '@modelcontextprotocol/core-internal';
5856
import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims';
5957

58+
import { normalizeElicitInputFormParams } from './elicitation';
59+
6060
export type ServerOptions = ProtocolOptions & {
6161
/**
6262
* Capabilities to advertise as being supported by this server.
@@ -86,44 +86,6 @@ export type ServerOptions = ProtocolOptions & {
8686
jsonSchemaValidator?: jsonSchemaValidator;
8787
};
8888

89-
function isJsonObject(value: unknown): value is Record<string, unknown> {
90-
return typeof value === 'object' && value !== null && !Array.isArray(value);
91-
}
92-
93-
const ELICITATION_STRING_FORMATS = new Set(['email', 'uri', 'date', 'date-time']);
94-
95-
function isSupportedFormatPattern(original: Record<string, unknown>, parsed: Record<string, unknown>, key: string): boolean {
96-
return (
97-
key === 'pattern' &&
98-
typeof original.pattern === 'string' &&
99-
parsed.type === 'string' &&
100-
typeof parsed.format === 'string' &&
101-
original.format === parsed.format &&
102-
ELICITATION_STRING_FORMATS.has(parsed.format)
103-
);
104-
}
105-
106-
function findStrippedJsonSchemaPaths(original: unknown, parsed: unknown, path = ''): string[] {
107-
if (Array.isArray(original) && Array.isArray(parsed)) {
108-
return original.flatMap((item, index) => findStrippedJsonSchemaPaths(item, parsed[index], `${path}[${index}]`));
109-
}
110-
111-
if (!isJsonObject(original) || !isJsonObject(parsed)) {
112-
return [];
113-
}
114-
115-
return Object.entries(original).flatMap(([key, value]) => {
116-
const childPath = path ? `${path}.${key}` : key;
117-
if (!Object.prototype.hasOwnProperty.call(parsed, key)) {
118-
if (isSupportedFormatPattern(original, parsed, key)) {
119-
return [];
120-
}
121-
return [childPath];
122-
}
123-
return findStrippedJsonSchemaPaths(value, parsed[key], childPath);
124-
});
125-
}
126-
12789
/**
12890
* An MCP server on top of a pluggable transport.
12991
*
@@ -594,7 +556,7 @@ export class Server extends Protocol<ServerContext> {
594556
throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support form elicitation.');
595557
}
596558

597-
const { params: formParams, standardSchema } = this.normalizeElicitInputFormParams(
559+
const { params: formParams, standardSchema } = normalizeElicitInputFormParams(
598560
params as ElicitRequestFormParams | ElicitInputFormParams<StandardSchemaWithJSON>
599561
);
600562

@@ -641,47 +603,6 @@ export class Server extends Protocol<ServerContext> {
641603
}
642604
}
643605

644-
private normalizeElicitInputFormParams(params: ElicitRequestFormParams | ElicitInputFormParams<StandardSchemaWithJSON>): {
645-
params: ElicitRequestFormParams;
646-
standardSchema?: StandardSchemaWithJSON;
647-
} {
648-
const formParams =
649-
params.mode === 'form'
650-
? (params as ElicitRequestFormParams)
651-
: { ...(params as ElicitRequestFormParams), mode: 'form' as const };
652-
653-
if (this.isElicitInputSchema(formParams.requestedSchema)) {
654-
const standardSchema = formParams.requestedSchema;
655-
const normalizedParams = {
656-
...formParams,
657-
requestedSchema: standardSchemaToJsonSchema(standardSchema, 'input')
658-
};
659-
const parsedParams = parseSchema(ElicitRequestFormParamsSchema, normalizedParams);
660-
if (!parsedParams.success) {
661-
throw new ProtocolError(
662-
ProtocolErrorCode.InvalidParams,
663-
`Elicitation requestedSchema only supports flat primitive properties (string, number, integer, boolean, and string enums): ${parsedParams.error.message}`
664-
);
665-
}
666-
const strippedSchemaPaths = findStrippedJsonSchemaPaths(normalizedParams.requestedSchema, parsedParams.data.requestedSchema);
667-
if (strippedSchemaPaths.length > 0) {
668-
throw new ProtocolError(
669-
ProtocolErrorCode.InvalidParams,
670-
`Elicitation requestedSchema contains unsupported JSON Schema keyword(s) after Standard Schema conversion: ${strippedSchemaPaths.join(', ')}`
671-
);
672-
}
673-
return { params: parsedParams.data, standardSchema };
674-
}
675-
676-
return { params: formParams };
677-
}
678-
679-
private isElicitInputSchema(
680-
schema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON
681-
): schema is StandardSchemaWithJSON {
682-
return typeof schema === 'object' && schema !== null && '~standard' in schema;
683-
}
684-
685606
/**
686607
* Creates a reusable callback that, when invoked, will send a `notifications/elicitation/complete`
687608
* notification for the specified elicitation ID.

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,16 @@ describe('server JSON Schema validator overrides', () => {
239239
).rejects.toThrow(/properties\.code\.pattern/);
240240
expect(sawElicitationRequest).toBe(false);
241241

242+
await expect(
243+
server.elicitInput({
244+
message: 'What is your email?',
245+
requestedSchema: z.object({
246+
email: z.email({ pattern: /@corp\.com$/ })
247+
})
248+
})
249+
).rejects.toThrow(/properties\.email\.pattern/);
250+
expect(sawElicitationRequest).toBe(false);
251+
242252
await expect(
243253
server.elicitInput({
244254
message: 'How many?',

0 commit comments

Comments
 (0)