@openwop/openwop-conformance 2.35.0 → 2.36.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.
Files changed (123) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +3 -3
  3. package/coverage.md +13 -1
  4. package/dist/lib/certification-bundle-v3.js +21 -4
  5. package/dist/spec-artifacts.lock.json +2 -2
  6. package/fixtures/a2ui-v09/negative-event-name-deleteall.json +139 -0
  7. package/fixtures/a2ui-v09/negative-extra-property.json +140 -0
  8. package/fixtures/a2ui-v09/negative-foreign-catalog.json +139 -0
  9. package/fixtures/a2ui-v09/negative-formatstring-label.json +145 -0
  10. package/fixtures/a2ui-v09/negative-functioncall-openurl.json +129 -0
  11. package/fixtures/a2ui-v09/negative-image-component.json +145 -0
  12. package/fixtures/a2ui-v09/negative-textfield-obscured.json +140 -0
  13. package/fixtures/a2ui-v09/negative-theme-iconurl.json +142 -0
  14. package/fixtures/a2ui-v09/positive-approve-brief.json +139 -0
  15. package/fixtures/conformance-artifact-emit.json +28 -0
  16. package/fixtures/conformance-clarification-nested.json +53 -0
  17. package/fixtures/conformance-clarification-sensitive.json +49 -0
  18. package/fixtures/conformance-credential.json +25 -0
  19. package/fixtures/conformance-mcp-client.json +28 -0
  20. package/fixtures/interrupt-payloads/interrupt-payload-approval.json +1 -0
  21. package/fixtures/interrupt-payloads/interrupt-payload-clarification.json +1 -0
  22. package/fixtures/interrupt-payloads/interrupt-payload-conversation-close.json +1 -0
  23. package/fixtures/interrupt-payloads/interrupt-payload-conversation-exchange.json +1 -0
  24. package/fixtures/interrupt-payloads/interrupt-payload-conversation-start.json +1 -0
  25. package/fixtures/interrupt-payloads/interrupt-payload-custom.json +1 -0
  26. package/fixtures/interrupt-payloads/interrupt-payload-external-event.json +1 -0
  27. package/fixtures/interrupt-payloads/interrupt-payload-low-confidence.json +1 -0
  28. package/fixtures/interrupt-payloads/negative-approval-with-clarification-data.json +1 -0
  29. package/fixtures/interrupt-payloads/negative-conversation-close-with-conversation-start-data.json +1 -0
  30. package/fixtures/interrupt-payloads/negative-conversation-start-with-conversation-exchange-data.json +1 -0
  31. package/fixtures/interrupt-payloads/negative-custom-with-conversation-start-data.json +1 -0
  32. package/fixtures/interrupt-payloads/negative-low-confidence-with-custom-data.json +1 -0
  33. package/fixtures/node-pack-runtime/negative-entry-mismatch.json +31 -0
  34. package/fixtures/node-pack-runtime/negative-headers.json +37 -0
  35. package/fixtures/node-pack-runtime/negative-http.json +31 -0
  36. package/fixtures/node-pack-runtime/negative-icons.json +37 -0
  37. package/fixtures/node-pack-runtime/negative-meta.json +36 -0
  38. package/fixtures/node-pack-runtime/negative-non-remote-language.json +31 -0
  39. package/fixtures/node-pack-runtime/negative-packages.json +41 -0
  40. package/fixtures/node-pack-runtime/negative-sse.json +31 -0
  41. package/fixtures/node-pack-runtime/negative-templated-url.json +31 -0
  42. package/fixtures/node-pack-runtime/negative-two-remotes.json +35 -0
  43. package/fixtures/node-pack-runtime/negative-variables.json +38 -0
  44. package/fixtures/node-pack-runtime/negative-version-range.json +31 -0
  45. package/fixtures/node-pack-runtime/positive-every-optional-member.json +40 -0
  46. package/fixtures/node-pack-runtime/positive-rfc-example.json +31 -0
  47. package/fixtures/node-pack-runtime/upstream/mcp-registry-server-2025-12-11.schema.json +574 -0
  48. package/fixtures/oauth-providers/synthetic.json +36 -4
  49. package/fixtures/upstream/a2a-v1.0.1/README.md +24 -0
  50. package/fixtures/upstream/a2a-v1.0.1/a2a.proto +811 -0
  51. package/fixtures/upstream/a2ui-v0.9/README.md +33 -0
  52. package/fixtures/upstream/a2ui-v0.9/catalog.json +1383 -0
  53. package/fixtures/upstream/a2ui-v0.9/common_types.json +305 -0
  54. package/fixtures/upstream/a2ui-v0.9/server_to_client.json +132 -0
  55. package/fixtures.md +139 -0
  56. package/package.json +2 -2
  57. package/requirements.json +4480 -97
  58. package/scenario-majors.json +93 -6
  59. package/schemas/CORPUS-STAMP.json +103 -89
  60. package/src/lib/behavior-gate.ts +7 -1
  61. package/src/lib/certification-bundle-v3.ts +18 -4
  62. package/src/lib/localized-content.ts +53 -0
  63. package/src/lib/mcp-fake-server.ts +105 -4
  64. package/src/lib/node-pack-runtime.ts +41 -0
  65. package/src/lib/oauth-as-double.ts +316 -0
  66. package/src/lib/protected-resource.ts +88 -0
  67. package/src/lib/standard-webhooks.ts +120 -0
  68. package/src/lib/toolCatalog.ts +54 -4
  69. package/src/lib/trace-context.ts +98 -0
  70. package/src/lib/v2.ts +6 -0
  71. package/src/lib/webhook-receiver.ts +201 -1
  72. package/src/scenarios/artifact-type-pack-manifest-validation.test.ts +10 -1
  73. package/src/scenarios/auth-challenge-no-oracle.test.ts +122 -0
  74. package/src/scenarios/auth-oauth2-client-credentials.test.ts +46 -5
  75. package/src/scenarios/cross-host-traceparent-propagation.test.ts +92 -58
  76. package/src/scenarios/debugBundle.test.ts +41 -0
  77. package/src/scenarios/fixtures-valid.test.ts +72 -0
  78. package/src/scenarios/inbound-credential-no-passthrough.test.ts +115 -0
  79. package/src/scenarios/interrupt-approver-routing.test.ts +3 -0
  80. package/src/scenarios/localized-content-delivery.test.ts +26 -15
  81. package/src/scenarios/mcp-current-auth-boundary.test.ts +38 -0
  82. package/src/scenarios/mcp-tool-roundtrip.test.ts +29 -0
  83. package/src/scenarios/otel-mcp-semconv-projection.test.ts +85 -0
  84. package/src/scenarios/tool-catalog-compact-projection.test.ts +28 -14
  85. package/src/scenarios/tool-catalog-projection.test.ts +48 -12
  86. package/src/scenarios/tool-descriptor-shape.test.ts +34 -13
  87. package/src/scenarios/v2-a2a-agent-cards.test.ts +305 -0
  88. package/src/scenarios/v2-a2a-operation-map.test.ts +310 -0
  89. package/src/scenarios/v2-a2ui-v09-surface.test.ts +283 -0
  90. package/src/scenarios/v2-advertised-fixtures-exist.test.ts +2 -2
  91. package/src/scenarios/v2-artifact-a2a-shape.test.ts +122 -0
  92. package/src/scenarios/v2-auth-challenge.test.ts +119 -0
  93. package/src/scenarios/v2-bound-id-kinds.test.ts +1 -1
  94. package/src/scenarios/v2-callback-url-guarded.test.ts +2 -2
  95. package/src/scenarios/v2-capability-maturity-bounded.test.ts +108 -0
  96. package/src/scenarios/v2-configurable-closed.test.ts +1 -1
  97. package/src/scenarios/v2-content-locale-keys.test.ts +92 -0
  98. package/src/scenarios/v2-conversation-turn-parts.test.ts +74 -0
  99. package/src/scenarios/v2-credential-interrupt.test.ts +253 -0
  100. package/src/scenarios/v2-effect-identity-business-key.test.ts +2 -2
  101. package/src/scenarios/v2-fork-a-v1-run.test.ts +1 -1
  102. package/src/scenarios/v2-implementation-informational.test.ts +33 -0
  103. package/src/scenarios/v2-interop-trace-context.test.ts +351 -0
  104. package/src/scenarios/v2-lane-exp-only-bound.test.ts +236 -0
  105. package/src/scenarios/v2-lane-issuer-advertised.test.ts +52 -3
  106. package/src/scenarios/v2-mcp-client-results.test.ts +202 -0
  107. package/src/scenarios/v2-mcp-mount-map.test.ts +308 -0
  108. package/src/scenarios/v2-mcp-tasks.test.ts +503 -0
  109. package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +203 -0
  110. package/src/scenarios/v2-oauth-mcp-reach-discovery.test.ts +186 -0
  111. package/src/scenarios/v2-oidc-id-token-audience.test.ts +108 -0
  112. package/src/scenarios/v2-protected-resource-metadata.test.ts +145 -0
  113. package/src/scenarios/v2-run-cancel.test.ts +1 -1
  114. package/src/scenarios/v2-run-fork-prefix.test.ts +4 -4
  115. package/src/scenarios/v2-run-pause-resume.test.ts +1 -1
  116. package/src/scenarios/v2-tool-catalog-annotations.test.ts +100 -0
  117. package/src/scenarios/v2-unmapped-type-refused.test.ts +1 -1
  118. package/src/scenarios/v2-v1-events-translated.test.ts +1 -1
  119. package/src/scenarios/v2-webhook-egress-refusal.test.ts +5 -5
  120. package/src/scenarios/v2-webhook-endpoint-verification.test.ts +124 -0
  121. package/src/scenarios/v2-webhook-message-id-stable.test.ts +124 -0
  122. package/src/scenarios/v2-webhook-secret-rotation.test.ts +132 -0
  123. package/src/scenarios/v2-webhook-standard-webhooks-delivery.test.ts +195 -0
