@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
@@ -5,8 +5,25 @@
5
5
  * Witness class: witnessable — unaided. Every member of `auth.lanes[]` MUST
6
6
  * name `lane`, `issuers[]` (min 1, the realm), `revocation` and
7
7
  * `minimumAssurance`; `revocationWindowSeconds` MUST accompany a rule that
8
- * names a window (`exp-and-recheck`, `short-lived`, `rebind`). A host that does
8
+ * names a window (`exp-and-recheck`, `exp-only`, `short-lived`, `rebind`). A host that does
9
9
  * not advertise the `auth` family records `inapplicable`.
10
+ *
11
+ * Third leg (suite 2.36.0): a lane advertises only a revocation rule that
12
+ * `identity.md` §2.2's row FOR THAT LANE lists. §2.2 has stated the per-lane
13
+ * rule since RFC 0170 §B.3, and until now nothing measured it: the facet schema
14
+ * relates `revocation` to `lane` nowhere, and the two legs above check only
15
+ * enum membership and window presence. So any of the nine values passed on any
16
+ * of the ten lanes, which is how `short-lived` — a rule §2.2 defines only for
17
+ * `mtls`, as an obligation on the CREDENTIAL ISSUER — came to be advertised on
18
+ * an `oidc` lane by a host that performs no revocation re-check at all.
19
+ *
20
+ * `LANE_RULES` below is the §2.2 table, transcribed. It is not a second
21
+ * declaration: `scripts/check-lane-revocation-rules.mjs` parses the table out
22
+ * of `spec/v2/core/identity.md` and fails when this map, the table and the
23
+ * facet schema's `revocation` enum disagree. `anonymous` is `null` — its §2.2
24
+ * row's revocation cell is `—` while the facet schema REQUIRES the field, so
25
+ * the table names no legal value for it; the leg exempts that one lane and
26
+ * says so in its message rather than inventing a rule (RFC 0210 register G1).
10
27
  */
11
28
 
12
29
  import { describe, it, expect } from 'vitest';
@@ -16,11 +33,27 @@ import { req } from '../lib/requirement-ids.js';
16
33
 
17
34
  const DOC = 'spec/v2/core/identity.md §2.1';
18
35
  const LANES = new Set(['api-key', 'oauth2', 'oidc', 'mtls', 'saml', 'scim', 'ldap', 'workload', 'session', 'anonymous']);
19
- const REVOCATION = new Set(['next-request', 'exp-and-recheck', 'crl', 'ocsp', 'short-lived', 'not-on-or-after', 'bound-connection', 'rebind', 'delegation-expiry']);
20
- const WINDOWED = new Set(['exp-and-recheck', 'short-lived', 'rebind']);
36
+ const REVOCATION = new Set(['next-request', 'exp-and-recheck', 'exp-only', 'crl', 'ocsp', 'short-lived', 'not-on-or-after', 'bound-connection', 'rebind', 'delegation-expiry']);
37
+ const WINDOWED = new Set(['exp-and-recheck', 'exp-only', 'short-lived', 'rebind']);
21
38
  const ASSURANCE = new Set(['bearer', 'sender-constrained', 'key-bound']);
22
39
  const PROOFS = new Set(['mtls-key-binding', 'dpop', 'svid-chain']);
23
40
 
41
+ // LANE_RULES — `spec/v2/core/identity.md` §2.2, `revocation` column, one entry
42
+ // per lane. `null` means the table names no rule for that lane. Parsed by
43
+ // scripts/check-lane-revocation-rules.mjs; keep the literal shape.
44
+ const LANE_RULES: Record<string, string[] | null> = {
45
+ 'api-key': ['next-request'],
46
+ 'oauth2': ['exp-and-recheck', 'exp-only'],
47
+ 'oidc': ['exp-and-recheck', 'exp-only'],
48
+ 'mtls': ['crl', 'ocsp', 'short-lived'],
49
+ 'saml': ['not-on-or-after'],
50
+ 'scim': ['bound-connection'],
51
+ 'ldap': ['rebind'],
52
+ 'workload': ['delegation-expiry'],
53
+ 'session': ['next-request'],
54
+ 'anonymous': null,
55
+ };
56
+
24
57
  async function lanes(): Promise<Array<Record<string, unknown>> | { reason: string }> {
25
58
  let doc: Record<string, unknown> | null;
26
59
  try { doc = await v2Discovery(); } catch { doc = null; }
@@ -59,4 +92,20 @@ describe('v2 lane-issuer-advertised (RFC 0170 §B.2–§B.4)', () => {
59
92
  expect(Number.isInteger(w) && (w as number) >= 1, req('openwop.requirement.0170.lane-issuer-advertised.window', 'spec/v2/core/identity.md §2.2', `lane ${String(l['lane'])} (revocation ${String(l['revocation'])}) MUST advertise revocationWindowSeconds (integer ≥ 1)`)).toBe(true);
60
93
  }
61
94
  });
