@mcpdesc/validator 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1397 @@
1
+ // Provide structural and semantic validation for MCP Description v0.8.0.
2
+ //
3
+ // The exported helpers complement the published JSON Schema with cross-object
4
+ // rules for protocol scopes, revision applicability, security, tags, embedded
5
+ // Tool schemas and examples, Resource examples, Elicitation Declarations,
6
+ // transports, and extension namespaces. Diagnostics
7
+ // distinguish fatal errors from nonfatal warnings. External schema references
8
+ // are never fetched automatically; unresolved targets are preserved and reported.
9
+
10
+ import Ajv from 'ajv';
11
+ import Ajv2020 from 'ajv/dist/2020.js';
12
+ import addFormats from 'ajv-formats';
13
+ import { UriTemplateMatcher } from 'uri-template-matcher';
14
+
15
+ export const supportedProtocolVersions = Object.freeze([
16
+ '2024-11-05',
17
+ '2025-03-26',
18
+ '2025-06-18',
19
+ '2025-11-25',
20
+ '2026-07-28'
21
+ ]);
22
+
23
+ const protocolOrder = new Map(supportedProtocolVersions.map((version, index) => [version, index]));
24
+ const toolSchemaDialectVersion = '2025-11-25';
25
+ const completeSemanticConformanceVersion = '2025-06-18';
26
+ const knownReservedCapabilityExtensions = new Set([
27
+ 'io.modelcontextprotocol/tasks'
28
+ ]);
29
+ const metaKeyPattern = /^(?:(?:[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?)(?:\.[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*\/)?(?:[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)?$/;
30
+
31
+ function createValidatorForDialect(dialect) {
32
+ const Factory = dialect === '2020-12' ? Ajv2020 : Ajv;
33
+ const instance = new Factory({ allErrors: true, strict: false });
34
+ addFormats(instance);
35
+ return instance;
36
+ }
37
+
38
+ function normalizeScope(scope) {
39
+ return [...scope].sort((left, right) => protocolOrder.get(left) - protocolOrder.get(right));
40
+ }
41
+
42
+ function effectiveScope(document, item, parentScope) {
43
+ return normalizeScope(item?.protocolVersions ?? parentScope ?? document.protocolVersions ?? []);
44
+ }
45
+
46
+ function intersection(left, right) {
47
+ const rightSet = new Set(right);
48
+ return left.filter((value) => rightSet.has(value));
49
+ }
50
+
51
+ function collectOAuthScopeCatalog(scheme) {
52
+ if (!scheme || typeof scheme !== 'object') return new Set();
53
+ const catalog = new Set();
54
+ if (scheme.type === 'oauth2') {
55
+ for (const flow of Object.values(scheme.flows ?? {})) {
56
+ for (const scope of Object.keys(flow?.scopes ?? {})) catalog.add(scope);
57
+ }
58
+ }
59
+ return catalog;
60
+ }
61
+
62
+ function embeddedSchemaDialect(schema) {
63
+ const declared = schema?.$schema;
64
+ if (declared === undefined) return '2020-12';
65
+ if (typeof declared !== 'string') return null;
66
+ if (/^https?:\/\/json-schema\.org\/draft\/2020-12\/schema#?$/.test(declared)) return '2020-12';
67
+ if (/^https?:\/\/json-schema\.org\/draft-07\/schema#?$/.test(declared)) return 'draft-07';
68
+ return null;
69
+ }
70
+
71
+ function schemaForPartialOfflineCompilation(schema) {
72
+ const externalReferences = [];
73
+
74
+ function visit(value) {
75
+ if (Array.isArray(value)) return value.map(visit);
76
+ if (!value || typeof value !== 'object') return value;
77
+ const result = {};
78
+ for (const [key, child] of Object.entries(value)) {
79
+ if (key === '$ref' && typeof child === 'string' && !child.startsWith('#')) {
80
+ externalReferences.push(child);
81
+ continue;
82
+ }
83
+ result[key] = visit(child);
84
+ }
85
+ return result;
86
+ }
87
+
88
+ return { schema: visit(schema), externalReferences };
89
+ }
90
+
91
+ function makeDiagnostic(code, severity, message, path) {
92
+ if (!Array.isArray(path)) throw new TypeError(`Diagnostic ${code} is missing an explicit path`);
93
+ return { code, severity, message, path };
94
+ }
95
+
96
+ function usesMcpReservedPrefix(identifier) {
97
+ if (typeof identifier !== 'string') return false;
98
+ const [prefix] = identifier.split('/', 1);
99
+ const labels = prefix.split('.');
100
+ if (labels.length < 2) return false;
101
+ const secondLabel = labels[1].toLowerCase();
102
+ return secondLabel === 'modelcontextprotocol' || secondLabel === 'mcp';
103
+ }
104
+
105
+ function usesMcpReservedMetaPrefix(identifier, version) {
106
+ if (typeof identifier !== 'string' || !identifier.includes('/')) return false;
107
+ const labels = identifier.slice(0, identifier.indexOf('/')).split('.');
108
+ if (version === '2025-06-18') {
109
+ return labels.slice(0, -1).some((label) => ['modelcontextprotocol', 'mcp'].includes(label.toLowerCase()));
110
+ }
111
+ return labels.length >= 2 && ['modelcontextprotocol', 'mcp'].includes(labels[1].toLowerCase());
112
+ }
113
+
114
+ function isImplementation(value) {
115
+ return value && typeof value === 'object' && !Array.isArray(value)
116
+ && typeof value.name === 'string' && typeof value.version === 'string';
117
+ }
118
+
119
+ function validateMeta(document, rel, diagnostics) {
120
+ const rootScope = normalizeScope(document.protocolVersions ?? []);
121
+ const legacyVersions = rootScope.filter(
122
+ (version) => protocolOrder.get(version) < protocolOrder.get(completeSemanticConformanceVersion)
123
+ );
124
+ if (legacyVersions.length) {
125
+ diagnostics.push(
126
+ makeDiagnostic(
127
+ 'legacy-protocol-validation-incomplete',
128
+ 'warning',
129
+ `MCP ${legacyVersions.join(', ')} are legacy compatibility revisions; structural and selected checks were applied, but complete MCP semantic conformance was not evaluated`,
130
+ ['protocolVersions']
131
+ )
132
+ );
133
+ }
134
+
135
+ function validateKnownReservedKey(key, value, context, version, location, locationPath) {
136
+ let validContext;
137
+ let validValue;
138
+
139
+ if (key === 'progressToken' && ['2025-06-18', '2025-11-25', '2026-07-28'].includes(version)) {
140
+ validContext = context === 'request';
141
+ validValue = typeof value === 'string' || typeof value === 'number';
142
+ } else if (key === 'io.modelcontextprotocol/related-task' && version === '2025-11-25') {
143
+ validContext = context === 'result';
144
+ validValue = value && typeof value === 'object' && !Array.isArray(value) && typeof value.taskId === 'string';
145
+ } else if (key === 'io.modelcontextprotocol/serverInfo' && version === '2026-07-28') {
146
+ validContext = context === 'result';
147
+ validValue = isImplementation(value);
148
+ } else if (
149
+ version === '2026-07-28'
150
+ && ['io.modelcontextprotocol/protocolVersion', 'io.modelcontextprotocol/clientInfo', 'io.modelcontextprotocol/clientCapabilities', 'io.modelcontextprotocol/logLevel'].includes(key)
151
+ ) {
152
+ validContext = context === 'request';
153
+ validValue = key === 'io.modelcontextprotocol/protocolVersion'
154
+ ? typeof value === 'string'
155
+ : key === 'io.modelcontextprotocol/clientInfo'
156
+ ? isImplementation(value)
157
+ : key === 'io.modelcontextprotocol/clientCapabilities'
158
+ ? value && typeof value === 'object' && !Array.isArray(value)
159
+ : ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'].includes(value);
160
+ } else if (key === 'io.modelcontextprotocol/subscriptionId' && version === '2026-07-28') {
161
+ validContext = context === 'notification' || context === 'subscription-result';
162
+ validValue = typeof value === 'string' || typeof value === 'number';
163
+ } else if (version === '2026-07-28' && ['traceparent', 'tracestate', 'baggage'].includes(key)) {
164
+ validContext = true;
165
+ validValue = typeof value === 'string' && value.length > 0;
166
+ } else {
167
+ return false;
168
+ }
169
+
170
+ if (!validContext) {
171
+ diagnostics.push(
172
+ makeDiagnostic(
173
+ 'meta-reserved-key-context',
174
+ 'error',
175
+ `${location} uses reserved _meta key ${JSON.stringify(key)} in a ${context} context where MCP ${version} does not define it`,
176
+ [...locationPath, key]
177
+ )
178
+ );
179
+ } else if (!validValue) {
180
+ diagnostics.push(
181
+ makeDiagnostic(
182
+ 'meta-reserved-key-value',
183
+ 'error',
184
+ `${location}[${JSON.stringify(key)}] has an invalid value for MCP ${version}`,
185
+ [...locationPath, key]
186
+ )
187
+ );
188
+ }
189
+ return true;
190
+ }
191
+
192
+ function check(meta, context, scope, location, locationPath) {
193
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return;
194
+ for (const [key, value] of Object.entries(meta)) {
195
+ if (!metaKeyPattern.test(key)) {
196
+ diagnostics.push(
197
+ makeDiagnostic(
198
+ 'meta-key-invalid',
199
+ 'error',
200
+ `${location} contains invalid MCP _meta key ${JSON.stringify(key)}`,
201
+ [...locationPath, key]
202
+ )
203
+ );
204
+ continue;
205
+ }
206
+ for (const version of scope.filter((candidate) => protocolOrder.get(candidate) >= protocolOrder.get(completeSemanticConformanceVersion))) {
207
+ if (validateKnownReservedKey(key, value, context, version, location, locationPath)) continue;
208
+ if (usesMcpReservedMetaPrefix(key, version)) {
209
+ diagnostics.push(
210
+ makeDiagnostic(
211
+ 'meta-unknown-reserved-key',
212
+ 'warning',
213
+ `${location} contains unrecognized key ${JSON.stringify(key)} under an MCP-reserved prefix for MCP ${version}; preserve it and review its authority`,
214
+ [...locationPath, key]
215
+ )
216
+ );
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ for (const [kind, items] of [
223
+ ['tools', document.tools ?? []],
224
+ ['resources', document.resources ?? []],
225
+ ['resourceTemplates', document.resourceTemplates ?? []],
226
+ ['prompts', document.prompts ?? []]
227
+ ]) {
228
+ items.forEach((item, itemIndex) => {
229
+ const scope = effectiveScope(document, item, rootScope);
230
+ const itemPath = [kind, itemIndex];
231
+ check(item._meta, 'declaration', scope, `${kind}[${itemIndex}]._meta`, [...itemPath, '_meta']);
232
+ if (kind === 'tools') {
233
+ for (const [exampleName, example] of Object.entries(item.examples ?? {})) {
234
+ const result = example?.result;
235
+ if (!result || typeof result !== 'object' || Array.isArray(result)) continue;
236
+ const resultLocation = `${kind}[${itemIndex}].examples[${JSON.stringify(exampleName)}].result`;
237
+ const resultPath = [...itemPath, 'examples', exampleName, 'result'];
238
+ check(result._meta, 'result', scope, `${resultLocation}._meta`, [...resultPath, '_meta']);
239
+ (result.content ?? []).forEach((content, contentIndex) => {
240
+ const contentLocation = `${resultLocation}.content[${contentIndex}]`;
241
+ const contentPath = [...resultPath, 'content', contentIndex];
242
+ check(content?._meta, 'content', scope, `${contentLocation}._meta`, [...contentPath, '_meta']);
243
+ if (content?.type === 'resource') {
244
+ check(content.resource?._meta, 'content', scope, `${contentLocation}.resource._meta`, [...contentPath, 'resource', '_meta']);
245
+ }
246
+ });
247
+ }
248
+ } else if (kind !== 'prompts') {
249
+ for (const [exampleName, example] of Object.entries(item.examples ?? {})) {
250
+ const result = example?.result;
251
+ if (!result || typeof result !== 'object' || Array.isArray(result)) continue;
252
+ const resultLocation = `${kind}[${itemIndex}].examples[${JSON.stringify(exampleName)}].result`;
253
+ const resultPath = [...itemPath, 'examples', exampleName, 'result'];
254
+ check(result._meta, 'result', scope, `${resultLocation}._meta`, [...resultPath, '_meta']);
255
+ (result.contents ?? []).forEach((content, contentIndex) => {
256
+ check(
257
+ content?._meta,
258
+ 'content',
259
+ scope,
260
+ `${resultLocation}.contents[${contentIndex}]._meta`,
261
+ [...resultPath, 'contents', contentIndex, '_meta']
262
+ );
263
+ });
264
+ }
265
+ }
266
+ });
267
+ }
268
+ }
269
+
270
+ function validateTagReferences(document, rel, diagnostics) {
271
+ const declaredTags = new Set();
272
+ (document.tags ?? []).forEach((tag, tagIndex) => {
273
+ if (declaredTags.has(tag.name)) {
274
+ diagnostics.push(makeDiagnostic(
275
+ 'duplicate-root-tag',
276
+ 'error',
277
+ `root tags must use unique names; found duplicate tag ${JSON.stringify(tag.name)}`,
278
+ ['tags', tagIndex, 'name']
279
+ ));
280
+ return;
281
+ }
282
+ declaredTags.add(tag.name);
283
+ });
284
+ if (!Object.hasOwn(document, 'tags')) return;
285
+
286
+ for (const [kind, items] of [
287
+ ['tools', document.tools ?? []],
288
+ ['resources', document.resources ?? []],
289
+ ['resourceTemplates', document.resourceTemplates ?? []],
290
+ ['prompts', document.prompts ?? []]
291
+ ]) {
292
+ items.forEach((item, index) => {
293
+ (item.tags ?? []).forEach((tag, tagIndex) => {
294
+ if (!declaredTags.has(tag)) {
295
+ diagnostics.push(
296
+ makeDiagnostic(
297
+ 'unknown-tag-reference',
298
+ 'error',
299
+ `${kind}[${index}] references undeclared tag ${JSON.stringify(tag)}`,
300
+ [kind, index, 'tags', tagIndex]
301
+ )
302
+ );
303
+ }
304
+ });
305
+ });
306
+ }
307
+ }
308
+
309
+ function validateSecurityRequirements(document, rel, diagnostics) {
310
+ const schemeMap = document.securitySchemes ?? {};
311
+ const schemeNames = new Set(Object.keys(schemeMap));
312
+ const scopeCatalogByScheme = new Map(
313
+ Object.entries(schemeMap).map(([name, scheme]) => [name, collectOAuthScopeCatalog(scheme)])
314
+ );
315
+
316
+ function checkRequirementArray(requirementArray, location, locationPath) {
317
+ if (!Array.isArray(requirementArray)) return;
318
+ requirementArray.forEach((requirement, requirementIndex) => {
319
+ for (const [schemeName, scopes] of Object.entries(requirement)) {
320
+ if (!schemeNames.has(schemeName)) {
321
+ diagnostics.push(
322
+ makeDiagnostic(
323
+ 'unknown-security-scheme',
324
+ 'error',
325
+ `${location}[${requirementIndex}] references unknown security scheme ${JSON.stringify(schemeName)}`,
326
+ [...locationPath, requirementIndex, schemeName]
327
+ )
328
+ );
329
+ continue;
330
+ }
331
+ const scheme = schemeMap[schemeName];
332
+ if (!Array.isArray(scopes)) continue;
333
+ const duplicateScopes = scopes.filter((scope, index) => scopes.indexOf(scope) !== index);
334
+ if (duplicateScopes.length) {
335
+ diagnostics.push(
336
+ makeDiagnostic(
337
+ 'duplicate-security-scope',
338
+ 'error',
339
+ `${location}[${requirementIndex}].${schemeName} contains duplicate scope values`,
340
+ [...locationPath, requirementIndex, schemeName]
341
+ )
342
+ );
343
+ }
344
+ if ((scheme.type === 'http' || scheme.type === 'apiKey') && scopes.length > 0) {
345
+ diagnostics.push(
346
+ makeDiagnostic(
347
+ 'non-oauth-scope-misuse',
348
+ 'error',
349
+ `${location}[${requirementIndex}].${schemeName} uses scopes with ${scheme.type}, but only oauth2 and openIdConnect requirements may list scopes`,
350
+ [...locationPath, requirementIndex, schemeName]
351
+ )
352
+ );
353
+ }
354
+ if (scheme.type === 'oauth2' || scheme.type === 'openIdConnect') {
355
+ const catalog = scopeCatalogByScheme.get(schemeName) ?? new Set();
356
+ scopes.forEach((scope, scopeIndex) => {
357
+ if (!catalog.has(scope)) {
358
+ diagnostics.push(
359
+ makeDiagnostic(
360
+ 'uncatalogued-oauth-scope',
361
+ 'warning',
362
+ `${location}[${requirementIndex}].${schemeName} references scope ${JSON.stringify(scope)} that is not present in the declared static scope catalogue`,
363
+ [...locationPath, requirementIndex, schemeName, scopeIndex]
364
+ )
365
+ );
366
+ }
367
+ });
368
+ }
369
+ }
370
+ });
371
+ }
372
+
373
+ checkRequirementArray(document.security, 'security', ['security']);
374
+ (document.transports ?? []).forEach((transport, index) => {
375
+ checkRequirementArray(transport.security, `transports[${index}].security`, ['transports', index, 'security']);
376
+ });
377
+ for (const [kind, items] of [
378
+ ['tools', document.tools ?? []],
379
+ ['resources', document.resources ?? []],
380
+ ['resourceTemplates', document.resourceTemplates ?? []],
381
+ ['prompts', document.prompts ?? []]
382
+ ]) {
383
+ items.forEach((item, index) => {
384
+ checkRequirementArray(item.security, `${kind}[${index}].security`, [kind, index, 'security']);
385
+ });
386
+ }
387
+ }
388
+
389
+ function validateProtocolScopes(document, rel, diagnostics) {
390
+ const rootScope = normalizeScope(document.protocolVersions ?? []);
391
+ const coverage = new Set();
392
+
393
+ function assertSubset(scope, parentScope, location, locationPath) {
394
+ const parentScopeSet = new Set(parentScope);
395
+ for (const version of scope) {
396
+ if (!parentScopeSet.has(version)) {
397
+ diagnostics.push(
398
+ makeDiagnostic(
399
+ 'protocol-scope-outside-parent',
400
+ 'error',
401
+ `${location} includes protocol version ${version} outside its parent scope`,
402
+ locationPath
403
+ )
404
+ );
405
+ }
406
+ }
407
+ }
408
+
409
+ (document.transports ?? []).forEach((transport, index) => {
410
+ const scope = effectiveScope(document, transport, rootScope);
411
+ assertSubset(scope, rootScope, `transports[${index}].protocolVersions`, ['transports', index, 'protocolVersions']);
412
+ for (const version of scope) coverage.add(version);
413
+ });
414
+ for (const version of rootScope) {
415
+ if (!coverage.has(version)) {
416
+ diagnostics.push(
417
+ makeDiagnostic(
418
+ 'transport-coverage-gap',
419
+ 'error',
420
+ `protocol version ${version} is present at the root but not covered by any transport`,
421
+ ['protocolVersions']
422
+ )
423
+ );
424
+ }
425
+ }
426
+
427
+ const capabilities = document.capabilities ?? [];
428
+ capabilities.forEach((capability, index) => {
429
+ const scope = effectiveScope(document, capability, rootScope);
430
+ assertSubset(scope, rootScope, `capabilities[${index}].protocolVersions`, ['capabilities', index, 'protocolVersions']);
431
+ });
432
+ for (let index = 0; index < capabilities.length; index += 1) {
433
+ for (let otherIndex = index + 1; otherIndex < capabilities.length; otherIndex += 1) {
434
+ const leftScope = effectiveScope(document, capabilities[index], rootScope);
435
+ const rightScope = effectiveScope(document, capabilities[otherIndex], rootScope);
436
+ const overlap = intersection(leftScope, rightScope);
437
+ if (overlap.length > 0) {
438
+ diagnostics.push(
439
+ makeDiagnostic(
440
+ 'capability-scope-overlap',
441
+ 'error',
442
+ `capabilities[${index}] and capabilities[${otherIndex}] overlap for protocol versions ${overlap.join(', ')}`,
443
+ ['capabilities', index, 'protocolVersions']
444
+ )
445
+ );
446
+ }
447
+ }
448
+ }
449
+
450
+ for (const [kind, items, key] of [
451
+ ['tools', document.tools ?? [], 'name'],
452
+ ['prompts', document.prompts ?? [], 'name'],
453
+ ['resources', document.resources ?? [], 'uri'],
454
+ ['resourceTemplates', document.resourceTemplates ?? [], 'uriTemplate']
455
+ ]) {
456
+ const byIdentifier = new Map();
457
+ items.forEach((item, index) => {
458
+ const identifier = item[key];
459
+ const scope = effectiveScope(document, item, rootScope);
460
+ assertSubset(scope, rootScope, `${kind}[${index}].protocolVersions`, [kind, index, 'protocolVersions']);
461
+ (item.elicitations ?? []).forEach((elicitation, elicitationIndex) => {
462
+ const elicitationScope = effectiveScope(document, elicitation, scope);
463
+ assertSubset(
464
+ elicitationScope,
465
+ scope,
466
+ `${kind}[${index}].elicitations[${elicitationIndex}].protocolVersions`,
467
+ [kind, index, 'elicitations', elicitationIndex, 'protocolVersions']
468
+ );
469
+ });
470
+ const existing = byIdentifier.get(identifier) ?? [];
471
+ for (const other of existing) {
472
+ const overlap = intersection(scope, other.scope);
473
+ if (overlap.length > 0) {
474
+ diagnostics.push(
475
+ makeDiagnostic(
476
+ 'primitive-scope-overlap',
477
+ 'error',
478
+ `${kind}[${index}] with identifier ${JSON.stringify(identifier)} overlaps ${kind}[${other.index}] for protocol versions ${overlap.join(', ')}`,
479
+ [kind, index, 'protocolVersions']
480
+ )
481
+ );
482
+ }
483
+ }
484
+ existing.push({ index, scope });
485
+ byIdentifier.set(identifier, existing);
486
+ });
487
+ }
488
+ }
489
+
490
+ function validateElicitations(document, rel, diagnostics) {
491
+ const rootScope = normalizeScope(document.protocolVersions ?? []);
492
+
493
+ function validateFormSchema(schema, version, location, locationPath) {
494
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return;
495
+ if (version === '2025-06-18' && Object.hasOwn(schema, '$schema')) {
496
+ diagnostics.push(
497
+ makeDiagnostic(
498
+ 'elicitation-form-schema-version-mismatch',
499
+ 'error',
500
+ `${location}.$schema is not defined for MCP ${version}`,
501
+ [...locationPath, '$schema']
502
+ )
503
+ );
504
+ }
505
+
506
+ const propertyNames = new Set(Object.keys(schema.properties ?? {}));
507
+ (schema.required ?? []).forEach((requiredName, requiredIndex) => {
508
+ if (propertyNames.has(requiredName)) return;
509
+ diagnostics.push(
510
+ makeDiagnostic(
511
+ 'elicitation-form-required-property-unknown',
512
+ 'error',
513
+ `${location}.required references undeclared property ${JSON.stringify(requiredName)}`,
514
+ [...locationPath, 'required', requiredIndex]
515
+ )
516
+ );
517
+ });
518
+
519
+ for (const [propertyName, property] of Object.entries(schema.properties ?? {})) {
520
+ const propertyLocation = `${location}.properties[${JSON.stringify(propertyName)}]`;
521
+ const propertyPath = [...locationPath, 'properties', propertyName];
522
+ if (!property || typeof property !== 'object' || Array.isArray(property)) continue;
523
+
524
+ if (version === '2025-06-18') {
525
+ if (property.type === 'array' || Object.hasOwn(property, 'oneOf')) {
526
+ diagnostics.push(
527
+ makeDiagnostic(
528
+ 'elicitation-form-schema-version-mismatch',
529
+ 'error',
530
+ `${propertyLocation} uses an enum form introduced after MCP ${version}`,
531
+ propertyPath
532
+ )
533
+ );
534
+ }
535
+ if (Object.hasOwn(property, 'default') && property.type !== 'boolean') {
536
+ diagnostics.push(
537
+ makeDiagnostic(
538
+ 'elicitation-form-schema-version-mismatch',
539
+ 'error',
540
+ `${propertyLocation}.default is not defined for this property type in MCP ${version}`,
541
+ [...propertyPath, 'default']
542
+ )
543
+ );
544
+ }
545
+ }
546
+
547
+ if (Array.isArray(property.enumNames) && Array.isArray(property.enum) && property.enumNames.length !== property.enum.length) {
548
+ diagnostics.push(
549
+ makeDiagnostic(
550
+ 'elicitation-form-enum-names-mismatch',
551
+ 'error',
552
+ `${propertyLocation}.enumNames must contain one display name for every enum value`,
553
+ [...propertyPath, 'enumNames']
554
+ )
555
+ );
556
+ }
557
+ if (Object.hasOwn(property, 'default')) {
558
+ const allowedValues = Array.isArray(property.enum)
559
+ ? property.enum
560
+ : Array.isArray(property.oneOf)
561
+ ? property.oneOf.map((option) => option.const)
562
+ : undefined;
563
+ if (allowedValues && !allowedValues.includes(property.default)) {
564
+ diagnostics.push(
565
+ makeDiagnostic(
566
+ 'elicitation-form-default-outside-enum',
567
+ 'error',
568
+ `${propertyLocation}.default must be one of the declared enum values`,
569
+ [...propertyPath, 'default']
570
+ )
571
+ );
572
+ }
573
+ if (property.type === 'array') {
574
+ const itemValues = property.items?.enum ?? property.items?.anyOf?.map((option) => option.const);
575
+ if (Array.isArray(itemValues) && property.default.some((value) => !itemValues.includes(value))) {
576
+ diagnostics.push(
577
+ makeDiagnostic(
578
+ 'elicitation-form-default-outside-enum',
579
+ 'error',
580
+ `${propertyLocation}.default contains a value outside the declared enum values`,
581
+ [...propertyPath, 'default']
582
+ )
583
+ );
584
+ }
585
+ }
586
+ }
587
+ if (typeof property.minLength === 'number' && typeof property.maxLength === 'number' && property.minLength > property.maxLength) {
588
+ diagnostics.push(makeDiagnostic('elicitation-form-invalid-range', 'error', `${propertyLocation}.minLength must not exceed maxLength`, [...propertyPath, 'minLength']));
589
+ }
590
+ if (typeof property.minimum === 'number' && typeof property.maximum === 'number' && property.minimum > property.maximum) {
591
+ diagnostics.push(makeDiagnostic('elicitation-form-invalid-range', 'error', `${propertyLocation}.minimum must not exceed maximum`, [...propertyPath, 'minimum']));
592
+ }
593
+ if (typeof property.minItems === 'number' && typeof property.maxItems === 'number' && property.minItems > property.maxItems) {
594
+ diagnostics.push(makeDiagnostic('elicitation-form-invalid-range', 'error', `${propertyLocation}.minItems must not exceed maxItems`, [...propertyPath, 'minItems']));
595
+ }
596
+ }
597
+ }
598
+
599
+ for (const [kind, items] of [
600
+ ['tools', document.tools ?? []],
601
+ ['resources', document.resources ?? []],
602
+ ['resourceTemplates', document.resourceTemplates ?? []],
603
+ ['prompts', document.prompts ?? []]
604
+ ]) {
605
+ items.forEach((item, itemIndex) => {
606
+ const parentScope = effectiveScope(document, item, rootScope);
607
+ const names = new Set();
608
+ (item.elicitations ?? []).forEach((elicitation, elicitationIndex) => {
609
+ const location = `${kind}[${itemIndex}].elicitations[${elicitationIndex}]`;
610
+ const locationPath = [kind, itemIndex, 'elicitations', elicitationIndex];
611
+ if (names.has(elicitation.name)) {
612
+ diagnostics.push(
613
+ makeDiagnostic(
614
+ 'duplicate-elicitation-name',
615
+ 'error',
616
+ `${location}.name duplicates Elicitation Declaration name ${JSON.stringify(elicitation.name)} within the containing primitive`,
617
+ [...locationPath, 'name']
618
+ )
619
+ );
620
+ }
621
+ names.add(elicitation.name);
622
+
623
+ const scope = effectiveScope(document, elicitation, parentScope);
624
+ for (const version of scope) {
625
+ if (protocolOrder.get(version) < protocolOrder.get(completeSemanticConformanceVersion)) continue;
626
+ if (version === '2025-06-18' && elicitation.mode !== 'form') {
627
+ diagnostics.push(
628
+ makeDiagnostic(
629
+ 'elicitation-mode-not-supported-by-version',
630
+ 'error',
631
+ `${location}.mode ${JSON.stringify(elicitation.mode)} is not defined for MCP ${version}; only form mode is supported`,
632
+ [...locationPath, 'mode']
633
+ )
634
+ );
635
+ }
636
+ if (elicitation.mode === 'form') {
637
+ validateFormSchema(elicitation.requestedSchema, version, `${location}.requestedSchema`, [...locationPath, 'requestedSchema']);
638
+ }
639
+ }
640
+ });
641
+ });
642
+ }
643
+ }
644
+
645
+ function validateVersionSpecificSemantics(document, rel, diagnostics) {
646
+ const rootScope = normalizeScope(document.protocolVersions ?? []);
647
+
648
+ function checkMinimumVersion(location, locationPath, object, fieldName, minimumVersion, scope) {
649
+ if (!(fieldName in object)) return;
650
+ for (const version of scope) {
651
+ if (protocolOrder.get(version) < protocolOrder.get(minimumVersion)) {
652
+ diagnostics.push(
653
+ makeDiagnostic(
654
+ 'field-not-supported-by-version',
655
+ 'error',
656
+ `${location}.${fieldName} is not defined for MCP ${version}; it requires ${minimumVersion} or later`,
657
+ [...locationPath, fieldName]
658
+ )
659
+ );
660
+ }
661
+ }
662
+ }
663
+
664
+ function checkOnlyVersion(location, locationPath, object, fieldName, supportedVersion, scope) {
665
+ if (!(fieldName in object)) return;
666
+ for (const version of scope) {
667
+ if (version !== supportedVersion) {
668
+ diagnostics.push(
669
+ makeDiagnostic(
670
+ 'field-not-supported-by-version',
671
+ 'error',
672
+ `${location}.${fieldName} is defined only for MCP ${supportedVersion}, not MCP ${version}`,
673
+ [...locationPath, fieldName]
674
+ )
675
+ );
676
+ }
677
+ }
678
+ }
679
+
680
+ checkMinimumVersion('info', ['info'], document.info ?? {}, 'title', '2025-06-18', rootScope);
681
+ checkMinimumVersion('info', ['info'], document.info ?? {}, 'description', '2025-11-25', rootScope);
682
+ checkMinimumVersion('info', ['info'], document.info ?? {}, 'icons', '2025-11-25', rootScope);
683
+ checkMinimumVersion('info', ['info'], document.info ?? {}, 'websiteUrl', '2025-11-25', rootScope);
684
+
685
+ (document.transports ?? []).forEach((transport, index) => {
686
+ const scope = effectiveScope(document, transport, rootScope);
687
+ if (transport.type === 'streamable-http') {
688
+ for (const version of scope) {
689
+ if (protocolOrder.get(version) >= protocolOrder.get('2025-03-26')) continue;
690
+ diagnostics.push(
691
+ makeDiagnostic(
692
+ 'transport-not-supported-by-version',
693
+ 'error',
694
+ `transports[${index}] uses Streamable HTTP, which is not defined for MCP ${version}; it requires 2025-03-26 or later`,
695
+ ['transports', index, 'type']
696
+ )
697
+ );
698
+ }
699
+ }
700
+ if (transport.type === 'sse') {
701
+ for (const version of scope) {
702
+ if (protocolOrder.get(version) < protocolOrder.get('2025-03-26')) continue;
703
+ diagnostics.push(
704
+ makeDiagnostic(
705
+ 'legacy-sse-for-modern-version',
706
+ 'warning',
707
+ `transports[${index}] associates legacy SSE with MCP ${version}, where Streamable HTTP is the standard remote transport`,
708
+ ['transports', index, 'type']
709
+ )
710
+ );
711
+ }
712
+ }
713
+ });
714
+
715
+ (document.tools ?? []).forEach((tool, index) => {
716
+ const scope = effectiveScope(document, tool, rootScope);
717
+ const toolPath = ['tools', index];
718
+ checkMinimumVersion(`tools[${index}]`, toolPath, tool, 'title', '2025-06-18', scope);
719
+ checkMinimumVersion(`tools[${index}]`, toolPath, tool, 'outputSchema', '2025-06-18', scope);
720
+ checkMinimumVersion(`tools[${index}]`, toolPath, tool, '_meta', '2025-06-18', scope);
721
+ checkMinimumVersion(`tools[${index}]`, toolPath, tool, 'annotations', '2025-03-26', scope);
722
+ checkOnlyVersion(`tools[${index}]`, toolPath, tool, 'execution', '2025-11-25', scope);
723
+ checkMinimumVersion(`tools[${index}]`, toolPath, tool, 'icons', '2025-11-25', scope);
724
+ });
725
+
726
+ for (const [kind, items] of [
727
+ ['resources', document.resources ?? []],
728
+ ['resourceTemplates', document.resourceTemplates ?? []],
729
+ ['prompts', document.prompts ?? []]
730
+ ]) {
731
+ items.forEach((item, index) => {
732
+ const scope = effectiveScope(document, item, rootScope);
733
+ const itemPath = [kind, index];
734
+ checkMinimumVersion(`${kind}[${index}]`, itemPath, item, 'title', '2025-06-18', scope);
735
+ checkMinimumVersion(`${kind}[${index}]`, itemPath, item, '_meta', '2025-06-18', scope);
736
+ checkMinimumVersion(`${kind}[${index}]`, itemPath, item, 'icons', '2025-11-25', scope);
737
+ if (kind !== 'prompts' && item.annotations?.lastModified !== undefined) {
738
+ for (const version of scope) {
739
+ if (protocolOrder.get(version) >= protocolOrder.get('2025-06-18')) continue;
740
+ diagnostics.push(
741
+ makeDiagnostic(
742
+ 'resource-annotation-not-supported-by-version',
743
+ 'error',
744
+ `${kind}[${index}].annotations.lastModified is not defined for MCP ${version}`,
745
+ [...itemPath, 'annotations', 'lastModified']
746
+ )
747
+ );
748
+ }
749
+ }
750
+ if (kind === 'prompts') {
751
+ (item.arguments ?? []).forEach((argument, argumentIndex) => {
752
+ checkMinimumVersion(
753
+ `${kind}[${index}].arguments[${argumentIndex}]`,
754
+ [...itemPath, 'arguments', argumentIndex],
755
+ argument,
756
+ 'title',
757
+ '2025-06-18',
758
+ scope
759
+ );
760
+ });
761
+ }
762
+ });
763
+ }
764
+
765
+ (document.capabilities ?? []).forEach((capability, index) => {
766
+ const scope = effectiveScope(document, capability, rootScope);
767
+ const capabilityPath = ['capabilities', index];
768
+ if ('completions' in capability) checkMinimumVersion(`capabilities[${index}]`, capabilityPath, capability, 'completions', '2025-03-26', scope);
769
+ if ('tasks' in capability) {
770
+ checkMinimumVersion(`capabilities[${index}]`, capabilityPath, capability, 'tasks', '2025-11-25', scope);
771
+ if (scope.includes('2026-07-28')) {
772
+ diagnostics.push(
773
+ makeDiagnostic(
774
+ 'tasks-core-not-valid-in-2026',
775
+ 'error',
776
+ `capabilities[${index}].tasks cannot apply to MCP 2026-07-28; use capabilities.extensions instead`,
777
+ [...capabilityPath, 'tasks']
778
+ )
779
+ );
780
+ }
781
+ }
782
+ if ('extensions' in capability) {
783
+ for (const version of scope) {
784
+ if (protocolOrder.get(version) < protocolOrder.get('2026-07-28')) {
785
+ diagnostics.push(
786
+ makeDiagnostic(
787
+ 'extensions-not-supported-by-version',
788
+ 'error',
789
+ `capabilities[${index}].extensions is not defined for MCP ${version}; it requires 2026-07-28`,
790
+ [...capabilityPath, 'extensions']
791
+ )
792
+ );
793
+ }
794
+ }
795
+ for (const identifier of Object.keys(capability.extensions ?? {})) {
796
+ if (!usesMcpReservedPrefix(identifier) || knownReservedCapabilityExtensions.has(identifier)) continue;
797
+ diagnostics.push(
798
+ makeDiagnostic(
799
+ 'unknown-reserved-extension-identifier',
800
+ 'warning',
801
+ `capabilities[${index}].extensions contains unrecognized identifier ${JSON.stringify(identifier)} under an MCP-reserved prefix; preserve it and review its authority`,
802
+ [...capabilityPath, 'extensions', identifier]
803
+ )
804
+ );
805
+ }
806
+ }
807
+ if ('logging' in capability && scope.includes('2026-07-28')) {
808
+ diagnostics.push(
809
+ makeDiagnostic(
810
+ 'logging-deprecated-in-2026',
811
+ 'warning',
812
+ `capabilities[${index}].logging applies to MCP 2026-07-28, where logging is deprecated`,
813
+ [...capabilityPath, 'logging']
814
+ )
815
+ );
816
+ }
817
+ });
818
+ }
819
+
820
+ const singleSchemaKeywords = [
821
+ 'additionalItems',
822
+ 'additionalProperties',
823
+ 'contains',
824
+ 'contentSchema',
825
+ 'else',
826
+ 'if',
827
+ 'items',
828
+ 'not',
829
+ 'propertyNames',
830
+ 'then',
831
+ 'unevaluatedItems',
832
+ 'unevaluatedProperties'
833
+ ];
834
+ const arraySchemaKeywords = ['allOf', 'anyOf', 'oneOf', 'prefixItems'];
835
+ const mapSchemaKeywords = ['$defs', 'definitions', 'dependentSchemas', 'patternProperties'];
836
+ const utf8Encoder = new TextEncoder();
837
+
838
+ function collectMcpHeaderAnnotations(schema) {
839
+ const annotations = [];
840
+
841
+ function visit(node, location, locationPath, propertiesOnlyPath, isPropertyNode) {
842
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return;
843
+ if (Object.hasOwn(node, 'x-mcp-header')) {
844
+ annotations.push({
845
+ location,
846
+ path: locationPath,
847
+ value: node['x-mcp-header'],
848
+ type: node.type,
849
+ staticallyReachable: propertiesOnlyPath && isPropertyNode
850
+ });
851
+ }
852
+
853
+ if (node.properties && typeof node.properties === 'object' && !Array.isArray(node.properties)) {
854
+ for (const [name, child] of Object.entries(node.properties)) {
855
+ visit(child, `${location}.properties[${JSON.stringify(name)}]`, [...locationPath, 'properties', name], propertiesOnlyPath, propertiesOnlyPath);
856
+ }
857
+ }
858
+
859
+ for (const keyword of mapSchemaKeywords) {
860
+ const map = node[keyword];
861
+ if (!map || typeof map !== 'object' || Array.isArray(map)) continue;
862
+ for (const [name, child] of Object.entries(map)) {
863
+ visit(child, `${location}.${keyword}[${JSON.stringify(name)}]`, [...locationPath, keyword, name], false, false);
864
+ }
865
+ }
866
+ for (const keyword of arraySchemaKeywords) {
867
+ if (!Array.isArray(node[keyword])) continue;
868
+ node[keyword].forEach((child, index) => visit(child, `${location}.${keyword}[${index}]`, [...locationPath, keyword, index], false, false));
869
+ }
870
+ for (const keyword of singleSchemaKeywords) {
871
+ const child = node[keyword];
872
+ if (Array.isArray(child)) {
873
+ child.forEach((item, index) => visit(item, `${location}.${keyword}[${index}]`, [...locationPath, keyword, index], false, false));
874
+ } else {
875
+ visit(child, `${location}.${keyword}`, [...locationPath, keyword], false, false);
876
+ }
877
+ }
878
+ const dependencies = node.dependencies;
879
+ if (dependencies && typeof dependencies === 'object' && !Array.isArray(dependencies)) {
880
+ for (const [name, child] of Object.entries(dependencies)) {
881
+ if (!Array.isArray(child)) visit(child, `${location}.dependencies[${JSON.stringify(name)}]`, [...locationPath, 'dependencies', name], false, false);
882
+ }
883
+ }
884
+ }
885
+
886
+ visit(schema, 'inputSchema', ['inputSchema'], true, false);
887
+ return annotations;
888
+ }
889
+
890
+ function validateMcpHeaderAnnotations(tool, toolIndex, scope, rel, diagnostics) {
891
+ const annotations = collectMcpHeaderAnnotations(tool.inputSchema);
892
+ if (!annotations.length) return;
893
+
894
+ for (const version of scope) {
895
+ if (version !== '2026-07-28') {
896
+ diagnostics.push(
897
+ makeDiagnostic(
898
+ 'field-not-supported-by-version',
899
+ 'error',
900
+ `tools[${toolIndex}].inputSchema uses x-mcp-header, which is not defined for MCP ${version}`,
901
+ ['tools', toolIndex, 'inputSchema']
902
+ )
903
+ );
904
+ }
905
+ }
906
+
907
+ const byCaseInsensitiveName = new Map();
908
+ for (const annotation of annotations) {
909
+ const location = `tools[${toolIndex}].${annotation.location}.x-mcp-header`;
910
+ const locationPath = ['tools', toolIndex, ...annotation.path, 'x-mcp-header'];
911
+ if (!annotation.staticallyReachable) {
912
+ diagnostics.push(
913
+ makeDiagnostic(
914
+ 'invalid-x-mcp-header',
915
+ 'error',
916
+ `${location} is not on a property statically reachable from the schema root through properties only`,
917
+ locationPath
918
+ )
919
+ );
920
+ }
921
+ if (typeof annotation.value !== 'string' || !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(annotation.value)) {
922
+ diagnostics.push(
923
+ makeDiagnostic(
924
+ 'invalid-x-mcp-header',
925
+ 'error',
926
+ `${location} must be a non-empty HTTP field-name token`,
927
+ locationPath
928
+ )
929
+ );
930
+ continue;
931
+ }
932
+ if (!['boolean', 'integer', 'string'].includes(annotation.type)) {
933
+ diagnostics.push(
934
+ makeDiagnostic(
935
+ 'invalid-x-mcp-header',
936
+ 'error',
937
+ `${location} may annotate only a boolean, integer, or string property`,
938
+ locationPath
939
+ )
940
+ );
941
+ }
942
+ const normalizedName = annotation.value.toLowerCase();
943
+ const previousLocation = byCaseInsensitiveName.get(normalizedName);
944
+ if (previousLocation) {
945
+ diagnostics.push(
946
+ makeDiagnostic(
947
+ 'duplicate-x-mcp-header',
948
+ 'error',
949
+ `${location} duplicates ${previousLocation} case-insensitively`,
950
+ locationPath
951
+ )
952
+ );
953
+ } else {
954
+ byCaseInsensitiveName.set(normalizedName, location);
955
+ }
956
+ }
957
+ }
958
+
959
+ function validateLegacyToolSchemaShape(schema, location, locationPath, rel, diagnostics) {
960
+ if (Object.hasOwn(schema, 'properties')) {
961
+ const properties = schema.properties;
962
+ if (!properties || typeof properties !== 'object' || Array.isArray(properties)) {
963
+ diagnostics.push(
964
+ makeDiagnostic(
965
+ 'invalid-legacy-tool-schema-shape',
966
+ 'error',
967
+ `${location}.properties must be an object for MCP revisions before ${toolSchemaDialectVersion}`,
968
+ [...locationPath, 'properties']
969
+ )
970
+ );
971
+ } else {
972
+ for (const [name, propertySchema] of Object.entries(properties)) {
973
+ if (propertySchema && typeof propertySchema === 'object' && !Array.isArray(propertySchema)) continue;
974
+ diagnostics.push(
975
+ makeDiagnostic(
976
+ 'invalid-legacy-tool-schema-shape',
977
+ 'error',
978
+ `${location}.properties[${JSON.stringify(name)}] must be an object for MCP revisions before ${toolSchemaDialectVersion}`,
979
+ [...locationPath, 'properties', name]
980
+ )
981
+ );
982
+ }
983
+ }
984
+ }
985
+ if (Object.hasOwn(schema, 'required') && (!Array.isArray(schema.required) || schema.required.some((name) => typeof name !== 'string'))) {
986
+ diagnostics.push(
987
+ makeDiagnostic(
988
+ 'invalid-legacy-tool-schema-shape',
989
+ 'error',
990
+ `${location}.required must be an array of strings for MCP revisions before ${toolSchemaDialectVersion}`,
991
+ [...locationPath, 'required']
992
+ )
993
+ );
994
+ }
995
+ }
996
+
997
+ function validateToolSchemas(document, rel, diagnostics) {
998
+ const rootScope = normalizeScope(document.protocolVersions ?? []);
999
+
1000
+ (document.tools ?? []).forEach((tool, toolIndex) => {
1001
+ const scope = effectiveScope(document, tool, rootScope);
1002
+ for (const fieldName of ['inputSchema', 'outputSchema']) {
1003
+ const schema = tool[fieldName];
1004
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) continue;
1005
+ const location = `tools[${toolIndex}].${fieldName}`;
1006
+ const locationPath = ['tools', toolIndex, fieldName];
1007
+ const hasLegacyScope = scope.some((version) => protocolOrder.get(version) < protocolOrder.get(toolSchemaDialectVersion));
1008
+ const hasDialectAwareScope = scope.some((version) => protocolOrder.get(version) >= protocolOrder.get(toolSchemaDialectVersion));
1009
+ if (Object.hasOwn(schema, '$schema')) {
1010
+ for (const version of scope) {
1011
+ if (protocolOrder.get(version) >= protocolOrder.get(toolSchemaDialectVersion)) continue;
1012
+ diagnostics.push(
1013
+ makeDiagnostic(
1014
+ 'field-not-supported-by-version',
1015
+ 'error',
1016
+ `${location}.$schema is not defined for MCP ${version}; it requires ${toolSchemaDialectVersion} or later`,
1017
+ [...locationPath, '$schema']
1018
+ )
1019
+ );
1020
+ }
1021
+ }
1022
+ if (hasLegacyScope) validateLegacyToolSchemaShape(schema, location, locationPath, rel, diagnostics);
1023
+
1024
+ if (hasDialectAwareScope) {
1025
+ const dialect = embeddedSchemaDialect(schema);
1026
+ if (!dialect) {
1027
+ diagnostics.push(
1028
+ makeDiagnostic(
1029
+ 'unsupported-tool-schema-dialect',
1030
+ 'error',
1031
+ `${location} declares an unsupported JSON Schema dialect ${JSON.stringify(schema.$schema)}`,
1032
+ [...locationPath, '$schema']
1033
+ )
1034
+ );
1035
+ } else {
1036
+ const validator = createValidatorForDialect(dialect);
1037
+ try {
1038
+ if (!validator.validateSchema(schema, true)) {
1039
+ const details = validator.errors?.map((error) => `${error.instancePath || '/'} ${error.message}`).join('; ') ?? 'unknown meta-schema error';
1040
+ diagnostics.push(
1041
+ makeDiagnostic(
1042
+ 'invalid-tool-schema',
1043
+ 'error',
1044
+ `${location} is not valid JSON Schema ${dialect}: ${details}`,
1045
+ locationPath
1046
+ )
1047
+ );
1048
+ } else {
1049
+ validator.compile(schema);
1050
+ }
1051
+ } catch (error) {
1052
+ const partial = schemaForPartialOfflineCompilation(schema);
1053
+ try {
1054
+ if (!partial.externalReferences.length) throw error;
1055
+ createValidatorForDialect(dialect).compile(partial.schema);
1056
+ const references = [...new Set(partial.externalReferences)].sort();
1057
+ diagnostics.push(
1058
+ makeDiagnostic(
1059
+ 'unresolved-external-tool-schema-reference',
1060
+ 'warning',
1061
+ `${location} contains external $ref values unavailable to offline validation (${references.map((reference) => JSON.stringify(reference)).join(', ')}); the references are preserved, but complete Tool schema validation was not possible`,
1062
+ locationPath
1063
+ )
1064
+ );
1065
+ } catch (partialError) {
1066
+ diagnostics.push(
1067
+ makeDiagnostic(
1068
+ 'invalid-tool-schema',
1069
+ 'error',
1070
+ `${location} is not a compilable JSON Schema ${dialect}: ${partialError.message}`,
1071
+ locationPath
1072
+ )
1073
+ );
1074
+ }
1075
+ }
1076
+ }
1077
+ }
1078
+
1079
+ if (fieldName === 'outputSchema' && schema.type !== 'object') {
1080
+ for (const version of scope) {
1081
+ if (version === '2026-07-28') continue;
1082
+ diagnostics.push(
1083
+ makeDiagnostic(
1084
+ 'field-not-supported-by-version',
1085
+ 'error',
1086
+ `${location} must have type "object" when it applies to MCP ${version}`,
1087
+ [...locationPath, 'type']
1088
+ )
1089
+ );
1090
+ }
1091
+ }
1092
+ }
1093
+ validateMcpHeaderAnnotations(tool, toolIndex, scope, rel, diagnostics);
1094
+ });
1095
+ }
1096
+
1097
+ function validateLegacyExampleValue(schema, value, location, locationPath, rel, diagnostics) {
1098
+ if (schema.type === 'object' && (!value || typeof value !== 'object' || Array.isArray(value))) {
1099
+ diagnostics.push(makeDiagnostic('tool-example-schema-mismatch', 'error', `${location} must be an object`, locationPath));
1100
+ return;
1101
+ }
1102
+ for (const name of schema.required ?? []) {
1103
+ if (!Object.hasOwn(value, name)) {
1104
+ diagnostics.push(makeDiagnostic('tool-example-schema-mismatch', 'error', `${location} is missing required property ${JSON.stringify(name)}`, [...locationPath, name]));
1105
+ }
1106
+ }
1107
+ if (schema.additionalProperties === false && schema.properties && typeof schema.properties === 'object') {
1108
+ for (const name of Object.keys(value)) {
1109
+ if (!Object.hasOwn(schema.properties, name)) {
1110
+ diagnostics.push(makeDiagnostic('tool-example-schema-mismatch', 'error', `${location} contains undeclared property ${JSON.stringify(name)}`, [...locationPath, name]));
1111
+ }
1112
+ }
1113
+ }
1114
+ }
1115
+
1116
+ function validateExampleValue(schema, value, location, locationPath, rel, diagnostics) {
1117
+ const dialect = embeddedSchemaDialect(schema);
1118
+ if (!dialect) return;
1119
+ const partial = schemaForPartialOfflineCompilation(schema);
1120
+ if (partial.externalReferences.length) {
1121
+ const references = [...new Set(partial.externalReferences)].sort();
1122
+ diagnostics.push(
1123
+ makeDiagnostic(
1124
+ 'incomplete-tool-example-validation',
1125
+ 'warning',
1126
+ `${location} could not be validated completely because its associated Tool schema contains unresolved external $ref values (${references.map((reference) => JSON.stringify(reference)).join(', ')})`,
1127
+ locationPath
1128
+ )
1129
+ );
1130
+ return;
1131
+ }
1132
+ try {
1133
+ const validate = createValidatorForDialect(dialect).compile(schema);
1134
+ if (!validate(value)) {
1135
+ const details = validate.errors?.map((error) => `${error.instancePath || '/'} ${error.message}`).join('; ') ?? 'unknown validation error';
1136
+ diagnostics.push(makeDiagnostic('tool-example-schema-mismatch', 'error', `${location} does not validate against its associated Tool schema: ${details}`, locationPath));
1137
+ }
1138
+ } catch {
1139
+ // validateToolSchemas reports the malformed or unsupported schema itself.
1140
+ }
1141
+ }
1142
+
1143
+ function validateToolExamples(document, rel, diagnostics) {
1144
+ const rootScope = normalizeScope(document.protocolVersions ?? []);
1145
+
1146
+ (document.tools ?? []).forEach((tool, toolIndex) => {
1147
+ if (!tool.examples || typeof tool.examples !== 'object' || Array.isArray(tool.examples)) return;
1148
+ const scope = effectiveScope(document, tool, rootScope);
1149
+ const legacyVersions = scope.filter((version) => protocolOrder.get(version) < protocolOrder.get(toolSchemaDialectVersion));
1150
+ const modernVersions = scope.filter((version) => protocolOrder.get(version) >= protocolOrder.get(toolSchemaDialectVersion));
1151
+
1152
+ for (const [exampleName, example] of Object.entries(tool.examples)) {
1153
+ if (!example || typeof example !== 'object') continue;
1154
+ const exampleLocation = `tools[${toolIndex}].examples[${JSON.stringify(exampleName)}]`;
1155
+ const examplePath = ['tools', toolIndex, 'examples', exampleName];
1156
+ const result = example.result ?? {};
1157
+ const resultPath = [...examplePath, 'result'];
1158
+ const successful = result.isError !== true;
1159
+
1160
+ for (const envelopeField of ['jsonrpc', 'id', 'error']) {
1161
+ if (Object.hasOwn(result, envelopeField)) {
1162
+ diagnostics.push(makeDiagnostic('tool-example-json-rpc-envelope', 'error', `${exampleLocation}.result must not contain JSON-RPC envelope field ${JSON.stringify(envelopeField)}`, [...resultPath, envelopeField]));
1163
+ }
1164
+ }
1165
+ for (const incompleteField of ['task', 'inputRequests', 'requestState']) {
1166
+ if (Object.hasOwn(result, incompleteField)) {
1167
+ diagnostics.push(makeDiagnostic('incomplete-tool-example-result', 'error', `${exampleLocation}.result must not contain non-completed workflow field ${JSON.stringify(incompleteField)}`, [...resultPath, incompleteField]));
1168
+ }
1169
+ }
1170
+
1171
+ for (const version of scope) {
1172
+ const resultLocation = `${exampleLocation}.result`;
1173
+ if (version === '2026-07-28' && result.resultType !== 'complete') {
1174
+ diagnostics.push(makeDiagnostic('tool-example-result-version-mismatch', 'error', `${resultLocation}.resultType must be "complete" for MCP ${version}`, [...resultPath, 'resultType']));
1175
+ }
1176
+ if (version !== '2026-07-28' && Object.hasOwn(result, 'resultType')) {
1177
+ diagnostics.push(makeDiagnostic('tool-example-result-version-mismatch', 'error', `${resultLocation}.resultType is not defined for MCP ${version}`, [...resultPath, 'resultType']));
1178
+ }
1179
+ if (Object.hasOwn(result, 'structuredContent') && protocolOrder.get(version) < protocolOrder.get('2025-06-18')) {
1180
+ diagnostics.push(makeDiagnostic('tool-example-result-version-mismatch', 'error', `${resultLocation}.structuredContent is not defined for MCP ${version}`, [...resultPath, 'structuredContent']));
1181
+ }
1182
+ if (Object.hasOwn(result, 'structuredContent') && version !== '2026-07-28' && (!result.structuredContent || typeof result.structuredContent !== 'object' || Array.isArray(result.structuredContent))) {
1183
+ diagnostics.push(makeDiagnostic('tool-example-result-version-mismatch', 'error', `${resultLocation}.structuredContent must be an object for MCP ${version}`, [...resultPath, 'structuredContent']));
1184
+ }
1185
+ (result.content ?? []).forEach((content, contentIndex) => {
1186
+ const contentLocation = `${resultLocation}.content[${contentIndex}]`;
1187
+ const contentPath = [...resultPath, 'content', contentIndex];
1188
+ if (content.type === 'audio' && protocolOrder.get(version) < protocolOrder.get('2025-03-26')) {
1189
+ diagnostics.push(makeDiagnostic('tool-example-content-version-mismatch', 'error', `${contentLocation} uses audio content, which is not defined for MCP ${version}`, [...contentPath, 'type']));
1190
+ }
1191
+ if (content.type === 'resource_link' && protocolOrder.get(version) < protocolOrder.get('2025-06-18')) {
1192
+ diagnostics.push(makeDiagnostic('tool-example-content-version-mismatch', 'error', `${contentLocation} uses resource-link content, which is not defined for MCP ${version}`, [...contentPath, 'type']));
1193
+ }
1194
+ if (Object.hasOwn(content, '_meta') && protocolOrder.get(version) < protocolOrder.get('2025-06-18')) {
1195
+ diagnostics.push(makeDiagnostic('tool-example-content-version-mismatch', 'error', `${contentLocation}._meta is not defined for MCP ${version}`, [...contentPath, '_meta']));
1196
+ }
1197
+ if (content.type === 'resource' && Object.hasOwn(content.resource ?? {}, '_meta') && protocolOrder.get(version) < protocolOrder.get('2025-06-18')) {
1198
+ diagnostics.push(makeDiagnostic('tool-example-content-version-mismatch', 'error', `${contentLocation}.resource._meta is not defined for MCP ${version}`, [...contentPath, 'resource', '_meta']));
1199
+ }
1200
+ if (content.type === 'resource_link' && Array.isArray(content.icons) && protocolOrder.get(version) < protocolOrder.get('2025-11-25')) {
1201
+ diagnostics.push(makeDiagnostic('tool-example-content-version-mismatch', 'error', `${contentLocation}.icons is not defined for MCP ${version}`, [...contentPath, 'icons']));
1202
+ }
1203
+ if (content.annotations?.lastModified !== undefined && protocolOrder.get(version) < protocolOrder.get('2025-06-18')) {
1204
+ diagnostics.push(makeDiagnostic('tool-example-content-version-mismatch', 'error', `${contentLocation}.annotations.lastModified is not defined for MCP ${version}`, [...contentPath, 'annotations', 'lastModified']));
1205
+ }
1206
+ });
1207
+ }
1208
+
1209
+ if (!successful && Object.hasOwn(result, 'structuredContent')) {
1210
+ diagnostics.push(makeDiagnostic('structured-tool-error-example', 'error', `${exampleLocation}.result must not contain structuredContent when isError is true`, [...resultPath, 'structuredContent']));
1211
+ }
1212
+ if (successful && tool.outputSchema && !Object.hasOwn(result, 'structuredContent')) {
1213
+ diagnostics.push(makeDiagnostic('missing-tool-example-structured-content', 'error', `${exampleLocation}.result must contain structuredContent because the Tool declares outputSchema`, [...resultPath, 'structuredContent']));
1214
+ }
1215
+
1216
+ if (legacyVersions.length) {
1217
+ validateLegacyExampleValue(tool.inputSchema, example.input, `${exampleLocation}.input`, [...examplePath, 'input'], rel, diagnostics);
1218
+ diagnostics.push(
1219
+ makeDiagnostic(
1220
+ 'incomplete-tool-example-validation',
1221
+ 'warning',
1222
+ `${exampleLocation}.input compatibility could not be validated completely for MCP ${legacyVersions.join(', ')} because those revisions do not define an embedded Tool-schema dialect`,
1223
+ [...examplePath, 'input']
1224
+ )
1225
+ );
1226
+ }
1227
+ if (modernVersions.length) {
1228
+ validateExampleValue(tool.inputSchema, example.input, `${exampleLocation}.input`, [...examplePath, 'input'], rel, diagnostics);
1229
+ }
1230
+ if (successful && Object.hasOwn(result, 'structuredContent') && tool.outputSchema) {
1231
+ if (legacyVersions.length) {
1232
+ validateLegacyExampleValue(tool.outputSchema, result.structuredContent, `${exampleLocation}.result.structuredContent`, [...resultPath, 'structuredContent'], rel, diagnostics);
1233
+ diagnostics.push(
1234
+ makeDiagnostic(
1235
+ 'incomplete-tool-example-validation',
1236
+ 'warning',
1237
+ `${exampleLocation}.result.structuredContent compatibility could not be validated completely for MCP ${legacyVersions.join(', ')} because those revisions do not define an embedded Tool-schema dialect`,
1238
+ [...resultPath, 'structuredContent']
1239
+ )
1240
+ );
1241
+ }
1242
+ if (modernVersions.length) {
1243
+ validateExampleValue(tool.outputSchema, result.structuredContent, `${exampleLocation}.result.structuredContent`, [...resultPath, 'structuredContent'], rel, diagnostics);
1244
+ }
1245
+ }
1246
+ }
1247
+ });
1248
+ }
1249
+
1250
+ function isValidBase64(value) {
1251
+ if (typeof value !== 'string' || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return false;
1252
+ if (value.endsWith('==')) return 'AQgw'.includes(value.at(-3));
1253
+ if (value.endsWith('=')) return 'AEIMQUYcgkosw048'.includes(value.at(-2));
1254
+ return true;
1255
+ }
1256
+
1257
+ function decodedBase64Length(value) {
1258
+ return (value.length / 4) * 3 - (value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0);
1259
+ }
1260
+
1261
+ function validateCompletedResourceResult(result, scope, location, locationPath, rel, diagnostics) {
1262
+ for (const envelopeField of ['jsonrpc', 'id', 'error']) {
1263
+ if (Object.hasOwn(result, envelopeField)) {
1264
+ diagnostics.push(makeDiagnostic('resource-example-json-rpc-envelope', 'error', `${location} must not contain JSON-RPC envelope field ${JSON.stringify(envelopeField)}`, [...locationPath, envelopeField]));
1265
+ }
1266
+ }
1267
+ for (const incompleteField of ['task', 'inputRequests', 'requestState']) {
1268
+ if (Object.hasOwn(result, incompleteField)) {
1269
+ diagnostics.push(makeDiagnostic('incomplete-resource-example-result', 'error', `${location} must not contain non-completed workflow field ${JSON.stringify(incompleteField)}`, [...locationPath, incompleteField]));
1270
+ }
1271
+ }
1272
+
1273
+ for (const version of scope) {
1274
+ if (version === '2026-07-28' && result.resultType !== 'complete') {
1275
+ diagnostics.push(makeDiagnostic('resource-example-result-version-mismatch', 'error', `${location}.resultType must be "complete" for MCP ${version}`, [...locationPath, 'resultType']));
1276
+ }
1277
+ if (version === '2026-07-28' && !Object.hasOwn(result, 'ttlMs')) {
1278
+ diagnostics.push(makeDiagnostic('resource-example-cache-fields-version-mismatch', 'error', `${location}.ttlMs is required for MCP ${version}`, [...locationPath, 'ttlMs']));
1279
+ }
1280
+ if (version === '2026-07-28' && !Object.hasOwn(result, 'cacheScope')) {
1281
+ diagnostics.push(makeDiagnostic('resource-example-cache-fields-version-mismatch', 'error', `${location}.cacheScope is required for MCP ${version}`, [...locationPath, 'cacheScope']));
1282
+ }
1283
+ if (version !== '2026-07-28' && Object.hasOwn(result, 'resultType')) {
1284
+ diagnostics.push(makeDiagnostic('resource-example-result-version-mismatch', 'error', `${location}.resultType is not defined for MCP ${version}`, [...locationPath, 'resultType']));
1285
+ }
1286
+ for (const cacheField of ['ttlMs', 'cacheScope']) {
1287
+ if (version !== '2026-07-28' && Object.hasOwn(result, cacheField)) {
1288
+ diagnostics.push(makeDiagnostic('resource-example-cache-fields-version-mismatch', 'error', `${location}.${cacheField} is not defined for MCP ${version}`, [...locationPath, cacheField]));
1289
+ }
1290
+ }
1291
+ if (Object.hasOwn(result, '_meta') && protocolOrder.get(version) < protocolOrder.get('2025-06-18')) {
1292
+ diagnostics.push(makeDiagnostic('resource-example-result-version-mismatch', 'error', `${location}._meta is not defined for MCP ${version}`, [...locationPath, '_meta']));
1293
+ }
1294
+ (result.contents ?? []).forEach((content, contentIndex) => {
1295
+ if (Object.hasOwn(content, '_meta') && protocolOrder.get(version) < protocolOrder.get('2025-06-18')) {
1296
+ diagnostics.push(makeDiagnostic('resource-example-content-version-mismatch', 'error', `${location}.contents[${contentIndex}]._meta is not defined for MCP ${version}`, [...locationPath, 'contents', contentIndex, '_meta']));
1297
+ }
1298
+ });
1299
+ }
1300
+ }
1301
+
1302
+ function validateResourceExampleContents(owner, requestedUri, result, location, locationPath, rel, diagnostics) {
1303
+ const contents = result.contents ?? [];
1304
+ let requestedEntryFound = false;
1305
+
1306
+ contents.forEach((content, contentIndex) => {
1307
+ const contentLocation = `${location}.result.contents[${contentIndex}]`;
1308
+ const contentPath = [...locationPath, 'result', 'contents', contentIndex];
1309
+ if (content.uri === requestedUri) {
1310
+ requestedEntryFound = true;
1311
+ if (owner.mimeType && content.mimeType && content.mimeType !== owner.mimeType) {
1312
+ diagnostics.push(makeDiagnostic('resource-example-mime-type-mismatch', 'warning', `${contentLocation}.mimeType ${JSON.stringify(content.mimeType)} differs from the declaration MIME type ${JSON.stringify(owner.mimeType)}`, [...contentPath, 'mimeType']));
1313
+ }
1314
+ if (owner.size !== undefined) {
1315
+ const actualSize = typeof content.text === 'string'
1316
+ ? utf8Encoder.encode(content.text).byteLength
1317
+ : typeof content.blob === 'string' && isValidBase64(content.blob)
1318
+ ? decodedBase64Length(content.blob)
1319
+ : undefined;
1320
+ if (actualSize !== undefined && actualSize !== owner.size) {
1321
+ diagnostics.push(makeDiagnostic('resource-example-size-mismatch', 'warning', `${contentLocation} contains ${actualSize} raw bytes, which differs from the declared Resource size ${owner.size}`, contentPath));
1322
+ }
1323
+ }
1324
+ }
1325
+ if (typeof content.blob === 'string' && !isValidBase64(content.blob)) {
1326
+ diagnostics.push(makeDiagnostic('invalid-resource-example-base64', 'error', `${contentLocation}.blob must be valid canonical base64`, [...contentPath, 'blob']));
1327
+ }
1328
+ });
1329
+
1330
+ if (!requestedEntryFound) {
1331
+ diagnostics.push(makeDiagnostic('resource-example-requested-uri-not-returned', 'warning', `${location}.result.contents has no entry for requested URI ${JSON.stringify(requestedUri)}; this is valid only for a documented collection or indirection`, [...locationPath, 'result', 'contents']));
1332
+ }
1333
+ }
1334
+
1335
+ function validateResourceExamples(document, rel, diagnostics) {
1336
+ const rootScope = normalizeScope(document.protocolVersions ?? []);
1337
+
1338
+ for (const [kind, items] of [
1339
+ ['resources', document.resources ?? []],
1340
+ ['resourceTemplates', document.resourceTemplates ?? []]
1341
+ ]) {
1342
+ items.forEach((owner, ownerIndex) => {
1343
+ if (!owner.examples || typeof owner.examples !== 'object' || Array.isArray(owner.examples)) return;
1344
+ const scope = effectiveScope(document, owner, rootScope);
1345
+ let template;
1346
+ if (kind === 'resourceTemplates') {
1347
+ try {
1348
+ template = new UriTemplateMatcher();
1349
+ template.add(owner.uriTemplate);
1350
+ } catch (error) {
1351
+ template = undefined;
1352
+ diagnostics.push(makeDiagnostic('resource-template-example-invalid-template', 'error', `${kind}[${ownerIndex}].uriTemplate is not a valid RFC 6570 template: ${error.message}`, [kind, ownerIndex, 'uriTemplate']));
1353
+ }
1354
+ }
1355
+
1356
+ for (const [exampleName, example] of Object.entries(owner.examples)) {
1357
+ if (!example || typeof example !== 'object' || Array.isArray(example)) continue;
1358
+ const location = `${kind}[${ownerIndex}].examples[${JSON.stringify(exampleName)}]`;
1359
+ const locationPath = [kind, ownerIndex, 'examples', exampleName];
1360
+ const result = example.result;
1361
+ if (!result || typeof result !== 'object' || Array.isArray(result)) continue;
1362
+ const requestedUri = kind === 'resources' ? owner.uri : example.uri;
1363
+
1364
+ if (kind === 'resourceTemplates' && typeof requestedUri === 'string' && template && !template.match(requestedUri)) {
1365
+ diagnostics.push(makeDiagnostic('resource-template-example-invalid-expansion', 'error', `${location}.uri ${JSON.stringify(requestedUri)} is not a valid RFC 6570 expansion of ${JSON.stringify(owner.uriTemplate)}`, [...locationPath, 'uri']));
1366
+ }
1367
+
1368
+ validateCompletedResourceResult(result, scope, `${location}.result`, [...locationPath, 'result'], rel, diagnostics);
1369
+ if (typeof requestedUri === 'string') validateResourceExampleContents(owner, requestedUri, result, location, locationPath, rel, diagnostics);
1370
+ }
1371
+ });
1372
+ }
1373
+ }
1374
+
1375
+ export function semanticValidateDocument(document, rel = 'document') {
1376
+ const diagnostics = [];
1377
+ validateProtocolScopes(document, rel, diagnostics);
1378
+ validateSecurityRequirements(document, rel, diagnostics);
1379
+ validateTagReferences(document, rel, diagnostics);
1380
+ validateVersionSpecificSemantics(document, rel, diagnostics);
1381
+ validateElicitations(document, rel, diagnostics);
1382
+ validateMeta(document, rel, diagnostics);
1383
+ validateToolSchemas(document, rel, diagnostics);
1384
+ validateToolExamples(document, rel, diagnostics);
1385
+ validateResourceExamples(document, rel, diagnostics);
1386
+ diagnostics.sort(
1387
+ (left, right) => left.code.localeCompare(right.code)
1388
+ || left.message.localeCompare(right.message)
1389
+ || JSON.stringify(left.path).localeCompare(JSON.stringify(right.path))
1390
+ );
1391
+ return diagnostics;
1392
+ }
1393
+
1394
+ export function createMcpdesc08Validator(schema) {
1395
+ const ajv = createValidatorForDialect('2020-12');
1396
+ return ajv.compile(schema);
1397
+ }