Skip to content

Commit c18c34f

Browse files
committed
fix(mcp): accept schema-valid empty strings in clientInfo identity fields
`isImplementation` reused `stringField`, which requires a non-empty string, for the required `name` and `version`. The 2026-07-28 schema declares both as plain `string` with no minimum length, so `{name: "", version: ""}` is a conforming `Implementation` and was being answered -32602. Required now means present and a string. `Icon.src` gets the same treatment. Its `format: uri` annotation is not something this server enforces — any other non-URI string is accepted — so rejecting the empty one alone was arbitrary rather than stricter. Adds positive regressions at both layers for empty `name`/`version` and an empty `Icon.src`, alongside the existing malformed cases, so the validator is pinned against over-rejection as well as under-rejection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKWswqMZ6Z9UAQDAda83Xz
1 parent ba544ed commit c18c34f

3 files changed

Lines changed: 37 additions & 4 deletions

File tree

src/mcp/__tests__/protocol-era.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,16 @@ test('a supplied clientInfo must be an Implementation, but omitting it is fine',
113113
const valid = { name: 'c', version: '1' };
114114
assert.equal(resolveProtocolEra('tools/list', modernMeta()), 'modern');
115115
assert.equal(resolveProtocolEra('tools/list', withClientInfo(valid)), 'modern');
116+
// Required means present and a string, not non-empty: the schema sets no minimum
117+
// length on `name`/`version`, nor on `Icon.src`.
118+
assert.equal(
119+
resolveProtocolEra('tools/list', withClientInfo({ name: '', version: '' })),
120+
'modern',
121+
);
122+
assert.equal(
123+
resolveProtocolEra('tools/list', withClientInfo({ ...valid, icons: [{ src: '' }] })),
124+
'modern',
125+
);
116126
// Every recognized optional field, plus an unknown extension key, stays acceptable.
117127
assert.equal(
118128
resolveProtocolEra(

src/mcp/__tests__/router.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,21 @@ test('a supplied clientInfo must be a valid Implementation', async () => {
249249
});
250250
assert.ok(omitted && 'result' in omitted);
251251

252+
// `name` and `version` are required `string` with no minimum length, so an empty
253+
// string is a conforming Implementation and must not be rejected.
254+
const emptyStrings = await handleMcpMessage({
255+
jsonrpc: '2.0',
256+
id: 'empty-client-info',
257+
method: 'tools/list',
258+
params: {
259+
_meta: {
260+
...MODERN_META,
261+
'io.modelcontextprotocol/clientInfo': { name: '', version: '' },
262+
},
263+
},
264+
});
265+
assert.ok(emptyStrings && 'result' in emptyStrings);
266+
252267
// A fully populated clientInfo, plus an extension key, must still be served:
253268
// validation must not harden into rejecting what the spec allows.
254269
const rich = await handleMcpMessage({

src/mcp/protocol-era.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,14 @@ function isRecord(value: unknown): value is Record<string, unknown> {
202202
* Whether a value is a valid `Implementation`: `name` and `version` are required, and
203203
* every other recognized field is type-checked when present. Unrecognized keys pass —
204204
* `_meta` payloads carry extension fields, and rejecting those would reject the future.
205+
*
206+
* Required means *present and a string*, not non-empty: the schema declares plain
207+
* `string` with no minimum length, so `{name: "", version: ""}` is a conforming
208+
* `Implementation` and refusing it would reject a client the spec allows.
205209
*/
206210
function isImplementation(value: unknown): boolean {
207211
if (!isRecord(value)) return false;
208-
if (stringField(value, 'name') === undefined) return false;
209-
if (stringField(value, 'version') === undefined) return false;
212+
if (!isString(value.name) || !isString(value.version)) return false;
210213
return (
211214
isOptional(value.title, isString) &&
212215
isOptional(value.description, isString) &&
@@ -215,10 +218,15 @@ function isImplementation(value: unknown): boolean {
215218
);
216219
}
217220

218-
/** An `Icon` requires `src`; the rest are optional but typed when supplied. */
221+
/**
222+
* An `Icon` requires `src`; the rest are optional but typed when supplied. `src` is
223+
* checked as a string for the same reason as `name`/`version` — its `format: uri`
224+
* annotation is not something this server enforces, so rejecting `""` while accepting
225+
* any other non-URI string would be arbitrary.
226+
*/
219227
function isIcon(value: unknown): boolean {
220228
if (!isRecord(value)) return false;
221-
if (stringField(value, 'src') === undefined) return false;
229+
if (!isString(value.src)) return false;
222230
return (
223231
isOptional(value.mimeType, isString) &&
224232
isOptional(value.sizes, isStringArray) &&

0 commit comments

Comments
 (0)