95
+
96
+ it('a lane advertises only a revocation rule its identity.md §2.2 row lists', async () => {
97
+ const ls = await lanes();
98
+ if (!Array.isArray(ls)) return softSkip(ls.reason.startsWith('FAIL-SHAPED') ? 'blocked' : 'inapplicable', ls.reason);
99
+ // Only lanes the table constrains are measured. `anonymous` is exempt
100
+ // because §2.2 names no rule for it at all (RFC 0210 register G1), not
101
+ // because any value is acceptable there.
102
+ const bound = ls.filter((l) => LANE_RULES[String(l['lane'])] != null);
103
+ if (bound.length === 0) return softSkip('inapplicable', `no advertised lane has a revocation rule stated in identity.md §2.2 (advertised: ${ls.map((l) => String(l['lane'])).join(', ') || 'none'}; the anonymous lane is exempt, its §2.2 revocation cell is "—")`);
104
+ for (const l of bound) {
105
+ const name = String(l['lane']);
106
+ const rule = String(l['revocation']);
107
+ const allowed = LANE_RULES[name] as string[];
108
+ expect(allowed.includes(rule), req('openwop.requirement.0170.lane-issuer-advertised.lane-rule', 'spec/v2/core/identity.md §2.2', `lane ${name} advertises revocation "${rule}", which §2.2's row for that lane does not list (it lists ${allowed.map((r) => `"${r}"`).join(' | ')}) — a host MUST NOT advertise a revocation rule its lane's row does not name`)).toBe(true);
109
+ }
110
+ });
62
111
  });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * RFC 0204 §A — the v2 host MCP client returns MCP results (suite 2.36.0,
3
+ * target major 2; gated on `mcp.client` + the `conformance-mcp-client` fixture).
4
+ *
5
+ * `spec/v2/core/host-services.md` §`mcp`: a host advertising `mcp.client`
6
+ * exposes `ctx.mcp.callTool` / `listTools` / `readResource` / `serverHealth`
7
+ * to pack code, and each resolves to the MCP result UNALTERED. The fixture's
8
+ * node calls the host's own `ctx.mcp.<method>` with the run's inputs and
9
+ * records the resolved value as `node.completed.outputs.result` (the v2
10
+ * RunSnapshot carries no run outputs, so the event log is the observation
11
+ * path — `GET /runs/{runId}/events/poll`).
12
+ *
13
+ * The expected value is never read from the host. Every leg compares the host's
14
+ * output with the JSON-RPC result the suite's own fake MCP server recorded
15
+ * sending (`McpFakeServer.exchanges()`), so a host that re-wraps, drops `_meta`,
16
+ * merges pages or answers from a stub cannot reproduce it.
17
+ *
18
+ * call-tool-verbatim structured-echo with a per-run nonce → deep-equal
19
+ * to the server's CallToolResult, `_meta` included;
20
+ * call-tool-iserror-resolves always-error → the run completes and resolves
21
+ * `isError: true` with the server's `content[]`;
22
+ * list-tools-page run 1: one page, deep-equal, with `nextCursor`;
23
+ * run 2 with that cursor: the server saw the cursor,
24
+ * the page is deep-equal and has no `nextCursor`;
25
+ * server-health `conformance` → reachable + the DiscoverResult;
26
+ * `conformance.down` → unreachable; never
27
+ * `connected` / `disconnected`;
28
+ * reject-unknown-server an unconfigured `serverId` fails the node
29
+ * `not_found`.
30
+ *
31
+ * Sabotage (RFC 0204 §Conformance), each run once against the v2 reference
32
+ * host: resolve `{ result }`; strip `_meta`; throw on `isError`; merge pages;
33
+ * drop the cursor; report `connected`; report `reachable` always; resolve
34
+ * `undefined` for an unknown id — each turns its row red.
35
+ *
36
+ * @see spec/v2/core/host-services.md §mcp
37
+ * @see RFCS/0204-host-mcp-client-returns-mcp-results.md §A
38
+ * @see conformance/fixtures.md §"The `ctx.mcp` fixture (RFC 0204)"
39
+ */
40
+
41
+ import { describe, it, expect } from 'vitest';
42
+ import { randomUUID } from 'node:crypto';
43
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
44
+ import { v2Discovery, familyAdvertised } from '../lib/v2.js';
45
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
46
+ import { getMcpFakeServer, ALWAYS_ERROR_CONTENT, TOOLS_PAGE_SIZE, type McpExchange, type McpFakeServer } from '../lib/mcp-fake-server.js';
47
+ import { readErrorCode } from '../lib/error-envelope.js';
48
+ import { softSkip } from '../lib/soft-skip.js';
49
+ import { req } from '../lib/requirement-ids.js';
50
+
51
+ export const REQUIRES_HOST_CALLBACK = "the host's ctx.mcp client calls the suite's fake MCP server (serverId \"conformance\")";
52
+
53
+ const FIXTURE = 'conformance-mcp-client';
54
+ const NODE_ID = 'mcp-client';
55
+ const DOC = 'spec/v2/core/host-services.md §mcp';
56
+ const ID = {
57
+ verbatim: 'openwop.requirement.0204.call-tool-verbatim',
58
+ isError: 'openwop.requirement.0204.call-tool-iserror-resolves',
59
+ page: 'openwop.requirement.0204.list-tools-page',
60
+ health: 'openwop.requirement.0204.server-health',
61
+ reject: 'openwop.requirement.0204.reject-unknown-server',
62
+ } as const;
63
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
64
+ const SESSION_WORDS = /"(dis)?connected"/i;
65
+
66
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
67
+ const enc = (id: string): string => encodeURIComponent(id);
68
+
69
+ /** The gate: the fake server, or the recorded reason the leg cannot run. */
70
+ type Gate = { fake: McpFakeServer } | { skip: ['inapplicable' | 'blocked', string] };
71
+ async function gate(): Promise<Gate> {
72
+ const doc = await v2Discovery().catch(() => null);
73
+ if (!doc) return { skip: ['blocked', 'v2 discovery unreachable'] };
74
+ const facet = await familyAdvertised('mcp');
75
+ if (!facet || facet['client'] !== true) return { skip: ['inapplicable', 'mcp.client not advertised — the host exposes no ctx.mcp to pack code (RFC 0204 §A.1)'] };
76
+ if (!isFixtureAdvertised(FIXTURE)) return { skip: ['blocked', `the host advertises mcp.client but not the ${FIXTURE} fixture — ctx.mcp is claimed and cannot be observed`] };
77
+ const fake = getMcpFakeServer();
78
+ if (fake === null) return { skip: ['blocked', 'the suite MCP fake server (OPENWOP_MCP_FAKE_SERVER=true) is not started in this run'] };
79
+ return { fake };
80
+ }
81
+
82
+ interface Outcome {
83
+ readonly status: string;
84
+ readonly result: unknown;
85
+ readonly hasResult: boolean;
86
+ readonly errorCode: string | null;
87
+ }
88
+
89
+ /** Run the fixture with `inputs` and read the node's terminal event. */
90
+ async function runFixture(inputs: Record<string, unknown>): Promise<Outcome | { reason: string }> {
91
+ const created = await http(() => driver.post('/runs', { workflowId: FIXTURE, inputs }));
92
+ if (created === null) return { reason: 'POST /runs unreachable (fetch failed)' };
93
+ const runId = (created.json as { runId?: unknown } | null)?.runId;
94
+ if (created.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs answered ${created.status} ${readErrorCode(created.json) ?? ''} — the fixture run was refused`.trim() };
95
+ const t0 = Date.now(); let status = '';
96
+ while (Date.now() - t0 < 30_000) {
97
+ const snap = await http(() => driver.get(`/runs/${enc(runId)}`));
98
+ status = String((snap?.json as { status?: unknown } | null)?.status ?? '');
99
+ if (TERMINAL.has(status)) break;
100
+ await new Promise((r) => setTimeout(r, 200));
101
+ }
102
+ if (!TERMINAL.has(status)) return { reason: `the ${FIXTURE} run did not reach a terminal status within 30 s (last: ${status || 'unreadable'})` };
103
+ const ev = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`));
104
+ const events = (ev?.json as { events?: unknown } | null)?.events;
105
+ if (ev?.status !== 200 || !Array.isArray(events)) return { reason: `GET /runs/{runId}/events/poll answered ${ev?.status ?? 'nothing'}` };
106
+ const node = (type: string) => (events as Array<{ type?: unknown; payload?: Record<string, unknown> }>).find((e) => e.type === type && e.payload?.['nodeId'] === NODE_ID)?.payload;
107
+ const completed = node('node.completed');
108
+ const failed = node('node.failed');
109
+ const outputs = (completed?.['outputs'] ?? {}) as Record<string, unknown>;
110
+ const error = (failed?.['error'] ?? null) as { code?: unknown } | null;
111
+ return { status, result: outputs['result'], hasResult: 'result' in outputs, errorCode: typeof error?.code === 'string' ? error.code : null };
112
+ }
113
+
114
+ function lastExchange(fake: McpFakeServer, pred: (x: McpExchange) => boolean): McpExchange | undefined {
115
+ return [...fake.exchanges()].reverse().find(pred);
116
+ }
117
+ const paramsOf = (x: McpExchange): Record<string, unknown> => (x.params ?? {}) as Record<string, unknown>;
118
+
119
+ describe('RFC 0204 §A — ctx.mcp returns MCP results (gated on mcp.client)', () => {
120
+ it('callTool resolves to the server CallToolResult unaltered, _meta included', async () => {
121
+ const g = await gate(); if ('skip' in g) return softSkip(...g.skip);
122
+ const fake = g.fake;
123
+ const nonce = randomUUID();
124
+ const out = await runFixture({ method: 'callTool', serverId: 'conformance', name: 'structured-echo', arguments: { nonce } });
125
+ if ('reason' in out) return softSkip('blocked', out.reason);
126
+ const sent = lastExchange(fake, (x) => x.method === 'tools/call' && paramsOf(x)['name'] === 'structured-echo' && (paramsOf(x)['arguments'] as { nonce?: unknown } | undefined)?.nonce === nonce);
127
+ if (!sent) return softSkip('blocked', 'the suite fake server received no structured-echo call carrying this run\'s nonce — the host\'s "conformance" binding does not reach this suite\'s server');
128
+ expect(out.status, req(ID.verbatim, DOC, `the run MUST complete (got ${out.status}, node error ${out.errorCode ?? 'none'})`)).toBe('completed');
129
+ expect(out.hasResult, req(ID.verbatim, 'fixtures.md §The ctx.mcp fixture', 'node.completed.outputs.result MUST carry the resolved value')).toBe(true);
130
+ expect(out.result, req(ID.verbatim, DOC, 'callTool MUST resolve to the server\'s CallToolResult unaltered — content[], structuredContent, isError and _meta, deep-equal to what the server sent')).toEqual(sent.result);
131
+ expect((out.result as { _meta?: Record<string, unknown> } | undefined)?._meta?.['dev.openwop.conformance/nonce'], req(ID.verbatim, DOC, 'the result\'s _meta MUST survive (the per-run nonce is in it)')).toBe(nonce);
132
+ });
133
+
134
+ it('callTool resolves (does not reject) a result with isError: true', async () => {
135
+ const g = await gate(); if ('skip' in g) return softSkip(...g.skip);
136
+ const fake = g.fake;
137
+ const out = await runFixture({ method: 'callTool', serverId: 'conformance', name: 'always-error', arguments: {} });
138
+ if ('reason' in out) return softSkip('blocked', out.reason);
139
+ const sent = lastExchange(fake, (x) => x.method === 'tools/call' && paramsOf(x)['name'] === 'always-error');
140
+ if (!sent) return softSkip('blocked', 'the suite fake server received no always-error call — the host\'s "conformance" binding does not reach this suite\'s server');
141
+ expect(out.status, req(ID.isError, DOC, `a tool error is a result, not a rejection: the run MUST complete (got ${out.status}, node error ${out.errorCode ?? 'none'})`)).toBe('completed');
142
+ const r = out.result as { isError?: unknown; content?: unknown } | undefined;
143
+ expect(r?.isError, req(ID.isError, DOC, 'the resolved CallToolResult MUST carry isError: true')).toBe(true);
144
+ expect(r?.content, req(ID.isError, DOC, 'the resolved CallToolResult MUST carry the server\'s content[]')).toEqual([...ALWAYS_ERROR_CONTENT]);
145
+ expect(out.result, req(ID.isError, DOC, 'the isError result MUST be the server\'s, unaltered')).toEqual(sent.result);
146
+ });
147
+
148
+ it('listTools resolves one ListToolsResult page unaltered and forwards the cursor', async () => {
149
+ const g = await gate(); if ('skip' in g) return softSkip(...g.skip);
150
+ const fake = g.fake;
151
+ const first = await runFixture({ method: 'listTools', serverId: 'conformance' });
152
+ if ('reason' in first) return softSkip('blocked', first.reason);
153
+ const sent1 = lastExchange(fake, (x) => x.method === 'tools/list' && paramsOf(x)['cursor'] === undefined);
154
+ if (!sent1) return softSkip('blocked', 'the suite fake server received no tools/list — the host\'s "conformance" binding does not reach this suite\'s server');
155
+ expect(first.status, req(ID.page, DOC, `the listTools run MUST complete (got ${first.status}, node error ${first.errorCode ?? 'none'})`)).toBe('completed');
156
+ const page1 = first.result as { tools?: Array<Record<string, unknown>>; nextCursor?: unknown } | undefined;
157
+ expect(page1, req(ID.page, DOC, 'listTools MUST resolve to ONE ListToolsResult page unaltered — tools with outputSchema and annotations, nextCursor, ttlMs, cacheScope')).toEqual(sent1.result);
158
+ expect(page1?.tools?.length, req(ID.page, DOC, `the page MUST NOT be merged with later pages (the server pages ${TOOLS_PAGE_SIZE} at a time)`)).toBe(TOOLS_PAGE_SIZE);
159
+ expect(page1?.tools?.some((t) => t['outputSchema'] !== undefined && t['annotations'] !== undefined), req(ID.page, DOC, 'outputSchema and annotations MUST survive')).toBe(true);
160
+ const cursor = page1?.nextCursor;
161
+ expect(typeof cursor, req(ID.page, DOC, 'the first page MUST carry the server\'s nextCursor')).toBe('string');
162
+ if (typeof cursor !== 'string') return softSkip('blocked', 'unreachable: the nextCursor assertion above failed');
163
+ const second = await runFixture({ method: 'listTools', serverId: 'conformance', cursor });
164
+ if ('reason' in second) return softSkip('blocked', second.reason);
165
+ const sent2 = lastExchange(fake, (x) => x.method === 'tools/list' && paramsOf(x)['cursor'] === cursor);
166
+ expect(sent2 !== undefined, req(ID.page, DOC, 'listTools MUST forward the pack\'s cursor to the server (no tools/list carried it)')).toBe(true);
167
+ expect(second.status, req(ID.page, DOC, `the cursor run MUST complete (got ${second.status}, node error ${second.errorCode ?? 'none'})`)).toBe('completed');
168
+ expect(second.result, req(ID.page, DOC, 'the cursor page MUST be the server\'s second page, unaltered')).toEqual(sent2?.result);
169
+ expect((second.result as { nextCursor?: unknown } | undefined)?.nextCursor, req(ID.page, DOC, 'the last page carries no nextCursor')).toBeUndefined();
170
+ });
171
+
172
+ it('serverHealth reports reachability from server/discover, never a session state', async () => {
173
+ const g = await gate(); if ('skip' in g) return softSkip(...g.skip);
174
+ const fake = g.fake;
175
+ const up = await runFixture({ method: 'serverHealth', serverId: 'conformance' });
176
+ if ('reason' in up) return softSkip('blocked', up.reason);
177
+ expect(up.status, req(ID.health, DOC, `the serverHealth run MUST complete (got ${up.status}, node error ${up.errorCode ?? 'none'})`)).toBe('completed');
178
+ const h = up.result as { state?: unknown; discover?: { supportedVersions?: unknown } } | undefined;
179
+ if (h?.state === 'unreachable' && !fake.exchanges().some((x) => x.method === 'server/discover')) {
180
+ return softSkip('blocked', 'serverHealth("conformance") is unreachable and the suite fake server received no server/discover — the host\'s "conformance" binding does not reach this suite\'s server');
181
+ }
182
+ expect(h?.state, req(ID.health, DOC, 'a server whose server/discover succeeded with a revision the host negotiates MUST be reachable')).toBe('reachable');
183
+ expect(h?.discover?.supportedVersions, req(ID.health, DOC, 'serverHealth MUST carry the DiscoverResult it received')).toEqual([...fake.protocolVersions()]);
184
+ const sentDiscover = lastExchange(fake, (x) => x.method === 'server/discover');
185
+ if (sentDiscover) expect(h?.discover, req(ID.health, DOC, 'the DiscoverResult MUST be the server\'s, unaltered')).toEqual(sentDiscover.result);
186
+ expect(SESSION_WORDS.test(JSON.stringify(up.result ?? null)), req(ID.health, DOC, 'serverHealth MUST NOT report a connection or session state (connected / disconnected)')).toBe(false);
187
+ const down = await runFixture({ method: 'serverHealth', serverId: 'conformance.down' });
188
+ if ('reason' in down) return softSkip('blocked', down.reason);
189
+ if (down.errorCode === 'not_found') return softSkip('blocked', 'the host has no "conformance.down" binding (fixtures.md operator contract) — the unreachable half cannot be observed');
190
+ expect(down.status, req(ID.health, DOC, `serverHealth on an unanswering server resolves, it does not reject (got ${down.status}, node error ${down.errorCode ?? 'none'})`)).toBe('completed');
191
+ expect((down.result as { state?: unknown } | undefined)?.state, req(ID.health, DOC, 'a server nothing answers MUST be unreachable')).toBe('unreachable');
192
+ expect(SESSION_WORDS.test(JSON.stringify(down.result ?? null)), req(ID.health, DOC, 'serverHealth MUST NOT report a connection or session state')).toBe(false);
193
+ });
194
+
195
+ it('an unknown serverId rejects with not_found', async () => {
196
+ const g = await gate(); if ('skip' in g) return softSkip(...g.skip);
197
+ const out = await runFixture({ method: 'callTool', serverId: `no-such-${randomUUID().slice(0, 8)}`, name: 'echo', arguments: { text: 'x' } });
198
+ if ('reason' in out) return softSkip('blocked', out.reason);
199
+ expect(out.status, req(ID.reject, DOC, `a call to an unconfigured serverId MUST reject, failing the node (got ${out.status})`)).toBe('failed');
200
+ expect(out.errorCode, req(ID.reject, DOC, 'the rejection MUST be not_found')).toBe('not_found');
201
+ });
202
+ });
@@ -0,0 +1,308 @@
1
+ /**
2
+ * RFC 0208 §A/§B — the host as an MCP 2026-07-28 server, held to the `mcp.*`
3
+ * rows of `spec/v2/interop-map.json` (`spec/v2/core/interop.md` §"The
4
+ * operation mappings"). Target major 2.
5
+ *
6
+ * Gate: the v2 `mcp` record carries `serverMount` and lists profile
7
+ * `mcp-2026-07-28`. The mount is `mcp.serverUrls[0]` (an absolute URL, or a
8
+ * path joined to the base URL); a host that advertises a mount with no URL is
9
+ * `blocked`, never a silent return. A host advertising `mcp` for its client
10
+ * path only is `inapplicable`.
11
+ *
12
+ * Tools (a suite requirement, `conformance/fixtures.md`, not a spec MUST): each
13
+ * advertised fixture the legs call — `conformance-noop`, `conformance-failure`,
14
+ * `conformance-approval` — is exposed as a tool under its workflowId.
15
+ *
16
+ * The v1 host-as-server legs (`mcp-2026-07-28-discover`, `mcp-stateless-request`,
17
+ * `mcp-mrtr-roundtrip` server half, `mcp-cache-tenant-scope`,
18
+ * `mcp-extension-opacity` server half, `mcp-current-auth-boundary`) are ported
19
+ * here as v2 twins: they cannot join `BOTH_MAJORS`, because they gate on v1
20
+ * `.supported` seats that do not exist at major 2.
21
+ *
22
+ * @see spec/v2/core/interop.md §"The operation mappings"
23
+ * @see spec/v2/interop-map.json mcp.features / methods / headers / meta / mrtr / cache / authorization
24
+ * @see RFCS/0208-v2-a2a-mcp-operation-mappings.md §A, §B
25
+ * @see RFCS/0199-outbound-oauth-client-and-credential-interrupt.md §D.2 (the URL-mode and form-mode legs; interop-map.json mcp.mrtr InputRequiredResult (host as server))
26
+ */
27
+ import { describe, it, expect } from 'vitest';
28
+ import { readFileSync } from 'node:fs';
29
+ import { join } from 'node:path';
30
+ import { driver } from '../lib/driver.js';
31
+ import { v2Discovery, familyAdvertised } from '../lib/v2.js';
32
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
33
+ import { loadEnv } from '../lib/env.js';
34
+ import { softSkip } from '../lib/soft-skip.js';
35
+ import { SCHEMAS_DIR } from '../lib/paths.js';
36
+ import { req } from '../lib/requirement-ids.js';
37
+ import { SEAMS_PREFIX } from '../lib/seams.js';
38
+
39
+ export const HOST_CALLBACK_NOT_REQUIRED = 'the suite is the MCP client: every leg POSTs JSON-RPC to the mount the host advertises in mcp.serverUrls; nothing harness-hosted is handed to the host';
40
+
41
+ const DOC = 'spec/v2/core/interop.md §"The operation mappings" (RFC 0208)';
42
+ const PROFILE = 'mcp-2026-07-28';
43
+ const REV = '2026-07-28';
44
+ const R = (slug: string): string => `openwop.requirement.0208.${slug}`;
45
+ const META_V = 'io.modelcontextprotocol/protocolVersion';
46
+ const META_C = 'io.modelcontextprotocol/clientCapabilities';
47
+ const ERR = { HEADER_MISMATCH: -32020, MISSING_CAPABILITY: -32021, UNSUPPORTED_VERSION: -32022, INVALID_PARAMS: -32602 } as const;
48
+
49
+ const MAP = JSON.parse(readFileSync(join(SCHEMAS_DIR, '..', 'spec', 'v2', 'interop-map.json'), 'utf8')) as { mcp: { features: Array<{ id: string; requiredFor: string[] }> } };
50
+ const REQUIRED_FEATURES = MAP.mcp.features.filter((f) => f.requiredFor.includes(PROFILE)).map((f) => f.id).sort();
51
+
52
+ interface RpcError { code: number; message?: string; data?: Record<string, unknown> }
53
+ interface Rpc { status: number; headers: Headers; result?: Record<string, unknown> | undefined; error?: RpcError | undefined }
54
+ type Mount = { ok: true; url: string; facet: Record<string, unknown> } | { ok: false; kind: 'inapplicable' | 'blocked'; reason: string };
55
+
56
+ async function mount(): Promise<Mount> {
57
+ if (!(await v2Discovery())) return { ok: false, kind: 'blocked', reason: 'v2 discovery unreachable' };
58
+ const facet = await familyAdvertised('mcp');
59
+ if (!facet) return { ok: false, kind: 'inapplicable', reason: 'mcp not advertised at major 2' };
60
+ const profiles = Array.isArray(facet['profiles']) ? (facet['profiles'] as unknown[]) : [];
61
+ if (facet['serverMount'] === undefined || !profiles.includes(PROFILE)) return { ok: false, kind: 'inapplicable', reason: 'mcp is advertised without serverMount + profile mcp-2026-07-28 — the host serves no MCP mount (client path only)' };
62
+ const urls = Array.isArray(facet['serverUrls']) ? (facet['serverUrls'] as unknown[]).filter((u): u is string => typeof u === 'string' && u.length > 0) : [];
63
+ if (urls.length === 0) return { ok: false, kind: 'blocked', reason: 'mcp.serverMount is advertised with no mcp.serverUrls[0] — the mount is unaddressable' };
64
+ const u = urls[0]!;
65
+ return { ok: true, url: /^https?:\/\//i.test(u) ? u : `${loadEnv().baseUrl}${u.startsWith('/') ? '' : '/'}${u}`, facet };
66
+ }
67
+ const skip = (m: Exclude<Mount, { ok: true }>): undefined => softSkip(m.kind, m.reason);
68
+
69
+ interface CallOpts { version?: string | null; bodyVersion?: string | null; mcpMethod?: string | null; mcpName?: string; caps?: Record<string, unknown>; bearer?: string | null; extraMeta?: Record<string, unknown>; headers?: Record<string, string> }
70
+ async function call(url: string, method: string, params: Record<string, unknown>, o: CallOpts = {}): Promise<Rpc> {
71
+ const version = o.version === undefined ? REV : o.version;
72
+ const bodyVersion = o.bodyVersion === undefined ? version : o.bodyVersion;
73
+ const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...(o.headers ?? {}) };
74
+ if (version !== null) headers['MCP-Protocol-Version'] = version;
75
+ const mcpMethod = o.mcpMethod === undefined ? method : o.mcpMethod;
76
+ if (mcpMethod !== null) headers['Mcp-Method'] = mcpMethod;
77
+ if (o.mcpName !== undefined) headers['Mcp-Name'] = o.mcpName;
78
+ const bearer = o.bearer === undefined ? loadEnv().apiKey : o.bearer;
79
+ if (bearer) headers['authorization'] = `Bearer ${bearer}`;
80
+ const meta: Record<string, unknown> = { [META_C]: o.caps ?? {}, ...(o.extraMeta ?? {}) };
81
+ if (bodyVersion !== null) meta[META_V] = bodyVersion;
82
+ const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: Math.floor(Math.random() * 1e9), method, params: { ...params, _meta: meta } }) });
83
+ const body = (await res.json().catch(() => ({}))) as { result?: Record<string, unknown>; error?: RpcError };
84
+ return { status: res.status, headers: res.headers, ...body };
85
+ }
86
+ const toolCall = (url: string, name: string, extra: Record<string, unknown> = {}, o: CallOpts = {}): Promise<Rpc> => call(url, 'tools/call', { name, arguments: {}, ...extra }, { mcpName: name, ...o });
87
+
88
+ function needFixtures(ids: string[]): string | null {
89
+ const missing = ids.filter((id) => !isFixtureAdvertised(id));
90
+ return missing.length === 0 ? null : `fixture(s) ${missing.join(', ')} not in the advertised fixtures[] — the leg calls them as tools`;
91
+ }
92
+
93
+ describe('RFC 0208 — v2-mcp-mount-map (host as MCP 2026-07-28 server, gated on mcp.serverMount + profile)', () => {
94
+ it('a host claiming mcp-2026-07-28 lists every feature the map requires for it', async () => {
95
+ const m = await mount();
96
+ if (!m.ok) return skip(m);
97
+ const features = Array.isArray(m.facet['features']) ? (m.facet['features'] as unknown[]).map(String) : [];
98
+ expect(REQUIRED_FEATURES.filter((f) => !features.includes(f)), req(R('mcp-features-required'), 'interop.md §"The operation mappings"; interop-map.json mcp.features requiredFor', `a host advertising ${PROFILE} MUST list every feature the map requires for it (${REQUIRED_FEATURES.join(', ')}); advertised [${features.join(', ')}]`)).toEqual([]);
99
+ });
100
+
101
+ it('server/discover supportedVersions equals mcp.revisions, with resultType and cache hints', async () => {
102
+ const m = await mount();
103
+ if (!m.ok) return skip(m);
104
+ const r = await call(m.url, 'server/discover', {});
105
+ expect(r.error, req(R('mcp-discover-revisions'), 'interop-map.json mcp.methods server/discover', `server/discover MUST be served (feature server-discover): ${r.status} ${JSON.stringify(r.error)}`)).toBeUndefined();
106
+ const revisions = (Array.isArray(m.facet['revisions']) ? (m.facet['revisions'] as string[]) : []).slice().sort();
107
+ expect([...((r.result?.['supportedVersions'] as string[] | undefined) ?? [])].sort(), req(R('mcp-discover-revisions'), 'interop-map.json mcp.methods server/discover', 'supportedVersions[] MUST equal mcp.revisions — two documents, one fact')).toEqual(revisions);
108
+ expect(r.result?.['resultType'], req(R('mcp-discover-revisions'), 'interop-map.json mcp.features cacheable-lists', 'a current-revision result carries resultType complete')).toBe('complete');
109
+ expect(typeof r.result?.['ttlMs'], req(R('mcp-discover-revisions'), 'interop-map.json mcp.features cacheable-lists', 'server/discover is cacheable: ttlMs')).toBe('number');
110
+ expect(['public', 'private'], req(R('mcp-discover-revisions'), 'interop-map.json mcp.cache cacheScope', 'cacheScope is public | private')).toContain(r.result?.['cacheScope']);
111
+ });
112
+
113
+ it('header/body disagreement fails closed -32020 before version selection; an unsupported revision is -32022', async () => {
114
+ const m = await mount();
115
+ if (!m.ok) return skip(m);
116
+ const id = R('mcp-header-body-consistent');
117
+ const mismatch = await call(m.url, 'tools/list', {}, { version: REV, bodyVersion: '2025-06-18' });
118
+ expect([mismatch.status, mismatch.error?.code], req(id, 'interop-map.json mcp.headers MCP-Protocol-Version', `MCP-Protocol-Version ≠ _meta protocolVersion MUST be 400 HeaderMismatchError -32020, checked before version selection (got ${mismatch.status} ${JSON.stringify(mismatch.error)})`)).toEqual([400, ERR.HEADER_MISMATCH]);
119
+ const method = await call(m.url, 'tools/list', {}, { mcpMethod: 'resources/list' });
120
+ expect([method.status, method.error?.code], req(id, 'interop-map.json mcp.headers Mcp-Method | Mcp-Name', `Mcp-Method ≠ method MUST fail closed 400 -32020 (got ${method.status} ${JSON.stringify(method.error)})`)).toEqual([400, ERR.HEADER_MISMATCH]);
121
+ const name = await call(m.url, 'tools/call', { name: 'conformance-noop', arguments: {} }, { mcpName: 'not-conformance-noop' });
122
+ expect([name.status, name.error?.code], req(id, 'interop-map.json mcp.headers Mcp-Method | Mcp-Name', `Mcp-Name ≠ params.name MUST fail closed 400 -32020 (got ${name.status} ${JSON.stringify(name.error)})`)).toEqual([400, ERR.HEADER_MISMATCH]);
123
+ const unsupported = await call(m.url, 'tools/list', {}, { version: '1999-01-01' });
124
+ expect([unsupported.status, unsupported.error?.code], req(id, 'interop-map.json mcp.methods server/discover', `a revision outside mcp.revisions MUST be refused 400 -32022 (got ${unsupported.status} ${JSON.stringify(unsupported.error)})`)).toEqual([400, ERR.UNSUPPORTED_VERSION]);
125
+ expect(Array.isArray(unsupported.error?.data?.['supported']), req(id, 'interop-map.json mcp.methods server/discover', 'the refusal lists data.supported[]')).toBe(true);
126
+ });
127
+
128
+ it('tools/list needs no initialize and no session; results carry cache hints and agree across requests', async () => {
129
+ const m = await mount();
130
+ if (!m.ok) return skip(m);
131
+ const id = R('mcp-stateless');
132
+ const a = await call(m.url, 'tools/list', {});
133
+ expect(a.error, req(id, 'interop-map.json mcp.methods initialize | Mcp-Session-Id', `a current-profile request MUST NOT require initialize or a session: ${a.status} ${JSON.stringify(a.error)}`)).toBeUndefined();
134
+ expect(a.status, req(id, 'interop-map.json mcp.methods tools/list', 'tools/list answers 200')).toBe(200);
135
+ expect(a.result?.['resultType'], req(id, 'interop-map.json mcp.features cacheable-lists', 'resultType complete')).toBe('complete');
136
+ expect(typeof a.result?.['ttlMs'] === 'number' && (a.result['ttlMs'] as number) >= 0, req(id, 'interop-map.json mcp.features cacheable-lists', 'tools/list MUST carry ttlMs >= 0')).toBe(true);
137
+ expect(['public', 'private'], req(id, 'interop-map.json mcp.cache cacheScope', 'tools/list MUST carry cacheScope')).toContain(a.result?.['cacheScope']);
138
+ const names = ((a.result?.['tools'] as Array<{ name?: string }> | undefined) ?? []).map((t) => String(t.name));
139
+ expect(names, req(id, 'interop-map.json mcp.methods tools/list', 'the tool list is sorted by name')).toEqual([...names].sort());
140
+ const b = await call(m.url, 'tools/list', {});
141
+ expect(JSON.stringify(b.result?.['tools']), req(id, 'interop-map.json mcp.methods tools/list', 'tools/list MUST be identical for every connection of one caller')).toBe(JSON.stringify(a.result?.['tools']));
142
+ });
143
+
144
+ it('tools/call answers a completed run isError false and a failed run isError true, never a JSON-RPC error', async () => {
145
+ const m = await mount();
146
+ if (!m.ok) return skip(m);
147
+ const missing = needFixtures(['conformance-noop', 'conformance-failure']);
148
+ if (missing) return softSkip('blocked', missing);
149
+ const ok = await toolCall(m.url, 'conformance-noop');
150
+ expect(ok.error, req(R('mcp-tool-outcome'), 'interop-map.json mcp.methods tools/call', `tools/call on a completing workflow MUST answer a result: ${JSON.stringify(ok.error)}`)).toBeUndefined();
151
+ expect([ok.result?.['resultType'], ok.result?.['isError']], req(R('mcp-tool-outcome'), 'interop-map.json mcp.methods tools/call', 'completed is CallToolResult isError false')).toEqual(['complete', false]);
152
+ const bad = await toolCall(m.url, 'conformance-failure');
153
+ expect(bad.error, req(R('mcp-tool-outcome'), 'interop-map.json mcp.methods tools/call', `a failed run MUST be a CallToolResult, not a JSON-RPC error (got ${JSON.stringify(bad.error)})`)).toBeUndefined();
154
+ expect(bad.result?.['isError'], req(R('mcp-tool-outcome'), 'interop-map.json mcp.methods tools/call', 'failed is CallToolResult isError true')).toBe(true);
155
+ const invalid = await call(m.url, 'tools/call', { name: 'conformance-noop', arguments: 'not-an-object' }, { mcpName: 'conformance-noop' });
156
+ expect(invalid.error?.code, req(R('mcp-tool-outcome'), 'interop-map.json mcp.methods tools/call', `arguments are validated against inputSchema before any run is created (else -32602); got ${JSON.stringify(invalid.error ?? invalid.result)}`)).toBe(ERR.INVALID_PARAMS);
157
+ });
158
+
159
+ it('a run started by tools/call records run.started.transport mcp', async () => {
160
+ const m = await mount();
161
+ if (!m.ok) return skip(m);
162
+ const missing = needFixtures(['conformance-noop']);
163
+ if (missing) return softSkip('blocked', missing);
164
+ if (!(await familyAdvertised('runList'))) return softSkip('inapplicable', 'runList not advertised — the suite has no black-box way to find the run a tools/call started');
165
+ const list = async (): Promise<string[]> => {
166
+ const r = await driver.get('/runs?workflowId=conformance-noop&limit=50');
167
+ return ((r.json as { runs?: Array<{ runId?: string }> } | undefined)?.runs ?? []).map((x) => String(x.runId));
168
+ };
169
+ const before = new Set(await list());
170
+ const ok = await toolCall(m.url, 'conformance-noop');
171
+ const fresh = (await list()).filter((x) => !before.has(x));
172
+ expect(fresh.length, req(R('mcp-run-transport'), 'interop-map.json mcp.methods tools/call', `tools/call MUST start a run (v2Operation createRun) that listRuns shows the same Subject (got ${fresh.length} new run(s); result ${JSON.stringify(ok.error ?? ok.result)})`)).toBe(1);
173
+ const poll = await driver.get(`/runs/${encodeURIComponent(fresh[0]!)}/events/poll?timeout=1`);
174
+ const started = ((poll.json as { events?: Array<{ type?: string; payload?: { transport?: string } }> } | undefined)?.events ?? []).find((e) => e.type === 'run.started');
175
+ expect(started?.payload?.transport, req(R('mcp-run-transport'), 'interop-map.json mcp.methods tools/call; runs.md run.started', 'the run starts with run.started.transport mcp')).toBe('mcp');
176
+ });
177
+
178
+ it('a suspending tool answers InputRequiredResult; the retry resolves it; requestState is single use and forgery-proof', async () => {
179
+ const m = await mount();
180
+ if (!m.ok) return skip(m);
181
+ const missing = needFixtures(['conformance-approval']);
182
+ if (missing) return softSkip('blocked', missing);
183
+ const id = R('mcp-mrtr-server');
184
+ const TOOL = 'conformance-approval';
185
+ const caps = { elicitation: {} };
186
+ const noCap = await toolCall(m.url, TOOL);
187
+ expect(noCap.error?.code, req(id, 'interop-map.json mcp.meta clientCapabilities', `a request that needs elicitation but did not declare it MUST be MissingRequiredClientCapabilityError -32021, never assumed (got ${JSON.stringify(noCap.error ?? noCap.result)})`)).toBe(ERR.MISSING_CAPABILITY);
188
+ const first = await toolCall(m.url, TOOL, {}, { caps });
189
+ expect(first.result?.['resultType'], req(id, 'interop-map.json mcp.mrtr InputRequiredResult (host as server)', `a run reaching waiting-approval MUST answer the in-flight tools/call with InputRequiredResult (got ${JSON.stringify(first.error ?? first.result)})`)).toBe('input_required');
190
+ const requests = (first.result?.['inputRequests'] ?? {}) as Record<string, { method?: string }>;
191
+ const key = Object.keys(requests)[0];
192
+ expect(key !== undefined && requests[key]?.method === 'elicitation/create', req(id, 'interop-map.json mcp.mrtr InputRequiredResult (host as server)', 'inputRequests carries { <interruptId>: elicitation/create }')).toBe(true);
193
+ const state = first.result?.['requestState'];
194
+ expect(typeof state, req(id, 'interop-map.json mcp.mrtr requestState (host as server)', 'a requestState accompanies the input request')).toBe('string');
195
+ const retry = await toolCall(m.url, TOOL, { requestState: state, inputResponses: { [key!]: { action: 'accept', content: { action: 'accept' } } } }, { caps });
196
+ expect([retry.error, retry.result?.['resultType'], retry.result?.['isError']], req(id, 'interop-map.json mcp.mrtr inputResponses[key] (host as server)', `ElicitResult accept resumes the run to completion (got ${JSON.stringify(retry.error ?? retry.result)})`)).toEqual([undefined, 'complete', false]);
197
+ const again = await toolCall(m.url, TOOL, { requestState: state, inputResponses: { [key!]: { action: 'accept', content: { action: 'accept' } } } }, { caps });
198
+ expect(again.error !== undefined || again.status >= 400, req(id, 'interop-map.json mcp.mrtr requestState (host as server)', `requestState is single use: a second retry with it MUST fail (got ${again.status} ${JSON.stringify(again.result)})`)).toBe(true);
199
+ const forged = await toolCall(m.url, TOOL, { requestState: `${String(state)}x`, inputResponses: { [key!]: { action: 'accept', content: { action: 'accept' } } } }, { caps });
200
+ expect(forged.error !== undefined || forged.status >= 400, req(id, 'interop-map.json mcp.mrtr requestState (host as server)', `a requestState that fails integrity verification MUST be refused (got ${forged.status} ${JSON.stringify(forged.result)})`)).toBe(true);
201
+ });
202
+
203
+ it('a list that differs per caller is cacheScope private', async () => {
204
+ const other = process.env.OPENWOP_TEST_TENANT_B_API_KEY;
205
+ if (!other) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — the cross-caller half cannot run');
206
+ const m = await mount();
207
+ if (!m.ok) return skip(m);
208
+ const mine = await call(m.url, 'tools/list', {});
209
+ const theirs = await call(m.url, 'tools/list', {}, { bearer: other });
210
+ if (mine.status === 200 && theirs.status === 200 && JSON.stringify(mine.result?.['tools']) === JSON.stringify(theirs.result?.['tools'])) {
211
+ // A byte-identical list may be public; the private-scope rule binds only a
212
+ // list that differs per caller. Passing here would be a row that cannot fail.
213
+ return softSkip('inapplicable', 'the tool list is byte-identical for both tenants — the cacheScope private rule has nothing to bind on this host');
214
+ }
215
+ expect([mine.status, theirs.status], req(R('mcp-cache-scope'), 'interop-map.json mcp.methods tools/list', 'tools/list is served to both callers')).toEqual([200, 200]);
216
+ const scopes = [mine.result?.['cacheScope'], theirs.result?.['cacheScope']];
217
+ expect(scopes.every((s) => s === 'private'), req(R('mcp-cache-scope'), 'interop-map.json mcp.cache cacheScope', `a list that differs per caller MUST be cacheScope private — public only when byte-identical for every caller (mcp-cache-tenant-scoped); scopes ${JSON.stringify(scopes)}`)).toBe(true);
218
+ });
219
+
220
+ it('unknown _meta extension keys and capabilities.extensions are opaque: processed normally, granting nothing', async () => {
221
+ const m = await mount();
222
+ if (!m.ok) return skip(m);
223
+ const r = await call(m.url, 'tools/list', {}, { caps: { extensions: { 'io.example/authority': { admin: true } } }, extraMeta: { 'io.example/authority': { grantScopes: ['*'] } } });
224
+ expect([r.status, r.error, r.result?.['resultType']], req(R('mcp-extension-opacity'), `${DOC} (what the map does not name is opaque); mcp-extension-no-authority`, `an unknown extension is opaque — neither refused nor honoured (got ${r.status} ${JSON.stringify(r.error)})`)).toEqual([200, undefined, 'complete']);
225
+ const plain = await call(m.url, 'tools/list', {});
226
+ expect(JSON.stringify(r.result?.['tools']), req(R('mcp-extension-opacity'), 'mcp-extension-no-authority', 'an extension asserting authority MUST NOT widen what the caller sees')).toBe(JSON.stringify(plain.result?.['tools']));
227
+ });
228
+
229
+ it('an unauthenticated request is refused at the boundary unless anonymousActor is advertised', async () => {
230
+ const m = await mount();
231
+ if (!m.ok) return skip(m);
232
+ if (await familyAdvertised('anonymousActor')) return softSkip('inapplicable', 'anonymousActor is advertised — anonymous MCP callers are routed through that surface\'s rules');
233
+ const authed = await call(m.url, 'tools/list', {});
234
+ expect(authed.status, req(R('mcp-auth-boundary'), 'interop-map.json mcp.authorization', 'the authenticated call succeeds at the same URL, so a refusal below is not a wrong path')).toBe(200);
235
+ const anon = await call(m.url, 'tools/list', {}, { bearer: null });
236
+ expect([401, 403], req(R('mcp-auth-boundary'), 'interop-map.json mcp.authorization', `every tools/* request MUST be authenticated at the OpenWOP boundary (got ${anon.status})`)).toContain(anon.status);
237
+ const cookies = (anon.headers as Headers & { getSetCookie?: () => string[] }).getSetCookie?.() ?? [];
238
+ if (cookies.length > 0) {
239
+ const withSession = await call(m.url, 'tools/list', {}, { bearer: null, headers: { Cookie: cookies.map((c) => c.split(';')[0]).join('; ') } });
240
+ expect([401, 403], req(R('mcp-auth-boundary'), 'interop-map.json mcp.authorization', `a host-minted anonymous session is still an anonymous principal (got ${withSession.status})`)).toContain(withSession.status);
241
+ }
242
+ });
243
+
244
+ // ── RFC 0199 §D.2 — what the bridge sends for a credential interrupt, and what it refuses to send in form mode ──
245
+
246
+ it('a credential interrupt is answered in URL mode (url = connectUrl), isError without URL support, and input_required again on an accept retry with no credential', async () => {
247
+ const id = 'openwop.requirement.0199.mcp-url-mode';
248
+ const m = await mount();
249
+ if (!m.ok) return skip(m);
250
+ const oauth = await familyAdvertised('oauth');
251
+ if (oauth?.['credentialInterrupt'] !== true) return softSkip('inapplicable', 'oauth.credentialInterrupt is not advertised — the host raises no credential interrupt to bridge (RFC 0199 §C.1)');
252
+ const missing = needFixtures(['conformance-credential']);
253
+ if (missing) return softSkip('blocked', missing);
254
+ const minted = await driver.post(`${SEAMS_PREFIX}/sample/auth/credential/mint`, { lane: 'api-key' }).catch(() => null);
255
+ const bearer = (minted?.json as { credential?: unknown } | undefined)?.credential;
256
+ if (typeof bearer !== 'string') return softSkip('blocked', 'the credential mint seam did not mint a fresh Subject — a Subject that may already hold a credential makes the leg vacuous');
257
+ const TOOL = 'conformance-credential';
258
+ const urlCaps = { elicitation: { url: {} } };
259
+ const first = await toolCall(m.url, TOOL, {}, { caps: urlCaps, bearer });
260
+ expect(first.result?.['resultType'], req(id, 'interop-map.json mcp.mrtr InputRequiredResult (host as server)', `a run suspended on a credential interrupt MUST answer input_required (got ${JSON.stringify(first.error ?? first.result)})`)).toBe('input_required');
261
+ const requests = (first.result?.['inputRequests'] ?? {}) as Record<string, { method?: string; params?: { mode?: string; url?: string; message?: string } }>;
262
+ const key = Object.keys(requests)[0];
263
+ const params = key === undefined ? undefined : requests[key]?.params;
264
+ expect([requests[key ?? '']?.method, params?.mode], req(id, 'RFC 0199 §D.2(a); interop-map.json mcp.mrtr', `a credential interrupt MUST be elicitation/create in mode url, never form (got ${JSON.stringify(requests)})`)).toEqual(['elicitation/create', 'url']);
265
+ let connectUrl: string | undefined;
266
+ if (await familyAdvertised('runList')) {
267
+ const runs = await driver.get('/runs?workflowId=conformance-credential&limit=5', { authenticated: false, headers: { Authorization: `Bearer ${bearer}` } });
268
+ const runId = ((runs.json as { runs?: Array<{ runId?: string }> } | undefined)?.runs ?? [])[0]?.runId;
269
+ if (runId !== undefined) {
270
+ const poll = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`, { authenticated: false, headers: { Authorization: `Bearer ${bearer}` } });
271
+ connectUrl = ((poll.json as { events?: Array<{ type?: string; payload?: { kind?: string; data?: { connectUrl?: string } } }> } | undefined)?.events ?? []).find((e) => e.type === 'interrupt.requested' && e.payload?.kind === 'credential')?.payload?.data?.connectUrl;
272
+ }
273
+ }
274
+ if (connectUrl !== undefined) {
275
+ expect(params?.url, req(id, 'RFC 0199 §D.2(a)', 'the url MUST be the credential interrupt\'s connectUrl')).toBe(connectUrl);
276
+ } else {
277
+ expect(typeof params?.url === 'string' && params.url.startsWith('https://'), req(id, 'RFC 0199 §D.2(a)', `the url MUST be an https connectUrl (runList unavailable, so it is not compared with the log; got ${params?.url})`)).toBe(true);
278
+ }
279
+ const formOnly = await toolCall(m.url, TOOL, {}, { caps: { elicitation: {} }, bearer });
280
+ expect([formOnly.error, formOnly.result?.['isError'], formOnly.result?.['inputRequests']], req(id, 'RFC 0199 §D.2(b) (an empty elicitation capability is form mode only)', `without elicitation.url the host MUST answer CallToolResult isError true and MUST NOT fall back to form mode (got ${JSON.stringify(formOnly.error ?? formOnly.result)})`)).toEqual([undefined, true, undefined]);
281
+ const retry = await toolCall(m.url, TOOL, { requestState: first.result?.['requestState'], inputResponses: { [key!]: { action: 'accept' } } }, { caps: urlCaps, bearer });
282
+ const again = (retry.result?.['inputRequests'] ?? {}) as Record<string, { params?: { mode?: string; url?: string } }>;
283
+ const againParams = Object.values(again)[0]?.params;
284
+ expect([retry.error, retry.result?.['resultType'], againParams?.mode, againParams?.url], req(id, 'RFC 0199 §D.2(c); interop-map.json mcp.mrtr inputResponses[key] (host as server)', `an accept retry with no credential MUST answer input_required again with the same url, not an error (got ${JSON.stringify(retry.error ?? retry.result)})`)).toEqual([undefined, 'input_required', 'url', params?.url]);
285
+ });
286
+
287
+ it('form mode is never emitted for a nested schema or a sensitive (format password) field', async () => {
288
+ const id = 'openwop.requirement.0199.form-mode-no-secret';
289
+ const m = await mount();
290
+ if (!m.ok) return skip(m);
291
+ const missing = needFixtures(['conformance-clarification', 'conformance-clarification-nested', 'conformance-clarification-sensitive']);
292
+ if (missing) return softSkip('blocked', missing);
293
+ const modes = (r: Rpc): string[] => Object.values((r.result?.['inputRequests'] ?? {}) as Record<string, { params?: { mode?: string } }>).map((x) => String(x.params?.mode));
294
+ // Positive control: a flat primitive clarification IS bridged in form mode, so a refusal below is not a bridge that never answers.
295
+ const flat = await toolCall(m.url, 'conformance-clarification', {}, { caps: { elicitation: {} } });
296
+ expect([flat.result?.['resultType'], modes(flat)], req(id, 'interop-map.json mcp.mrtr InputRequiredResult (host as server)', `positive control: a flat primitive clarification is bridged in form mode (got ${JSON.stringify(flat.error ?? flat.result)})`)).toEqual(['input_required', ['form']]);
297
+ for (const tool of ['conformance-clarification-nested', 'conformance-clarification-sensitive']) {
298
+ for (const caps of [{ elicitation: {} }, { elicitation: { url: {} } }]) {
299
+ const r = await toolCall(m.url, tool, {}, { caps });
300
+ expect(r.error, req(id, 'RFC 0199 §D.2(d)', `${tool} MUST be answered with a result, not a JSON-RPC error (got ${JSON.stringify(r.error)})`)).toBeUndefined();
301
+ expect(modes(r).includes('form'), req(id, 'RFC 0199 §D.2(d); invariant elicitation-form-no-secret (MCP Elicitation §Requested Schema / §User Interaction Model)', `${tool} with ${JSON.stringify(caps)}: form mode MUST NOT be emitted for a non-flat or sensitive schema — URL mode when declared, else isError (got ${JSON.stringify(r.result)})`)).toBe(false);
302
+ const urlDeclared = (caps.elicitation as Record<string, unknown>)['url'] !== undefined;
303
+ if (urlDeclared) expect(modes(r), req(id, 'RFC 0199 §D.2(d)', `${tool}: with elicitation.url declared the host emits URL mode (a host-owned resolve page)`)).toEqual(['url']);
304
+ else expect(r.result?.['isError'], req(id, 'RFC 0199 §D.2(d)/(b)', `${tool}: without elicitation.url the host answers isError true`)).toBe(true);
305
+ }
306
+ }
307
+ });
308
+ });