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