@mcpdesc/validator 0.1.0 → 0.2.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,446 @@
1
+ // Validate the immutable Draft 2 snapshot using only snapshot-local artifacts.
2
+
3
+ import Ajv2020 from 'ajv/dist/2020.js';
4
+ import addFormats from 'ajv-formats';
5
+ import schema from './schema.json' with { type: 'json' };
6
+ import {
7
+ semanticValidateDocument as validateDraft1Semantics,
8
+ supportedProtocolVersions
9
+ } from './base.js';
10
+
11
+ export { supportedProtocolVersions };
12
+
13
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
14
+ addFormats(ajv);
15
+ const validateStructure = ajv.compile(schema);
16
+ const provenanceDigestPattern = /^[A-Za-z][A-Za-z0-9+._-]*:[^\s]+$/;
17
+ const primitiveCollections = ['tools', 'resources', 'resourceTemplates', 'prompts'];
18
+ const componentNamespaces = ['schemas', 'toolExamples', 'resourceExamples', 'resourceTemplateExamples'];
19
+ const knownClientCapabilities = new Set(['roots', 'sampling', 'elicitation', 'tasks', 'extensions', 'experimental']);
20
+ const knownReservedCapabilityExtensions = new Set(['io.modelcontextprotocol/tasks']);
21
+
22
+ const clientCapabilitiesByVersion = {
23
+ '2024-11-05': new Set(['roots', 'sampling', 'experimental']),
24
+ '2025-03-26': new Set(['roots', 'sampling', 'experimental']),
25
+ '2025-06-18': new Set(['roots', 'sampling', 'elicitation', 'experimental']),
26
+ '2025-11-25': new Set(['roots', 'sampling', 'elicitation', 'tasks', 'experimental']),
27
+ '2026-07-28': new Set(['roots', 'sampling', 'elicitation', 'extensions', 'experimental'])
28
+ };
29
+
30
+ function semanticDiagnostic(code, severity, message, path) {
31
+ return { code, severity, message, path };
32
+ }
33
+
34
+ function usesMcpReservedPrefix(identifier) {
35
+ if (typeof identifier !== 'string' || !identifier.includes('/')) return false;
36
+ const labels = identifier.slice(0, identifier.indexOf('/')).split('.');
37
+ return labels.length >= 2 && ['modelcontextprotocol', 'mcp'].includes(labels[1].toLowerCase());
38
+ }
39
+
40
+ function isReferenceObject(value) {
41
+ return value !== null
42
+ && typeof value === 'object'
43
+ && !Array.isArray(value)
44
+ && Object.hasOwn(value, '$componentRef');
45
+ }
46
+
47
+ function componentDiagnostic(code, rel, message, path) {
48
+ return {
49
+ code,
50
+ severity: 'error',
51
+ message: `${rel}.${path.map((segment) => typeof segment === 'number' ? `[${segment}]` : segment).join('.')} ${message}`,
52
+ path
53
+ };
54
+ }
55
+
56
+ export function resolveComponentReferences(document, rel = 'document') {
57
+ const resolved = structuredClone(document);
58
+ const diagnostics = [];
59
+ let substitutions = 0;
60
+
61
+ function resolve(reference, expectedNamespace, path, stack = []) {
62
+ const match = /^#\/components\/([^/]+)\/([^/]+)$/.exec(reference?.$componentRef ?? '');
63
+ if (!match) return reference;
64
+ const [, namespace, name] = match;
65
+ if (namespace !== expectedNamespace) {
66
+ diagnostics.push(componentDiagnostic(
67
+ 'wrong-component-reference-namespace',
68
+ rel,
69
+ `must target #/components/${expectedNamespace}, not #/components/${namespace}`,
70
+ path
71
+ ));
72
+ return reference;
73
+ }
74
+
75
+ const key = `${namespace}/${name}`;
76
+ if (stack.includes(key)) {
77
+ diagnostics.push(componentDiagnostic(
78
+ 'component-reference-cycle',
79
+ rel,
80
+ `forms a cycle through ${[...stack, key].join(' -> ')}`,
81
+ path
82
+ ));
83
+ return reference;
84
+ }
85
+
86
+ const target = document?.components?.[namespace]?.[name];
87
+ if (target === undefined) {
88
+ diagnostics.push(componentDiagnostic(
89
+ 'missing-component-reference-target',
90
+ rel,
91
+ `targets missing component ${JSON.stringify(reference.$componentRef)}`,
92
+ path
93
+ ));
94
+ return reference;
95
+ }
96
+ substitutions += 1;
97
+ return isReferenceObject(target)
98
+ ? resolve(target, expectedNamespace, path, [...stack, key])
99
+ : structuredClone(target);
100
+ }
101
+
102
+ for (const namespace of componentNamespaces) {
103
+ for (const [name, value] of Object.entries(document?.components?.[namespace] ?? {})) {
104
+ if (isReferenceObject(value)) {
105
+ resolved.components[namespace][name] = resolve(value, namespace, ['components', namespace, name], [`${namespace}/${name}`]);
106
+ }
107
+ }
108
+ }
109
+
110
+ for (const [collection, declarations] of Object.entries({
111
+ tools: document?.tools,
112
+ resources: document?.resources,
113
+ resourceTemplates: document?.resourceTemplates
114
+ })) {
115
+ for (const [declarationIndex, declaration] of (declarations ?? []).entries()) {
116
+ const resolvedDeclaration = resolved[collection][declarationIndex];
117
+ if (collection === 'tools') {
118
+ for (const field of ['inputSchema', 'outputSchema']) {
119
+ if (isReferenceObject(declaration[field])) {
120
+ resolvedDeclaration[field] = resolve(declaration[field], 'schemas', [collection, declarationIndex, field]);
121
+ }
122
+ }
123
+ }
124
+
125
+ const exampleNamespace = collection === 'tools'
126
+ ? 'toolExamples'
127
+ : collection === 'resources' ? 'resourceExamples' : 'resourceTemplateExamples';
128
+ for (const [name, example] of Object.entries(declaration.examples ?? {})) {
129
+ if (isReferenceObject(example)) {
130
+ resolvedDeclaration.examples[name] = resolve(
131
+ example,
132
+ exampleNamespace,
133
+ [collection, declarationIndex, 'examples', name]
134
+ );
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ for (const collection of primitiveCollections) {
141
+ for (const [declarationIndex, declaration] of (document?.[collection] ?? []).entries()) {
142
+ for (const [elicitationIndex, elicitation] of (declaration.elicitations ?? []).entries()) {
143
+ if (isReferenceObject(elicitation.requestedSchema)) {
144
+ resolved[collection][declarationIndex].elicitations[elicitationIndex].requestedSchema = resolve(
145
+ elicitation.requestedSchema,
146
+ 'schemas',
147
+ [collection, declarationIndex, 'elicitations', elicitationIndex, 'requestedSchema']
148
+ );
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ return { document: resolved, diagnostics, substitutions };
155
+ }
156
+
157
+ function structuralPath(document, error) {
158
+ const path = error.instancePath
159
+ ? error.instancePath.slice(1).split('/').map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~'))
160
+ : [];
161
+ if (error.keyword === 'required' && typeof error.params.missingProperty === 'string') {
162
+ path.push(error.params.missingProperty);
163
+ } else if (error.keyword === 'additionalProperties' && typeof error.params.additionalProperty === 'string') {
164
+ path.push(error.params.additionalProperty);
165
+ }
166
+ return path.map((segment) => Array.isArray(document) && /^(?:0|[1-9][0-9]*)$/.test(segment) ? Number(segment) : segment);
167
+ }
168
+
169
+ function structuralDiagnostics(document) {
170
+ if (validateStructure(document)) return [];
171
+ return (validateStructure.errors ?? []).map((error) => ({
172
+ code: 'schema-validation',
173
+ severity: 'error',
174
+ message: `does not validate against 0.8.0: ${error.instancePath || '/'} ${error.message ?? 'unknown error'}`,
175
+ path: structuralPath(document, error)
176
+ }));
177
+ }
178
+
179
+ function validateProvenance(document, rel) {
180
+ const diagnostics = [];
181
+ const records = document?.provenance?.records ?? {};
182
+
183
+ for (const [recordId, record] of Object.entries(records)) {
184
+ const digest = record?.artifact?.digest;
185
+ if (typeof digest === 'string' && !provenanceDigestPattern.test(digest)) {
186
+ diagnostics.push({
187
+ code: 'invalid-provenance-digest',
188
+ severity: 'error',
189
+ message: `${rel}.provenance.records[${JSON.stringify(recordId)}].artifact.digest must identify a digest algorithm and non-empty value separated by ":"`,
190
+ path: ['provenance', 'records', recordId, 'artifact', 'digest']
191
+ });
192
+ }
193
+ }
194
+
195
+ const references = [
196
+ ['provenance', 'defaultIds', document?.provenance?.defaultIds]
197
+ ];
198
+ for (const collection of primitiveCollections) {
199
+ for (const [index, primitive] of (document?.[collection] ?? []).entries()) {
200
+ references.push([collection, index, 'provenanceIds', primitive?.provenanceIds]);
201
+ }
202
+ }
203
+
204
+ for (const reference of references) {
205
+ const ids = reference.at(-1);
206
+ if (!Array.isArray(ids)) continue;
207
+ const path = reference.slice(0, -1);
208
+ for (const [index, id] of ids.entries()) {
209
+ if (Object.hasOwn(records, id)) continue;
210
+ diagnostics.push({
211
+ code: 'unknown-provenance-reference',
212
+ severity: 'error',
213
+ message: `${rel}.${path.join('.')}[${index}] references unknown provenance record ${JSON.stringify(id)}`,
214
+ path: [...path, index]
215
+ });
216
+ }
217
+ }
218
+
219
+ return diagnostics;
220
+ }
221
+
222
+ function validateClientRequirements(document) {
223
+ const diagnostics = [];
224
+ const rootScope = document.protocolVersions ?? [];
225
+
226
+ function invalidMember(location, path, version, member) {
227
+ diagnostics.push(semanticDiagnostic(
228
+ 'client-requirement-version-mismatch',
229
+ 'error',
230
+ `${location} member ${JSON.stringify(member)} is not defined by MCP ${version}`,
231
+ [...path, member]
232
+ ));
233
+ }
234
+
235
+ function requireObject(value, location, path, version) {
236
+ if (value && typeof value === 'object' && !Array.isArray(value)) return true;
237
+ diagnostics.push(semanticDiagnostic(
238
+ 'invalid-client-requirement-value',
239
+ 'error',
240
+ `${location} must be an object for MCP ${version}`,
241
+ path
242
+ ));
243
+ return false;
244
+ }
245
+
246
+ function validateClosedMembers(value, allowed, location, path, version) {
247
+ if (!requireObject(value, location, path, version)) return false;
248
+ for (const member of Object.keys(value)) {
249
+ if (!allowed.has(member)) invalidMember(location, path, version, member);
250
+ }
251
+ return true;
252
+ }
253
+
254
+ function validateTasks(value, location, path, version) {
255
+ validateClosedMembers(value, new Set(['list', 'cancel', 'requests']), location, path, version);
256
+ for (const member of ['list', 'cancel']) {
257
+ if (Object.hasOwn(value, member)) requireObject(value[member], `${location}.${member}`, [...path, member], version);
258
+ }
259
+ if (!Object.hasOwn(value, 'requests') || !requireObject(value.requests, `${location}.requests`, [...path, 'requests'], version)) return;
260
+ validateClosedMembers(value.requests, new Set(['sampling', 'elicitation']), `${location}.requests`, [...path, 'requests'], version);
261
+ for (const [family, operation] of [['sampling', 'createMessage'], ['elicitation', 'create']]) {
262
+ if (!Object.hasOwn(value.requests, family)) continue;
263
+ const familyValue = value.requests[family];
264
+ const familyPath = [...path, 'requests', family];
265
+ if (!validateClosedMembers(familyValue, new Set([operation]), `${location}.requests.${family}`, familyPath, version)) continue;
266
+ if (Object.hasOwn(familyValue, operation)) {
267
+ requireObject(familyValue[operation], `${location}.requests.${family}.${operation}`, [...familyPath, operation], version);
268
+ }
269
+ }
270
+ }
271
+
272
+ for (const collection of primitiveCollections) {
273
+ for (const [primitiveIndex, primitive] of (document[collection] ?? []).entries()) {
274
+ const requirements = primitive.clientRequirements;
275
+ if (!requirements || typeof requirements !== 'object' || Array.isArray(requirements)) continue;
276
+ const scope = primitive.protocolVersions ?? rootScope;
277
+ const baseLocation = `${collection}[${primitiveIndex}].clientRequirements`;
278
+ const basePath = [collection, primitiveIndex, 'clientRequirements'];
279
+
280
+ for (const version of scope) {
281
+ const available = clientCapabilitiesByVersion[version] ?? new Set();
282
+ for (const [capability, value] of Object.entries(requirements)) {
283
+ if (capability.startsWith('x-')) continue;
284
+ if (knownClientCapabilities.has(capability) && !available.has(capability)) {
285
+ diagnostics.push(semanticDiagnostic(
286
+ 'client-requirement-version-mismatch',
287
+ 'error',
288
+ `${baseLocation}.${capability} is not defined by MCP ${version}; split the primitive into disjoint protocol-scoped variants when requirements differ`,
289
+ [...basePath, capability]
290
+ ));
291
+ continue;
292
+ }
293
+ if (!available.has(capability)) continue;
294
+
295
+ const location = `${baseLocation}.${capability}`;
296
+ const path = [...basePath, capability];
297
+ if (capability === 'roots') {
298
+ validateClosedMembers(value, version === '2026-07-28' ? new Set() : new Set(['listChanged']), location, path, version);
299
+ if (version !== '2026-07-28' && Object.hasOwn(value, 'listChanged') && typeof value.listChanged !== 'boolean') {
300
+ diagnostics.push(semanticDiagnostic('invalid-client-requirement-value', 'error', `${location}.listChanged must be a boolean for MCP ${version}`, [...path, 'listChanged']));
301
+ }
302
+ } else if (capability === 'sampling' && ['2025-11-25', '2026-07-28'].includes(version)) {
303
+ validateClosedMembers(value, new Set(['context', 'tools']), location, path, version);
304
+ for (const member of ['context', 'tools']) {
305
+ if (Object.hasOwn(value, member)) requireObject(value[member], `${location}.${member}`, [...path, member], version);
306
+ }
307
+ if (Object.hasOwn(value, 'context')) {
308
+ diagnostics.push(semanticDiagnostic('deprecated-client-requirement', 'warning', `${location}.context uses deprecated MCP context-inclusion capability semantics in MCP ${version}`, [...path, 'context']));
309
+ }
310
+ } else if (capability === 'elicitation' && ['2025-11-25', '2026-07-28'].includes(version)) {
311
+ validateClosedMembers(value, new Set(['form', 'url']), location, path, version);
312
+ for (const member of ['form', 'url']) {
313
+ if (Object.hasOwn(value, member)) requireObject(value[member], `${location}.${member}`, [...path, member], version);
314
+ }
315
+ } else if (capability === 'tasks') {
316
+ validateTasks(value, location, path, version);
317
+ } else if (capability === 'extensions') {
318
+ for (const extension of Object.keys(value)) {
319
+ if (usesMcpReservedPrefix(extension) && !knownReservedCapabilityExtensions.has(extension)) {
320
+ diagnostics.push(semanticDiagnostic(
321
+ 'unknown-reserved-extension-identifier',
322
+ 'warning',
323
+ `${location} contains unrecognized extension ${JSON.stringify(extension)} under an MCP-reserved prefix; preserve it and review its authority`,
324
+ [...path, extension]
325
+ ));
326
+ }
327
+ }
328
+ }
329
+
330
+ if (version === '2026-07-28' && ['roots', 'sampling'].includes(capability)) {
331
+ diagnostics.push(semanticDiagnostic(
332
+ 'deprecated-client-requirement',
333
+ 'warning',
334
+ `${location} requires an MCP capability deprecated in MCP ${version}`,
335
+ path
336
+ ));
337
+ }
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ return diagnostics;
344
+ }
345
+
346
+ export function evaluateClientRequirements(requirements, clientCapabilities, version) {
347
+ if (!supportedProtocolVersions.includes(version)) {
348
+ throw new Error(`Unsupported MCP protocol revision ${JSON.stringify(version)}`);
349
+ }
350
+ if (requirements === undefined) {
351
+ return { status: 'satisfied', declared: false, unsatisfied: [], indeterminate: [] };
352
+ }
353
+
354
+ const profile = clientCapabilities && typeof clientCapabilities === 'object' && !Array.isArray(clientCapabilities)
355
+ ? clientCapabilities
356
+ : {};
357
+ const unsatisfied = [];
358
+ const indeterminate = [];
359
+
360
+ function requirePresence(owner, key, path) {
361
+ if (Object.hasOwn(owner, key)) return true;
362
+ unsatisfied.push(path);
363
+ return false;
364
+ }
365
+
366
+ function evaluateMarkerMap(required, advertised, path) {
367
+ for (const [member, settings] of Object.entries(required)) {
368
+ const memberPath = [...path, member];
369
+ if (!requirePresence(advertised, member, memberPath)) continue;
370
+ if (Object.keys(settings).length > 0) indeterminate.push(memberPath);
371
+ }
372
+ }
373
+
374
+ for (const [capability, required] of Object.entries(requirements)) {
375
+ if (capability.startsWith('x-')) continue;
376
+ const path = [capability];
377
+ if (!requirePresence(profile, capability, path)) continue;
378
+ const advertised = profile[capability];
379
+
380
+ if (capability === 'roots') {
381
+ if (Object.hasOwn(required, 'listChanged')
382
+ && (!advertised || advertised.listChanged !== required.listChanged)) {
383
+ unsatisfied.push([...path, 'listChanged']);
384
+ }
385
+ } else if (capability === 'sampling' || capability === 'elicitation') {
386
+ evaluateMarkerMap(required, advertised ?? {}, path);
387
+ } else if (capability === 'tasks') {
388
+ for (const member of ['list', 'cancel']) {
389
+ if (Object.hasOwn(required, member)) requirePresence(advertised ?? {}, member, [...path, member]);
390
+ }
391
+ for (const [family, operation] of [['sampling', 'createMessage'], ['elicitation', 'create']]) {
392
+ if (!Object.hasOwn(required.requests ?? {}, family)) continue;
393
+ const familyPath = [...path, 'requests', family];
394
+ if (!requirePresence(advertised?.requests ?? {}, family, familyPath)) continue;
395
+ if (Object.hasOwn(required.requests[family], operation)) {
396
+ requirePresence(advertised.requests[family] ?? {}, operation, [...familyPath, operation]);
397
+ }
398
+ }
399
+ } else if (capability === 'extensions') {
400
+ evaluateMarkerMap(required, advertised ?? {}, path);
401
+ } else if (capability === 'experimental') {
402
+ for (const member of Object.keys(required)) {
403
+ const memberPath = [...path, member];
404
+ if (requirePresence(advertised ?? {}, member, memberPath)) indeterminate.push(memberPath);
405
+ }
406
+ } else {
407
+ indeterminate.push(path);
408
+ }
409
+ }
410
+
411
+ return {
412
+ status: unsatisfied.length ? 'unsatisfied' : indeterminate.length ? 'indeterminate' : 'satisfied',
413
+ declared: true,
414
+ unsatisfied,
415
+ indeterminate
416
+ };
417
+ }
418
+
419
+ export function semanticValidateDocument(document, rel = 'document') {
420
+ const resolution = resolveComponentReferences(document, rel);
421
+ const resolvedStructureDiagnostics = resolution.diagnostics.length || resolution.substitutions === 0
422
+ ? []
423
+ : structuralDiagnostics(resolution.document);
424
+ const diagnostics = [
425
+ ...resolution.diagnostics,
426
+ ...resolvedStructureDiagnostics,
427
+ ...(resolution.diagnostics.length || resolvedStructureDiagnostics.length
428
+ ? []
429
+ : validateDraft1Semantics(resolution.document, rel)),
430
+ ...validateProvenance(document, rel),
431
+ ...validateClientRequirements(document)
432
+ ];
433
+ const filtered = Object.hasOwn(document, 'transports')
434
+ ? diagnostics
435
+ : diagnostics.filter((diagnostic) => diagnostic.code !== 'transport-coverage-gap');
436
+ return filtered.sort(
437
+ (left, right) => left.code.localeCompare(right.code)
438
+ || left.message.localeCompare(right.message)
439
+ || JSON.stringify(left.path).localeCompare(JSON.stringify(right.path))
440
+ );
441
+ }
442
+
443
+ export function validateMcpdesc08Document(document) {
444
+ const diagnostics = structuralDiagnostics(document);
445
+ return diagnostics.length ? diagnostics : semanticValidateDocument(document);
446
+ }