@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.
- package/CHANGELOG.md +27 -0
- package/README.md +3 -3
- package/coverage.md +13 -1
- package/dist/lib/certification-bundle-v3.js +21 -4
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/a2ui-v09/negative-event-name-deleteall.json +139 -0
- package/fixtures/a2ui-v09/negative-extra-property.json +140 -0
- package/fixtures/a2ui-v09/negative-foreign-catalog.json +139 -0
- package/fixtures/a2ui-v09/negative-formatstring-label.json +145 -0
- package/fixtures/a2ui-v09/negative-functioncall-openurl.json +129 -0
- package/fixtures/a2ui-v09/negative-image-component.json +145 -0
- package/fixtures/a2ui-v09/negative-textfield-obscured.json +140 -0
- package/fixtures/a2ui-v09/negative-theme-iconurl.json +142 -0
- package/fixtures/a2ui-v09/positive-approve-brief.json +139 -0
- package/fixtures/conformance-artifact-emit.json +28 -0
- package/fixtures/conformance-clarification-nested.json +53 -0
- package/fixtures/conformance-clarification-sensitive.json +49 -0
- package/fixtures/conformance-credential.json +25 -0
- package/fixtures/conformance-mcp-client.json +28 -0
- package/fixtures/interrupt-payloads/interrupt-payload-approval.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-clarification.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-conversation-close.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-conversation-exchange.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-conversation-start.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-custom.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-external-event.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-low-confidence.json +1 -0
- package/fixtures/interrupt-payloads/negative-approval-with-clarification-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-conversation-close-with-conversation-start-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-conversation-start-with-conversation-exchange-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-custom-with-conversation-start-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-low-confidence-with-custom-data.json +1 -0
- package/fixtures/node-pack-runtime/negative-entry-mismatch.json +31 -0
- package/fixtures/node-pack-runtime/negative-headers.json +37 -0
- package/fixtures/node-pack-runtime/negative-http.json +31 -0
- package/fixtures/node-pack-runtime/negative-icons.json +37 -0
- package/fixtures/node-pack-runtime/negative-meta.json +36 -0
- package/fixtures/node-pack-runtime/negative-non-remote-language.json +31 -0
- package/fixtures/node-pack-runtime/negative-packages.json +41 -0
- package/fixtures/node-pack-runtime/negative-sse.json +31 -0
- package/fixtures/node-pack-runtime/negative-templated-url.json +31 -0
- package/fixtures/node-pack-runtime/negative-two-remotes.json +35 -0
- package/fixtures/node-pack-runtime/negative-variables.json +38 -0
- package/fixtures/node-pack-runtime/negative-version-range.json +31 -0
- package/fixtures/node-pack-runtime/positive-every-optional-member.json +40 -0
- package/fixtures/node-pack-runtime/positive-rfc-example.json +31 -0
- package/fixtures/node-pack-runtime/upstream/mcp-registry-server-2025-12-11.schema.json +574 -0
- package/fixtures/oauth-providers/synthetic.json +36 -4
- package/fixtures/upstream/a2a-v1.0.1/README.md +24 -0
- package/fixtures/upstream/a2a-v1.0.1/a2a.proto +811 -0
- package/fixtures/upstream/a2ui-v0.9/README.md +33 -0
- package/fixtures/upstream/a2ui-v0.9/catalog.json +1383 -0
- package/fixtures/upstream/a2ui-v0.9/common_types.json +305 -0
- package/fixtures/upstream/a2ui-v0.9/server_to_client.json +132 -0
- package/fixtures.md +139 -0
- package/package.json +2 -2
- package/requirements.json +4480 -97
- package/scenario-majors.json +93 -6
- package/schemas/CORPUS-STAMP.json +103 -89
- package/src/lib/behavior-gate.ts +7 -1
- package/src/lib/certification-bundle-v3.ts +18 -4
- package/src/lib/localized-content.ts +53 -0
- package/src/lib/mcp-fake-server.ts +105 -4
- package/src/lib/node-pack-runtime.ts +41 -0
- package/src/lib/oauth-as-double.ts +316 -0
- package/src/lib/protected-resource.ts +88 -0
- package/src/lib/standard-webhooks.ts +120 -0
- package/src/lib/toolCatalog.ts +54 -4
- package/src/lib/trace-context.ts +98 -0
- package/src/lib/v2.ts +6 -0
- package/src/lib/webhook-receiver.ts +201 -1
- package/src/scenarios/artifact-type-pack-manifest-validation.test.ts +10 -1
- package/src/scenarios/auth-challenge-no-oracle.test.ts +122 -0
- package/src/scenarios/auth-oauth2-client-credentials.test.ts +46 -5
- package/src/scenarios/cross-host-traceparent-propagation.test.ts +92 -58
- package/src/scenarios/debugBundle.test.ts +41 -0
- package/src/scenarios/fixtures-valid.test.ts +72 -0
- package/src/scenarios/inbound-credential-no-passthrough.test.ts +115 -0
- package/src/scenarios/interrupt-approver-routing.test.ts +3 -0
- package/src/scenarios/localized-content-delivery.test.ts +26 -15
- package/src/scenarios/mcp-current-auth-boundary.test.ts +38 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +29 -0
- package/src/scenarios/otel-mcp-semconv-projection.test.ts +85 -0
- package/src/scenarios/tool-catalog-compact-projection.test.ts +28 -14
- package/src/scenarios/tool-catalog-projection.test.ts +48 -12
- package/src/scenarios/tool-descriptor-shape.test.ts +34 -13
- package/src/scenarios/v2-a2a-agent-cards.test.ts +305 -0
- package/src/scenarios/v2-a2a-operation-map.test.ts +310 -0
- package/src/scenarios/v2-a2ui-v09-surface.test.ts +283 -0
- package/src/scenarios/v2-advertised-fixtures-exist.test.ts +2 -2
- package/src/scenarios/v2-artifact-a2a-shape.test.ts +122 -0
- package/src/scenarios/v2-auth-challenge.test.ts +119 -0
- package/src/scenarios/v2-bound-id-kinds.test.ts +1 -1
- package/src/scenarios/v2-callback-url-guarded.test.ts +2 -2
- package/src/scenarios/v2-capability-maturity-bounded.test.ts +108 -0
- package/src/scenarios/v2-configurable-closed.test.ts +1 -1
- package/src/scenarios/v2-content-locale-keys.test.ts +92 -0
- package/src/scenarios/v2-conversation-turn-parts.test.ts +74 -0
- package/src/scenarios/v2-credential-interrupt.test.ts +253 -0
- package/src/scenarios/v2-effect-identity-business-key.test.ts +2 -2
- package/src/scenarios/v2-fork-a-v1-run.test.ts +1 -1
- package/src/scenarios/v2-implementation-informational.test.ts +33 -0
- package/src/scenarios/v2-interop-trace-context.test.ts +351 -0
- package/src/scenarios/v2-lane-exp-only-bound.test.ts +236 -0
- package/src/scenarios/v2-lane-issuer-advertised.test.ts +52 -3
- package/src/scenarios/v2-mcp-client-results.test.ts +202 -0
- package/src/scenarios/v2-mcp-mount-map.test.ts +308 -0
- package/src/scenarios/v2-mcp-tasks.test.ts +503 -0
- package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +203 -0
- package/src/scenarios/v2-oauth-mcp-reach-discovery.test.ts +186 -0
- package/src/scenarios/v2-oidc-id-token-audience.test.ts +108 -0
- package/src/scenarios/v2-protected-resource-metadata.test.ts +145 -0
- package/src/scenarios/v2-run-cancel.test.ts +1 -1
- package/src/scenarios/v2-run-fork-prefix.test.ts +4 -4
- package/src/scenarios/v2-run-pause-resume.test.ts +1 -1
- package/src/scenarios/v2-tool-catalog-annotations.test.ts +100 -0
- package/src/scenarios/v2-unmapped-type-refused.test.ts +1 -1
- package/src/scenarios/v2-v1-events-translated.test.ts +1 -1
- package/src/scenarios/v2-webhook-egress-refusal.test.ts +5 -5
- package/src/scenarios/v2-webhook-endpoint-verification.test.ts +124 -0
- package/src/scenarios/v2-webhook-message-id-stable.test.ts +124 -0
- package/src/scenarios/v2-webhook-secret-rotation.test.ts +132 -0
- 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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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 (${
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 (${
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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(
|
|
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',
|
|
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
|
-
|
|
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 (${
|
|
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
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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 =
|
|
128
|
-
|
|
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
|
});
|