@@ -95,6 +95,9 @@ describe('interrupt-approver-routing: ApprovalData additive optionality (interru
95
95
  const ajv = new Ajv2020({ strict: false, allErrors: true });
96
96
  addFormats(ajv);
97
97
  const suspend = loadSchema('suspend-request.schema.json');
98
+ // RFC 0199 added CredentialData, whose `credentialRef` $refs credential-reference.schema.json;
99
+ // a standalone compile has to register it or the whole document fails to resolve.
100
+ ajv.addSchema(loadSchema('credential-reference.schema.json'));
98
101
  const validate = ajv.compile(suspend);
99
102
 
100
103
  const baseApproval = {
@@ -10,7 +10,7 @@
10
10
  * A. Always-on, server-free legs — the `content` capability block, the four
11
11
  * content schemas (section / page / language-settings / page-response),
12
12
  * the §A capability-coherence constraints, and the §C per-section field
13
- * merge reference algorithm (`resolveSection`, exact → family → base,
13
+ * merge reference algorithm (`resolveSection`, exact → script family → language family → base (RFC 0206),
14
14
  * shallow overlay) shared verbatim with hosts.
15
15
  *
16
16
  * B. Capability-gated behavioral legs — on a host advertising
@@ -35,6 +35,7 @@ import { SCHEMAS_DIR } from '../lib/paths.js';
35
35
  import { behaviorGate } from '../lib/behavior-gate.js';
36
36
  import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
37
37
  import { req } from '../lib/requirement-ids.js';
38
+ import { resolveSection, resolveSectionRfc0103, type Section } from '../lib/localized-content.js';
38
39
  function loadSchema(name: string): Record<string, unknown> {
39
40
  return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
40
41
  }
@@ -53,20 +54,8 @@ interface I18nCap {
53
54
  const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
54
55
 
55
56
  // ── §C reference merge — shared verbatim with conforming hosts ──────────────
56
- type Section = {
57
- data: Record<string, unknown>;
58
- localizations: Record<string, Record<string, unknown>>;
59
- };
60
- function resolveSection(section: Section, negotiatedLocale: string, baseLocale: string): Record<string, unknown> {
61
- const loc = section.localizations ?? {};
62
- if (negotiatedLocale === baseLocale || Object.keys(loc).length === 0) return section.data;
63
- if (loc[negotiatedLocale]) return { ...section.data, ...loc[negotiatedLocale] };
64
- if (negotiatedLocale.includes('-')) {
65
- const lang = negotiatedLocale.split('-')[0];
66
- if (loc[lang]) return { ...section.data, ...loc[lang] };
67
- }
68
- return section.data;
69
- }
57
+ // One copy in src/lib (RFC 0206 register G5), amended by RFC 0206 §B.4; the
58
+ // corpus rows for it live in src/coherence/locale-key-grammar.test.ts.
70
59
 
71
60
  // ── §A capability-coherence predicate ──────────────────────────────────────
72
61
  function contentCoherent(content: ContentCap, i18n: I18nCap): boolean {
@@ -123,6 +112,18 @@ describe('localized-content: schema shapes (localized-content.md §B, server-fre
123
112
  expect(section({ ...goodSection, localizations: { EN: { heading: 'x' } } }), req('openwop.it.localized-content-delivery.a-localizations-key-with-wrong-case-underscore-is-rejected', 'RFC 0103 §B', 'a non-BCP-47-subset key MUST be rejected')).toBe(false);
124
113
  expect(section({ ...goodSection, localizations: { en_US: { heading: 'x' } } }), req('openwop.it.localized-content-delivery.a-localizations-key-with-wrong-case-underscore-is-rejected', 'RFC 0103 §B', 'an underscore locale key MUST be rejected')).toBe(false);
125
114
  });
115
+ // RFC 0206 §A (supplementary, non-gating here: the gating rows are the corpus
116
+ // coherence test locale-key-grammar.test.ts).
117
+ it('case-canonical BCP 47 keys outside the RFC 0103 subset validate (RFC 0206)', () => {
118
+ for (const key of ['zh-Hans', 'zh-Hant-TW', 'es-419', 'fil']) {
119
+ expect(section({ ...goodSection, localizations: { [key]: { heading: 'x' } } }), req('openwop.it.localized-content-delivery.case-canonical-bcp-47-keys-outside-the-rfc-0103-subset-validate-rfc-0206', 'RFC 0206 §A.1', `"${key}" MUST validate as a localizations key. Errors: ${JSON.stringify(section.errors)}`)).toBe(true);
120
+ }
121
+ });
122
+ it('non-canonical-case and out-of-subset keys are rejected (RFC 0206)', () => {
123
+ for (const key of ['en-us', 'zh-hans', 'de-CH-1996']) {
124
+ expect(section({ ...goodSection, localizations: { [key]: { heading: 'x' } } }), req('openwop.it.localized-content-delivery.non-canonical-case-and-out-of-subset-keys-are-rejected-rfc-0206', 'RFC 0206 §A.1', `"${key}" MUST be rejected as a localizations key`)).toBe(false);
125
+ }
126
+ });
126
127
  it('a section missing a required field is rejected', () => {
127
128
  const { status: _omit, ...noStatus } = goodSection;
128
129
  expect(section(noStatus), req('openwop.it.localized-content-delivery.a-section-missing-a-required-field-is-rejected', 'RFC 0103 §B', 'status is REQUIRED')).toBe(false);
@@ -177,6 +178,16 @@ describe('localized-content: per-section field merge (localized-content.md §C,
177
178
  const s: Section = { data: { h: 'Hi' }, localizations: { pt: { h: 'Oi' } } };
178
179
  expect(resolveSection(s, 'pt-BR', 'en'), req('openwop.it.localized-content-delivery.language-family-fallback-applies-when-exact-tag-is-absent', 'RFC 0103 §C', 'pt-BR MUST fall back to the pt family override')).toEqual({ h: 'Oi' });
179
180
  });
181
+ it('script-family fallback applies before the language family (RFC 0206)', () => {
182
+ const s: Section = { data: { h: 'x' }, localizations: { 'zh-Hant': { h: '繁' }, zh: { h: '简' } } };
183
+ expect(resolveSection(s, 'zh-Hant-TW', 'en'), req('openwop.it.localized-content-delivery.script-family-fallback-applies-before-the-language-family-rfc-0206', 'RFC 0206 §B.4', 'zh-Hant-TW MUST overlay zh-Hant before zh')).toEqual({ h: '繁' });
184
+ });
185
+ it('an RFC 0103-valid section resolves as RFC 0103 resolved it (RFC 0206)', () => {
186
+ const s: Section = { data: { h: 'x' }, localizations: { 'de-CH': { h: 'ch' }, de: { h: 'de' } } };
187
+ for (const n of ['de-CH-1996', 'de-CH', 'de-AT', 'en']) {
188
+ expect(resolveSection(s, n, 'en'), req('openwop.it.localized-content-delivery.an-rfc-0103-valid-section-resolves-as-rfc-0103-resolved-it-rfc-0206', 'RFC 0206 §B.5', `${n}: the amended merge MUST equal RFC 0103's`)).toEqual(resolveSectionRfc0103(s, n, 'en'));
189
+ }
190
+ });
180
191
  it('unsupported/base locale returns base data unchanged', () => {
181
192
  expect(resolveSection(section, 'de', 'en'), req('openwop.it.localized-content-delivery.unsupported-base-locale-returns-base-data-unchanged', 'RFC 0103 §C', 'no match MUST return base data')).toEqual(section.data);
182
193
  expect(resolveSection(section, 'en', 'en'), req('openwop.it.localized-content-delivery.unsupported-base-locale-returns-base-data-unchanged', 'RFC 0103 §C', 'base locale MUST return base data')).toEqual(section.data);
@@ -20,6 +20,7 @@ import { describe, it, expect } from 'vitest';
20
20
  import { driver } from '../lib/driver.js';
21
21
  import { seamAbsent, softSkip } from '../lib/soft-skip.js';
22
22
  import { mcpServerMount } from '../lib/mcp-mount.js';
23
+ import { parseChallenge, prmUrlFor } from '../lib/protected-resource.js';
23
24
  import { behaviorGate } from '../lib/behavior-gate.js';
24
25
  import { capabilityFamily } from '../lib/discovery-capabilities.js';
25
26
  import { req } from '../lib/requirement-ids.js';
@@ -81,4 +82,41 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §E — mcp-current-aut
81
82
  req('openwop.it.mcp-current-auth-boundary.an-unauthenticated-current-profile-request-is-refused-unless-anonymousactor-is-a', 'mcp-integration.md §E', 'a caller holding only a host-minted anonymous session cookie is still an anonymous principal — refuse (401/403) unless anonymousActor is advertised (S30)'),
82
83
  ).toContain(withSession.status);
83
84
  });
85
+
86
+ /**
87
+ * RFC 0200 §A.4 — the mount as its own OAuth protected resource. Upstream makes this a
88
+ * MUST for an MCP server whose mount requires OAuth ("MCP servers MUST implement one of
89
+ * the following discovery mechanisms"), and `mcp-integration.md` §E now carries it.
90
+ *
91
+ * NON-GATING coverage: this file is major-1 only, and `check-accepted-predicate` rule 4
92
+ * reads only certified v2 bundles, so a verdict recorded here can never satisfy the
93
+ * RFC's row. The gating witness is the mount leg of `v2-protected-resource-metadata`,
94
+ * against the v2 mount. This leg exists so a v1 host that DOES require an OAuth lane on
95
+ * its mount is measured today.
96
+ */
97
+ it('a mount that requires OAuth-lane authentication offers resource_metadata or a path-inserted PRM', async () => {
98
+ const { mcp, anon } = await disco();
99
+ const claims = mcp?.supported === true && (mcp.profiles ?? []).includes(PROFILE) && mcp.serverMount?.supported === true;
100
+ if (!behaviorGate(PROFILE, claims)) return;
101
+ if (anon?.supported === true) return softSkip('inapplicable', 'anonymousActor is advertised — the mount does not require authentication at all, so §A.4 does not bind it');
102
+ const mount = await mcpServerMount();
103
+ const anonymous = await driver.post(mount, REQ, { headers: HDR, authenticated: false });
104
+ if (anonymous.status !== 401) return softSkip('inapplicable', `the mount answered ${anonymous.status} without credentials, not 401 — §A.4 binds a mount that refuses for want of an OAuth-lane credential`);
105
+ const challenge = parseChallenge(anonymous.headers.get('www-authenticate'));
106
+ const viaChallenge = challenge !== null && challenge.scheme === 'bearer' && typeof challenge.params['resource_metadata'] === 'string';
107
+ let viaWellKnown = false;
108
+ if (!viaChallenge) {
109
+ const doc = await driver.get(prmUrlFor(mount.startsWith('http') ? mount : `${process.env['OPENWOP_BASE_URL'] ?? ''}${mount}`), { authenticated: false });
110
+ viaWellKnown = doc.status === 200 && typeof (doc.json as { resource?: unknown } | null)?.resource === 'string';
111
+ }
112
+ if (!viaChallenge && !viaWellKnown) {
113
+ // A mount that refuses an api-key caller for want of an api-key is not an OAuth
114
+ // protected resource, and this host's lanes are not readable from here at major 1.
115
+ return softSkip('inapplicable', 'the mount offers neither discovery mechanism and this host advertises no v1 OAuth/OIDC auth profile reachable from here — §A.4 binds only a mount that requires OAuth-lane authentication');
116
+ }
117
+ expect(
118
+ viaChallenge || viaWellKnown,
119
+ req('openwop.it.mcp-current-auth-boundary.a-mount-that-requires-oauth-lane-authentication-offers-resource-metadata-or-a-path-inserted-prm', 'spec/v1/mcp-integration.md §E (RFC 0200 §A.4)', 'a mount requiring OAuth-lane authentication MUST implement one of upstream\'s two discovery mechanisms for the mount URL as the resource'),
120
+ ).toBe(true);
121
+ });
84
122
  });
@@ -332,3 +332,32 @@ describe('mcp-tool-roundtrip: host-mediated tool invocation', () => {
332
332
  )).toBe(true);
333
333
  });
334
334
  });
335
+
336
+ /**
337
+ * RFC 0204 §B — v1 `raw` (a MAY; major 1 only). When the fixture node's output
338
+ * carries `raw`, it MUST be the server's `CallToolResult` for the negotiated
339
+ * revision, unaltered, and `isError` MUST equal `raw.isError === true`. Absent
340
+ * ⇒ `inapplicable`. Non-gating: outside RFC 0204's Acceptance box, and no v2
341
+ * cut reaches a major-1 file. Sabotage: rebuild `raw` from `result`.
342
+ */
343
+ describe('mcp-tool-roundtrip: v1 raw CallToolResult (RFC 0204 §B)', () => {
344
+ it('raw, when present, is the server CallToolResult unaltered and isError agrees', async () => {
345
+ const server = getMcpFakeServer();
346
+ if (!server) return softSkip('blocked', '[mcp-tool-roundtrip] fake server not started; the raw leg needs the host-mediated run');
347
+ if (!isFixtureAdvertised(ROUNDTRIP_FIXTURE)) return softSkip('inapplicable', `fixture ${ROUNDTRIP_FIXTURE} not advertised`);
348
+ server.reset();
349
+ const create = await driver.post('/v1/runs', { workflowId: ROUNDTRIP_FIXTURE, inputs: { text: 'raw-probe' } });
350
+ if (create.status !== 201) return softSkip('blocked', `POST /v1/runs answered ${create.status}`);
351
+ const runId = (create.json as { runId: string }).runId;
352
+ await pollUntilTerminal(runId, { timeoutMs: 30_000 });
353
+ const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
354
+ const list = (events.json as { events?: Array<{ type: string; payload?: Record<string, unknown> }> }).events ?? [];
355
+ const done = list.find((e) => e.type === 'node.completed' && e.payload?.['nodeId'] === 'mcp-call');
356
+ const outputs = (done?.payload?.['outputs'] ?? {}) as { raw?: unknown; isError?: unknown };
357
+ if (outputs.raw === undefined) return softSkip('inapplicable', 'the node output carries no raw (RFC 0204 §B: a MAY)');
358
+ const sent = [...server.exchanges()].reverse().find((x) => x.method === 'tools/call');
359
+ if (!sent) return softSkip('blocked', 'the output carries raw but the suite fake server answered no tools/call in this run');
360
+ expect(outputs.raw, req('openwop.it.mcp-tool-roundtrip.raw-when-present-is-the-server-calltoolresult-unaltered-and-iserror-agrees', 'host-capabilities.md §host.mcp (RFC 0204 §B)', 'raw MUST be the server\'s CallToolResult unaltered')).toEqual(sent.result);
361
+ expect(outputs.isError === true, req('openwop.it.mcp-tool-roundtrip.raw-when-present-is-the-server-calltoolresult-unaltered-and-iserror-agrees', 'host-capabilities.md §host.mcp (RFC 0204 §B)', 'isError MUST equal raw.isError === true')).toBe((outputs.raw as { isError?: unknown }).isError === true);
362
+ });
363
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * otel-mcp-semconv-projection — RFC 0207 §D (both majors: `BOTH_MAJORS`).
3
+ *
4
+ * `spec/v1/observability.md` §"MCP semantic-convention projection" (v1-carried
5
+ * normative text at major 2): a host MAY project `openwop.mcp.invocation` onto
6
+ * the OpenTelemetry MCP conventions (upstream Development stability). A host
7
+ * that emits ANY `mcp.*` attribute under the projection MUST also emit
8
+ * `openwop.otel.mcp_mapping_version: "0"` and `openwop.otel.mcp_semconv_ref:
9
+ * "open-telemetry/semantic-conventions-genai@<commit>"`, MUST NOT emit
10
+ * `mcp.session.id` for a 2026-07-28 exchange (that revision has no session), and
11
+ * MUST NOT emit `gen_ai.tool.call.arguments` / `.result` (content).
12
+ *
13
+ * Read from the suite's OTLP collector after one MCP-tool fixture run
14
+ * (`conformance-mcp-client` at major 2, `conformance-mcp-tool-roundtrip` at
15
+ * major 1). The projection is OPTIONAL, so a host that sends no `mcp.*` span —
16
+ * or a run with no collector to send it to — records `inapplicable` with that
17
+ * reason, never a failure and never a pass. Written major-aware: unversioned
18
+ * run paths through the driver, no capability-record gate.
19
+ *
20
+ * Sabotage: a projected span without the version stamp fails; one carrying
21
+ * `mcp.session.id` on a 2026-07-28 exchange fails; one carrying the tool
22
+ * arguments fails.
23
+ *
24
+ * @see spec/v1/observability.md §"MCP semantic-convention projection"
25
+ * @see RFCS/0207-trace-context-across-mcp-and-a2a.md §D
26
+ */
27
+
28
+ import { describe, it, expect } from 'vitest';
29
+ import { randomUUID } from 'node:crypto';
30
+ import { driver } from '../lib/driver.js';
31
+ import { targetMajor } from '../lib/seams.js';
32
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
33
+ import { getMcpFakeServer } from '../lib/mcp-fake-server.js';
34
+ import { getCollector, type CapturedSpan } from '../lib/otel-collector.js';
35
+ import { softSkip } from '../lib/soft-skip.js';
36
+ import { req } from '../lib/requirement-ids.js';
37
+
38
+ export const REQUIRES_HOST_CALLBACK = "the host exports OTLP spans to the suite's collector";
39
+
40
+ const ID = 'openwop.requirement.0207.mcp-semconv-stamped';
41
+ const DOC = 'observability.md §"MCP semantic-convention projection" (RFC 0207 §D)';
42
+ const SEMCONV_REF = /^open-telemetry\/semantic-conventions-genai@[0-9a-f]{7,40}$/;
43
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
44
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
45
+
46
+ /** One MCP-tool fixture run through the host, so an MCP span has a reason to exist. Best effort. */
47
+ async function driveOneMcpCall(): Promise<void> {
48
+ if (getMcpFakeServer() === null) return;
49
+ const v2 = targetMajor() === 2;
50
+ const fixture = v2 ? 'conformance-mcp-client' : 'conformance-mcp-tool-roundtrip';
51
+ if (!isFixtureAdvertised(fixture)) return;
52
+ const inputs = v2 ? { method: 'callTool', serverId: 'conformance', name: 'structured-echo', arguments: { nonce: randomUUID() } } : { text: 'semconv-probe' };
53
+ const created = await driver.post(v2 ? '/runs' : '/v1/runs', { workflowId: fixture, inputs }).catch(() => null);
54
+ const runId = (created?.json as { runId?: unknown } | null | undefined)?.runId;
55
+ if (typeof runId !== 'string') return;
56
+ const t0 = Date.now();
57
+ while (Date.now() - t0 < 20_000) {
58
+ const snap = await driver.get(`${v2 ? '' : '/v1'}/runs/${encodeURIComponent(runId)}`).catch(() => null);
59
+ if (TERMINAL.has(String((snap?.json as { status?: unknown } | null | undefined)?.status ?? ''))) return;
60
+ await sleep(200);
61
+ }
62
+ }
63
+
64
+ const projected = (s: CapturedSpan): boolean => [...s.attributes.keys()].some((k) => k.startsWith('mcp.'));
65
+
66
+ describe('otel-mcp-semconv-projection (RFC 0207 §D, both majors)', () => {
67
+ it('every projected mcp.* span carries the v0 version stamp and no forbidden attribute', async () => {
68
+ const collector = getCollector();
69
+ if (collector === null) return softSkip('inapplicable', 'the mcp.* projection is optional and observable only through OTLP export to the suite collector, which is not started in this run (OPENWOP_OTEL_COLLECTOR) — no mcp.* span can arrive');
70
+ await driveOneMcpCall();
71
+ await sleep(2_000);
72
+ const spans = collector.spans().filter(projected);
73
+ if (spans.length === 0) return softSkip('inapplicable', 'no span carrying an mcp.* attribute reached the suite collector — the host does not project onto the OTel MCP conventions (optional, RFC 0207 §D.12)');
74
+ for (const s of spans) {
75
+ const where = `span ${s.name} (${s.spanId})`;
76
+ expect(s.attributes.get('openwop.otel.mcp_mapping_version'), req(ID, DOC, `${where} carries mcp.* attributes and MUST carry openwop.otel.mcp_mapping_version: "0"`)).toBe('0');
77
+ const ref = s.attributes.get('openwop.otel.mcp_semconv_ref');
78
+ expect(typeof ref === 'string' && SEMCONV_REF.test(ref), req(ID, DOC, `${where} MUST carry openwop.otel.mcp_semconv_ref "open-telemetry/semantic-conventions-genai@<commit>" (got ${JSON.stringify(ref ?? null)})`)).toBe(true);
79
+ if (s.attributes.get('mcp.protocol.version') === '2026-07-28') {
80
+ expect(s.attributes.has('mcp.session.id'), req(ID, DOC, `${where} is a 2026-07-28 exchange and MUST NOT carry mcp.session.id (that revision is stateless)`)).toBe(false);
81
+ }
82
+ expect(s.attributes.has('gen_ai.tool.call.arguments') || s.attributes.has('gen_ai.tool.call.result'), req(ID, DOC, `${where} MUST NOT carry gen_ai.tool.call.arguments / .result under the projection (content)`)).toBe(false);
83
+ }
84
+ });
85
+ });
@@ -26,6 +26,11 @@
26
26
  * 5. BY-ID — `GET /v1/tools/{toolId}?view=compact` returns one schema-valid
27
27
  * CompactToolDescriptor.
28
28
  *
29
+ * MAJORS [1, 2] since suite 2.36.0 (RFC 0204 G3): `spec/v2/core/tool-catalog.md`
30
+ * §Views and sessions restates §compact; the gate and paths resolve the major
31
+ * (`toolCatalogGate(…, 'compactView')`, `toolsPath`) and the schema is the v2
32
+ * `compact-tool-descriptor.schema.json` at major 2.
33
+ *
29
34
  * Spec references:
30
35
  * - https://github.com/openwop/openwop/blob/main/spec/v1/tool-catalog.md (§compact)
31
36
  * - https://github.com/openwop/openwop/blob/main/RFCS/0112-compact-tool-projection.md
@@ -37,10 +42,12 @@ import { join } from 'node:path';
37
42
  import Ajv2020 from 'ajv/dist/2020.js';
38
43
  import addFormats from 'ajv-formats';
39
44
  import { driver } from '../lib/driver.js';
40
- import { behaviorGate } from '../lib/behavior-gate.js';
41
45
  import { SCHEMAS_DIR } from '../lib/paths.js';
46
+ import { targetMajor } from '../lib/seams.js';
47
+ import { v2Validator } from '../lib/v2.js';
42
48
  import {
43
- readToolCatalogCap,
49
+ toolCatalogGate,
50
+ toolsPath,
44
51
  listToolsCompact,
45
52
  COMPACT_DROPPED_FIELDS,
46
53
  findBannedInputSchemaKeyword,
@@ -53,6 +60,15 @@ function loadSchema(name: string): Record<string, unknown> {
53
60
  return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
54
61
  }
55
62
 
63
+ /** The CompactToolDescriptor validator for the major in play. */
64
+ function compactValidator(): (d: unknown) => { ok: boolean; errors: string } {
65
+ if (targetMajor() === 2) return v2Validator('compact-tool-descriptor');
66
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
67
+ addFormats(ajv);
68
+ const validate = ajv.compile(loadSchema('compact-tool-descriptor.schema.json'));
69
+ return (d: unknown) => ({ ok: validate(d) as boolean, errors: ajv.errorsText(validate.errors) });
70
+ }
71
+
56
72
  /** Extract the `toolId` set from a `GET /v1/tools` body, tolerating both the
57
73
  * bare-array and `{ tools: [] }` envelope standard shapes (cast-free). */
58
74
  function toolIdSet(body: unknown): Set<string> {
@@ -73,12 +89,8 @@ function toolIdSet(body: unknown): Set<string> {
73
89
 
74
90
  describe('tool-catalog-compact-projection (RFC 0112 §compact)', () => {
75
91
  it('serves the { tools: CompactToolDescriptor[] } projection — closed shape, bounded inputSchema, same toolId set as standard', async () => {
76
- const cap = await readToolCatalogCap();
77
- if (!behaviorGate('openwop-tool-catalog-compact', cap?.compactView === true)) return;
78
-
79
- const ajv = new Ajv2020({ strict: false, allErrors: true });
80
- addFormats(ajv);
81
- const validate = ajv.compile(loadSchema('compact-tool-descriptor.schema.json'));
92
+ if (!(await toolCatalogGate('openwop-tool-catalog-compact', 'compactView'))) return;
93
+ const validate = compactValidator();
82
94
 
83
95
  // ---- Leg 1: the compact envelope (§compact) -------------------------
84
96
  const compact = await listToolsCompact();
@@ -86,9 +98,10 @@ describe('tool-catalog-compact-projection (RFC 0112 §compact)', () => {
86
98
 
87
99
  for (const t of compact) {
88
100
  // ---- Leg 2: schema validity + heavy fields dropped ----------------
101
+ const v = validate(t);
89
102
  expect(
90
- validate(t),
91
- req('openwop.it.tool-catalog-compact-projection.serves-the-tools-compacttooldescriptor-projection-closed-shape-bounded-inputsche', 'compact-tool-descriptor.schema.json', `each CompactToolDescriptor MUST validate (${ajv.errorsText(validate.errors)})`),
103
+ v.ok,
104
+ req('openwop.it.tool-catalog-compact-projection.serves-the-tools-compacttooldescriptor-projection-closed-shape-bounded-inputsche', 'compact-tool-descriptor.schema.json', `each CompactToolDescriptor MUST validate (${v.errors})`),
92
105
  ).toBe(true);
93
106
  for (const f of COMPACT_DROPPED_FIELDS) {
94
107
  expect(
@@ -115,7 +128,7 @@ describe('tool-catalog-compact-projection (RFC 0112 §compact)', () => {
115
128
  }
116
129
 
117
130
  // ---- Leg 4: projection completeness vs the standard view -------------
118
- const standardRes = await driver.get('/v1/tools');
131
+ const standardRes = await driver.get(toolsPath());
119
132
  const standardIds = toolIdSet(standardRes.json);
120
133
  const compactIds = new Set<string>();
121
134
  for (const t of compact) {
@@ -134,11 +147,12 @@ describe('tool-catalog-compact-projection (RFC 0112 §compact)', () => {
134
147
  // ---- Leg 5: by-id compact round-trip --------------------------------
135
148
  if (compact.length > 0 && typeof compact[0]!.toolId === 'string') {
136
149
  const id = compact[0]!.toolId;
137
- const one = await driver.get(`/v1/tools/${encodeURIComponent(id)}?view=compact`);
150
+ const one = await driver.get(toolsPath(`/${encodeURIComponent(id)}?view=compact`));
138
151
  if (one.status === 200) {
152
+ const v1 = validate(one.json);
139
153
  expect(
140
- validate(one.json),
141
- req('openwop.it.tool-catalog-compact-projection.serves-the-tools-compacttooldescriptor-projection-closed-shape-bounded-inputsche', 'compact-tool-descriptor.schema.json', `GET /v1/tools/{toolId}?view=compact MUST return a valid CompactToolDescriptor (${ajv.errorsText(validate.errors)})`),
154
+ v1.ok,
155
+ req('openwop.it.tool-catalog-compact-projection.serves-the-tools-compacttooldescriptor-projection-closed-shape-bounded-inputsche', 'compact-tool-descriptor.schema.json', `GET /v1/tools/{toolId}?view=compact MUST return a valid CompactToolDescriptor (${v1.errors})`),
142
156
  ).toBe(true);
143
157
  const got = one.json;
144
158
  expect(
@@ -19,8 +19,20 @@
19
19
  * "not found" — the authorization-scoped projection never discloses another
20
20
  * principal's tools. Soft-skips when the env var is unset.
21
21
  *
22
+ * MAJORS [1, 2] since suite 2.36.0 (RFC 0204 G3). `spec/v2/core/tool-catalog.md`
23
+ * restates every v1 MUST this file checks, so the obligation holds at major 2
24
+ * unchanged; what held the file at [1] was the instrument — a `supported` gate
25
+ * (absent by construction at major 2) and hard-coded `/v1/tools` paths. Both now
26
+ * resolve the major (`toolCatalogGate`, `toolsPath`), and the descriptor is
27
+ * validated against `schemas/v2/tool-descriptor.schema.json` at major 2.
28
+ *
29
+ * Leg 5 (advisory, RFC 0204 §D.13 — a SHOULD, no certification row): two reads
30
+ * of the list are identical and sorted by `toolId`. Met ⇒ `executed-pass`; not
31
+ * met ⇒ recorded `inapplicable` with the reason, never failed.
32
+ *
22
33
  * Spec references:
23
34
  * - https://github.com/openwop/openwop/blob/main/spec/v1/tool-catalog.md (§B/§F)
35
+ * - https://github.com/openwop/openwop/blob/main/spec/v2/core/tool-catalog.md
24
36
  * - https://github.com/openwop/openwop/blob/main/RFCS/0078-portable-tool-catalog-and-tool-session-contract.md
25
37
  */
26
38
 
@@ -30,10 +42,12 @@ import { join } from 'node:path';
30
42
  import Ajv2020 from 'ajv/dist/2020.js';
31
43
  import addFormats from 'ajv-formats';
32
44
  import { driver } from '../lib/driver.js';
33
- import { behaviorGate } from '../lib/behavior-gate.js';
34
45
  import { SCHEMAS_DIR } from '../lib/paths.js';
46
+ import { targetMajor } from '../lib/seams.js';
47
+ import { v2Validator } from '../lib/v2.js';
35
48
  import {
36
- readToolCatalogCap,
49
+ toolCatalogGate,
50
+ toolsPath,
37
51
  listTools,
38
52
  getTool,
39
53
  TOOL_SOURCES,
@@ -47,6 +61,15 @@ function loadSchema(name: string): Record<string, unknown> {
47
61
  return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
48
62
  }
49
63
 
64
+ /** The ToolDescriptor validator for the major in play. */
65
+ function descriptorValidator(): (d: unknown) => { ok: boolean; errors: string } {
66
+ if (targetMajor() === 2) return v2Validator('tool-descriptor');
67
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
68
+ addFormats(ajv);
69
+ const validate = ajv.compile(loadSchema('tool-descriptor.schema.json'));
70
+ return (d: unknown) => ({ ok: validate(d) as boolean, errors: ajv.errorsText(validate.errors) });
71
+ }
72
+
50
73
  function expectContentFree(d: Record<string, unknown>, where: string): void {
51
74
  for (const f of TOOL_CONTENT_FORBIDDEN) {
52
75
  expect(
@@ -58,18 +81,14 @@ function expectContentFree(d: Record<string, unknown>, where: string): void {
58
81
 
59
82
  describe('tool-catalog-projection (RFC 0078 §B/§F)', () => {
60
83
  it('lists schema-valid ToolDescriptors, serves by-id + 404s, is auth-gated, and never discloses another principal', async () => {
61
- const cap = await readToolCatalogCap();
62
- if (!behaviorGate('openwop-tool-catalog', cap?.supported === true)) return;
63
-
64
- const ajv = new Ajv2020({ strict: false, allErrors: true });
65
- addFormats(ajv);
66
- const validate = ajv.compile(loadSchema('tool-descriptor.schema.json'));
84
+ if (!(await toolCatalogGate('openwop-tool-catalog'))) return;
85
+ const validate = descriptorValidator();
67
86
 
68
87
  // ---- Leg 3: auth-gated (unauthenticated list MUST be 401) -------------
69
- const unauth = await driver.get('/v1/tools', { authenticated: false });
88
+ const unauth = await driver.get(toolsPath(), { authenticated: false });
70
89
  expect(
71
90
  unauth.status === 401,
72
- req('openwop.it.tool-catalog-projection.lists-schema-valid-tooldescriptors-serves-by-id-404s-is-auth-gated-and-never-dis', 'tool-catalog.md §B', 'GET /v1/tools MUST require authentication (401 unauthenticated)'),
91
+ req('openwop.it.tool-catalog-projection.lists-schema-valid-tooldescriptors-serves-by-id-404s-is-auth-gated-and-never-dis', 'tool-catalog.md §B', `GET ${toolsPath()} MUST require authentication (401 unauthenticated)`),
73
92
  ).toBe(true);
74
93
 
75
94
  // ---- Leg 1: the list (§B) -------------------------------------------
@@ -77,9 +96,10 @@ describe('tool-catalog-projection (RFC 0078 §B/§F)', () => {
77
96
  if (tools === null) return softSkip('inapplicable', 'capability or profile not advertised by this host — gate `tools === null` returned early (host advertises the cap but doesn\'t serve the read — soft-skip the rest)'); // host advertises the cap but doesn't serve the read — soft-skip the rest
78
97
 
79
98
  for (const t of tools) {
99
+ const v = validate(t);
80
100
  expect(
81
- validate(t),
82
- req('openwop.it.tool-catalog-projection.lists-schema-valid-tooldescriptors-serves-by-id-404s-is-auth-gated-and-never-dis', 'tool-descriptor.schema.json', `each ToolDescriptor MUST validate (${ajv.errorsText(validate.errors)})`),
101
+ v.ok,
102
+ req('openwop.it.tool-catalog-projection.lists-schema-valid-tooldescriptors-serves-by-id-404s-is-auth-gated-and-never-dis', 'tool-descriptor.schema.json', `each ToolDescriptor MUST validate (${v.errors})`),
83
103
  ).toBe(true);
84
104
  expect(
85
105
  typeof t.source === 'string' && TOOL_SOURCES.includes(t.source as string),
@@ -119,4 +139,20 @@ describe('tool-catalog-projection (RFC 0078 §B/§F)', () => {
119
139
  ).toBe(true);
120
140
  }
121
141
  });
142
+
143
+ it('advisory (SHOULD): an unchanged catalog reads identically, sorted by toolId', async () => {
144
+ if (!(await toolCatalogGate('openwop-tool-catalog'))) return;
145
+ const a = await listTools();
146
+ const b = await listTools();
147
+ if (a === null || b === null) return softSkip('inapplicable', 'the host advertises the catalog but does not serve the list read');
148
+ const ids = (l: ReadonlyArray<{ toolId?: unknown }>) => l.map((t) => String(t.toolId));
149
+ const first = ids(a); const second = ids(b);
150
+ const sorted = [...first].sort((x, y) => (x < y ? -1 : x > y ? 1 : 0));
151
+ const stable = JSON.stringify(first) === JSON.stringify(second);
152
+ const ordered = JSON.stringify(first) === JSON.stringify(sorted);
153
+ if (!stable || !ordered) {
154
+ return softSkip('inapplicable', `advisory SHOULD not met (RFC 0204 §D.13, tool-catalog.md §The catalog): ${stable ? '' : 'two reads returned different orders; '}${ordered ? '' : 'tools are not sorted by toolId'} — recorded, never failed`);
155
+ }
156
+ expect(stable && ordered, req('openwop.it.tool-catalog-projection.advisory-should-an-unchanged-catalog-reads-identically-sorted-by-toolid', 'spec/v2/core/tool-catalog.md §The catalog (SHOULD)', 'two reads are identical and sorted by toolId')).toBe(true);
157
+ });
122
158
  });
@@ -23,6 +23,12 @@
23
23
  * per RFC 0078 §Conformance — reference host deferred). This scenario asserts the
24
24
  * wire contract, not host behavior.
25
25
  *
26
+ * MAJORS [1, 2] since suite 2.36.0 (RFC 0204 G3). At major 2 every leg reads the
27
+ * v2 schema of the same name (`schemas/v2/…`), the capability leg reads the v2
28
+ * `toolCatalog` record's facets (no `supported` field exists at major 2 — the
29
+ * record is the claim) and the event-name leg reads the v2 spellings
30
+ * (`tool.session-opened` / `tool.session-closed`, `spec/v2/event-codemap.json`).
31
+ *
26
32
  * Spec references:
27
33
  * - https://github.com/openwop/openwop/blob/main/spec/v1/tool-catalog.md
28
34
  * - https://github.com/openwop/openwop/blob/main/RFCS/0078-portable-tool-catalog-and-tool-session-contract.md
@@ -36,14 +42,24 @@ import Ajv2020 from 'ajv/dist/2020.js';
36
42
  import addFormats from 'ajv-formats';
37
43
  import { SCHEMAS_DIR } from '../lib/paths.js';
38
44
  import { req } from '../lib/requirement-ids.js';
45
+ import { targetMajor } from '../lib/seams.js';
46
+ import { v2Validator, v2RefValidator } from '../lib/v2.js';
47
+
48
+ const V2 = targetMajor() === 2;
39
49
 
40
50
  function loadSchema(name: string): Record<string, unknown> {
41
- return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
51
+ return JSON.parse(readFileSync(V2 ? join(SCHEMAS_DIR, 'v2', name) : join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
52
+ }
53
+
54
+ /** A boolean validator for a whole schema file, for the major in play. */
55
+ function fileValidator(name: string): (d: unknown) => boolean {
56
+ if (V2) { const v = v2Validator(name); return (d) => v(d).ok; }
57
+ const v = addFormats(new Ajv2020({ strict: false })).compile(loadSchema(name));
58
+ return (d) => v(d) as boolean;
42
59
  }
43
60
 
44
61
  describe('tool-descriptor-shape: ToolDescriptor (RFC 0078 §C, server-free)', () => {
45
- const ajv = addFormats(new Ajv2020({ strict: false }));
46
- const validate = ajv.compile(loadSchema('tool-descriptor.schema.json'));
62
+ const validate = fileValidator('tool-descriptor.schema.json');
47
63
 
48
64
  it('a conforming descriptor validates', () => {
49
65
  expect(
@@ -91,7 +107,7 @@ describe('tool-descriptor-shape: capability advertisement (RFC 0078 §A, server-
91
107
  toolCatalog,
92
108
  req('openwop.it.tool-descriptor-shape.capabilities-toolcatalog-is-declared-with-its-sub-flags', 'capabilities.md §toolCatalog', 'capabilities.toolCatalog MUST be declared'),
93
109
  ).toBeDefined();
94
- for (const flag of ['supported', 'sources', 'sessionLifecycle']) {
110
+ for (const flag of V2 ? ['sources', 'sessionLifecycle', 'compactView'] : ['supported', 'sources', 'sessionLifecycle']) {
95
111
  expect(
96
112
  toolCatalog?.properties?.[flag],
97
113
  req('openwop.it.tool-descriptor-shape.capabilities-toolcatalog-is-declared-with-its-sub-flags', 'tool-catalog.md §A', `capabilities.toolCatalog.${flag} MUST be declared`),
@@ -101,13 +117,16 @@ describe('tool-descriptor-shape: capability advertisement (RFC 0078 §A, server-
101
117
  });
102
118
 
103
119
  describe('tool-descriptor-shape: session lifecycle events (RFC 0078 §D, server-free)', () => {
104
- const payloads = loadSchema('run-event-payloads.schema.json');
105
- const ajv = addFormats(new Ajv2020({ strict: false }));
106
- const compile = (defName: string) => ajv.compile({
107
- $schema: 'https://json-schema.org/draft/2020-12/schema',
108
- $defs: (payloads as { $defs: Record<string, unknown> }).$defs,
109
- $ref: `#/$defs/${defName}`,
110
- } as Record<string, unknown>);
120
+ const compile = (defName: string): ((d: unknown) => boolean) => {
121
+ if (V2) { const v = v2RefValidator(`run-event-payloads.schema.json#/$defs/${defName}`); return (d) => v(d).ok; }
122
+ const payloads = loadSchema('run-event-payloads.schema.json');
123
+ const v = addFormats(new Ajv2020({ strict: false })).compile({
124
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
125
+ $defs: (payloads as { $defs: Record<string, unknown> }).$defs,
126
+ $ref: `#/$defs/${defName}`,
127
+ } as Record<string, unknown>);
128
+ return (d) => v(d) as boolean;
129
+ };
111
130
 
112
131
  it('tool.session.opened validates a content-free record', () => {
113
132
  const v = compile('toolSessionOpened');
@@ -124,8 +143,10 @@ describe('tool-descriptor-shape: session lifecycle events (RFC 0078 §D, server-
124
143
 
125
144
  it('both session event names appear in the RunEventType enum', () => {
126
145
  const runEvent = loadSchema('run-event.schema.json');
127
- const enumVals = ((runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum) ?? [];
128
- for (const name of ['tool.session.opened', 'tool.session.closed']) {
146
+ const enumVals = V2
147
+ ? (((runEvent.properties as { type?: { oneOf?: Array<{ enum?: string[] }> } }).type?.oneOf ?? []).flatMap((b) => b.enum ?? []))
148
+ : (((runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum) ?? []);
149
+ for (const name of V2 ? ['tool.session-opened', 'tool.session-closed'] : ['tool.session.opened', 'tool.session.closed']) {
129
150
  expect(enumVals.includes(name), req('openwop.it.tool-descriptor-shape.both-session-event-names-appear-in-the-runeventtype-enum', 'run-event.schema.json', `${name} MUST be in the RunEventType enum`)).toBe(true);
130
151
  }
131
152
  });