@openwop/openwop-conformance 2.35.1 → 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 (120) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +3 -3
  3. package/coverage.md +13 -1
  4. package/dist/spec-artifacts.lock.json +2 -2
  5. package/fixtures/a2ui-v09/negative-event-name-deleteall.json +139 -0
  6. package/fixtures/a2ui-v09/negative-extra-property.json +140 -0
  7. package/fixtures/a2ui-v09/negative-foreign-catalog.json +139 -0
  8. package/fixtures/a2ui-v09/negative-formatstring-label.json +145 -0
  9. package/fixtures/a2ui-v09/negative-functioncall-openurl.json +129 -0
  10. package/fixtures/a2ui-v09/negative-image-component.json +145 -0
  11. package/fixtures/a2ui-v09/negative-textfield-obscured.json +140 -0
  12. package/fixtures/a2ui-v09/negative-theme-iconurl.json +142 -0
  13. package/fixtures/a2ui-v09/positive-approve-brief.json +139 -0
  14. package/fixtures/conformance-artifact-emit.json +28 -0
  15. package/fixtures/conformance-clarification-nested.json +53 -0
  16. package/fixtures/conformance-clarification-sensitive.json +49 -0
  17. package/fixtures/conformance-credential.json +25 -0
  18. package/fixtures/conformance-mcp-client.json +28 -0
  19. package/fixtures/interrupt-payloads/interrupt-payload-approval.json +1 -0
  20. package/fixtures/interrupt-payloads/interrupt-payload-clarification.json +1 -0
  21. package/fixtures/interrupt-payloads/interrupt-payload-conversation-close.json +1 -0
  22. package/fixtures/interrupt-payloads/interrupt-payload-conversation-exchange.json +1 -0
  23. package/fixtures/interrupt-payloads/interrupt-payload-conversation-start.json +1 -0
  24. package/fixtures/interrupt-payloads/interrupt-payload-custom.json +1 -0
  25. package/fixtures/interrupt-payloads/interrupt-payload-external-event.json +1 -0
  26. package/fixtures/interrupt-payloads/interrupt-payload-low-confidence.json +1 -0
  27. package/fixtures/interrupt-payloads/negative-approval-with-clarification-data.json +1 -0
  28. package/fixtures/interrupt-payloads/negative-conversation-close-with-conversation-start-data.json +1 -0
  29. package/fixtures/interrupt-payloads/negative-conversation-start-with-conversation-exchange-data.json +1 -0
  30. package/fixtures/interrupt-payloads/negative-custom-with-conversation-start-data.json +1 -0
  31. package/fixtures/interrupt-payloads/negative-low-confidence-with-custom-data.json +1 -0
  32. package/fixtures/node-pack-runtime/negative-entry-mismatch.json +31 -0
  33. package/fixtures/node-pack-runtime/negative-headers.json +37 -0
  34. package/fixtures/node-pack-runtime/negative-http.json +31 -0
  35. package/fixtures/node-pack-runtime/negative-icons.json +37 -0
  36. package/fixtures/node-pack-runtime/negative-meta.json +36 -0
  37. package/fixtures/node-pack-runtime/negative-non-remote-language.json +31 -0
  38. package/fixtures/node-pack-runtime/negative-packages.json +41 -0
  39. package/fixtures/node-pack-runtime/negative-sse.json +31 -0
  40. package/fixtures/node-pack-runtime/negative-templated-url.json +31 -0
  41. package/fixtures/node-pack-runtime/negative-two-remotes.json +35 -0
  42. package/fixtures/node-pack-runtime/negative-variables.json +38 -0
  43. package/fixtures/node-pack-runtime/negative-version-range.json +31 -0
  44. package/fixtures/node-pack-runtime/positive-every-optional-member.json +40 -0
  45. package/fixtures/node-pack-runtime/positive-rfc-example.json +31 -0
  46. package/fixtures/node-pack-runtime/upstream/mcp-registry-server-2025-12-11.schema.json +574 -0
  47. package/fixtures/oauth-providers/synthetic.json +36 -4
  48. package/fixtures/upstream/a2a-v1.0.1/README.md +24 -0
  49. package/fixtures/upstream/a2a-v1.0.1/a2a.proto +811 -0
  50. package/fixtures/upstream/a2ui-v0.9/README.md +33 -0
  51. package/fixtures/upstream/a2ui-v0.9/catalog.json +1383 -0
  52. package/fixtures/upstream/a2ui-v0.9/common_types.json +305 -0
  53. package/fixtures/upstream/a2ui-v0.9/server_to_client.json +132 -0
  54. package/fixtures.md +139 -0
  55. package/package.json +2 -2
  56. package/requirements.json +4480 -97
  57. package/scenario-majors.json +93 -6
  58. package/schemas/CORPUS-STAMP.json +103 -89
  59. package/src/lib/localized-content.ts +53 -0
  60. package/src/lib/mcp-fake-server.ts +105 -4
  61. package/src/lib/node-pack-runtime.ts +41 -0
  62. package/src/lib/oauth-as-double.ts +316 -0
  63. package/src/lib/protected-resource.ts +88 -0
  64. package/src/lib/standard-webhooks.ts +120 -0
  65. package/src/lib/toolCatalog.ts +54 -4
  66. package/src/lib/trace-context.ts +98 -0
  67. package/src/lib/v2.ts +6 -0
  68. package/src/lib/webhook-receiver.ts +201 -1
  69. package/src/scenarios/artifact-type-pack-manifest-validation.test.ts +10 -1
  70. package/src/scenarios/auth-challenge-no-oracle.test.ts +122 -0
  71. package/src/scenarios/auth-oauth2-client-credentials.test.ts +46 -5
  72. package/src/scenarios/cross-host-traceparent-propagation.test.ts +92 -58
  73. package/src/scenarios/debugBundle.test.ts +41 -0
  74. package/src/scenarios/fixtures-valid.test.ts +72 -0
  75. package/src/scenarios/inbound-credential-no-passthrough.test.ts +115 -0
  76. package/src/scenarios/interrupt-approver-routing.test.ts +3 -0
  77. package/src/scenarios/localized-content-delivery.test.ts +26 -15
  78. package/src/scenarios/mcp-current-auth-boundary.test.ts +38 -0
  79. package/src/scenarios/mcp-tool-roundtrip.test.ts +29 -0
  80. package/src/scenarios/otel-mcp-semconv-projection.test.ts +85 -0
  81. package/src/scenarios/tool-catalog-compact-projection.test.ts +28 -14
  82. package/src/scenarios/tool-catalog-projection.test.ts +48 -12
  83. package/src/scenarios/tool-descriptor-shape.test.ts +34 -13
  84. package/src/scenarios/v2-a2a-agent-cards.test.ts +305 -0
  85. package/src/scenarios/v2-a2a-operation-map.test.ts +310 -0
  86. package/src/scenarios/v2-a2ui-v09-surface.test.ts +283 -0
  87. package/src/scenarios/v2-advertised-fixtures-exist.test.ts +2 -2
  88. package/src/scenarios/v2-artifact-a2a-shape.test.ts +122 -0
  89. package/src/scenarios/v2-auth-challenge.test.ts +119 -0
  90. package/src/scenarios/v2-bound-id-kinds.test.ts +1 -1
  91. package/src/scenarios/v2-callback-url-guarded.test.ts +2 -2
  92. package/src/scenarios/v2-capability-maturity-bounded.test.ts +108 -0
  93. package/src/scenarios/v2-configurable-closed.test.ts +1 -1
  94. package/src/scenarios/v2-content-locale-keys.test.ts +92 -0
  95. package/src/scenarios/v2-conversation-turn-parts.test.ts +74 -0
  96. package/src/scenarios/v2-credential-interrupt.test.ts +253 -0
  97. package/src/scenarios/v2-effect-identity-business-key.test.ts +2 -2
  98. package/src/scenarios/v2-fork-a-v1-run.test.ts +1 -1
  99. package/src/scenarios/v2-implementation-informational.test.ts +33 -0
  100. package/src/scenarios/v2-interop-trace-context.test.ts +351 -0
  101. package/src/scenarios/v2-lane-exp-only-bound.test.ts +236 -0
  102. package/src/scenarios/v2-lane-issuer-advertised.test.ts +52 -3
  103. package/src/scenarios/v2-mcp-client-results.test.ts +202 -0
  104. package/src/scenarios/v2-mcp-mount-map.test.ts +308 -0
  105. package/src/scenarios/v2-mcp-tasks.test.ts +503 -0
  106. package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +203 -0
  107. package/src/scenarios/v2-oauth-mcp-reach-discovery.test.ts +186 -0
  108. package/src/scenarios/v2-oidc-id-token-audience.test.ts +108 -0
  109. package/src/scenarios/v2-protected-resource-metadata.test.ts +145 -0
  110. package/src/scenarios/v2-run-cancel.test.ts +1 -1
  111. package/src/scenarios/v2-run-fork-prefix.test.ts +4 -4
  112. package/src/scenarios/v2-run-pause-resume.test.ts +1 -1
  113. package/src/scenarios/v2-tool-catalog-annotations.test.ts +100 -0
  114. package/src/scenarios/v2-unmapped-type-refused.test.ts +1 -1
  115. package/src/scenarios/v2-v1-events-translated.test.ts +1 -1
  116. package/src/scenarios/v2-webhook-egress-refusal.test.ts +5 -5
  117. package/src/scenarios/v2-webhook-endpoint-verification.test.ts +124 -0
  118. package/src/scenarios/v2-webhook-message-id-stable.test.ts +124 -0
  119. package/src/scenarios/v2-webhook-secret-rotation.test.ts +132 -0
  120. package/src/scenarios/v2-webhook-standard-webhooks-delivery.test.ts +195 -0
@@ -0,0 +1,503 @@
1
+ /**
2
+ * RFC 0198 — the v2 MCP server mount maps long runs to MCP Tasks
3
+ * (`io.modelcontextprotocol/tasks`, revision 2026-07-28), and a disconnect
4
+ * cancels only the run it owns (`spec/v2/core/interop.md` §"MCP tasks and
5
+ * cancellation"; `spec/v2/interop-map.json` `mcp.tasks`). Target major 2.
6
+ *
7
+ * Gate: the v2 `mcp` record carries `serverMount`, lists profile
8
+ * `mcp-2026-07-28` and an `mcp.serverUrls[0]`, and the mount's
9
+ * `server/discover` lists `io.modelcontextprotocol/tasks` in
10
+ * `capabilities.extensions`. A mount that does not serve the extension is
11
+ * `inapplicable` for the task legs; the §G disconnect / no-`notifications/cancelled`
12
+ * legs bind every v2 mount and run whenever the mount does.
13
+ *
14
+ * Tools (a suite requirement, `conformance/fixtures.md`): `conformance-approval`,
15
+ * `conformance-approval-approvers`, `conformance-failure`, `conformance-delay`
16
+ * and `conformance-cancellable`, each exposed as a tool under its workflowId.
17
+ * A missing fixture is `blocked` naming it, never a pass.
18
+ *
19
+ * Cross-tenant legs need `OPENWOP_TEST_TENANT_B_API_KEY` (a credential bound to
20
+ * a SECOND tenant; not `OPENWOP_TEST_SECONDARY_API_KEY`, which is a same-principal
21
+ * rotation key). Missing ⇒ `blocked`.
22
+ *
23
+ * Unwitnessable halves, stated and not claimed: the entropy of `taskId` beyond
24
+ * its length (a proxy), the TTL rule (§F.11), stdio `notifications/cancelled`
25
+ * (a network suite cannot drive stdio), and the replay restatement (§E.8).
26
+ *
27
+ * @see spec/v2/core/interop.md §"MCP tasks and cancellation"
28
+ * @see spec/v2/interop-map.json mcp.tasks
29
+ * @see RFCS/0198-mcp-server-mount-tasks.md
30
+ */
31
+ import { describe, it, expect } from 'vitest';
32
+ import { request as httpRequest } from 'node:http';
33
+ import { request as httpsRequest } from 'node:https';
34
+ import { driver } from '../lib/driver.js';
35
+ import { v2Discovery, familyAdvertised } from '../lib/v2.js';
36
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
37
+ import { loadEnv } from '../lib/env.js';
38
+ import { softSkip } from '../lib/soft-skip.js';
39
+ import { req } from '../lib/requirement-ids.js';
40
+
41
+ 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';
42
+
43
+ const DOC = 'spec/v2/core/interop.md §"MCP tasks and cancellation" (RFC 0198)';
44
+ const PROFILE = 'mcp-2026-07-28';
45
+ const REV = '2026-07-28';
46
+ const TASKS = 'io.modelcontextprotocol/tasks';
47
+ const META_V = 'io.modelcontextprotocol/protocolVersion';
48
+ const META_C = 'io.modelcontextprotocol/clientCapabilities';
49
+ const INVALID_PARAMS = -32602;
50
+ const MISSING_CAPABILITY = -32021;
51
+ /** identity.md §5 tenant-bound id, after the one-segment projection is undone. */
52
+ const BOUND = /^[A-Za-z0-9._~-]{1,128}\/([A-Za-z0-9._~-]{16,128})$/;
53
+ /** 128 bits in the opaque alphabet (~6 bits/char) — a LENGTH proxy; the entropy itself is unwitnessable. */
54
+ const MIN_OPAQUE = 22;
55
+ /** Requirement ids (top-level literals: generate-requirement-registry reads them). */
56
+ const ID_ADVERTISED_VIA_DISCOVER = 'openwop.requirement.0198.advertised-via-discover';
57
+ const ID_DISCONNECT_CANCELS_RUN = 'openwop.requirement.0198.disconnect-cancels-run';
58
+ const ID_LISTEN_OMITS_UNREADABLE = 'openwop.requirement.0198.listen-omits-unreadable';
59
+ const ID_NO_SERVER_CANCELLED = 'openwop.requirement.0198.no-server-cancelled';
60
+ const ID_TASK_CANCEL_CANCELS_RUN = 'openwop.requirement.0198.task-cancel-cancels-run';
61
+ const ID_TASK_GET_READ_ONLY = 'openwop.requirement.0198.task-get-read-only';
62
+ const ID_TASK_ID_IS_RUN_ID = 'openwop.requirement.0198.task-id-is-run-id';
63
+ const ID_TASK_STATUS_PROJECTION = 'openwop.requirement.0198.task-status-projection';
64
+ const ID_TASK_SURVIVES_DISCONNECT = 'openwop.requirement.0198.task-survives-disconnect';
65
+ const ID_TASK_UNREADABLE_NOT_FOUND = 'openwop.requirement.0198.task-unreadable-not-found';
66
+ const ID_TASK_UPDATE_APPROVER_CHECKED = 'openwop.requirement.0198.task-update-approver-checked';
67
+ const ID_TASK_UPDATE_RESOLVES_ONCE = 'openwop.requirement.0198.task-update-resolves-once';
68
+ const ID_TASK_WHEN_NONTERMINAL = 'openwop.requirement.0198.task-when-nonterminal';
69
+ const TASK_CAPS = { elicitation: {}, extensions: { [TASKS]: {} } };
70
+
71
+ interface RpcError { code: number; message?: string; data?: unknown }
72
+ interface Rpc { status: number; headers: Headers; result?: Record<string, unknown> | undefined; error?: RpcError | undefined }
73
+ type Mount = { ok: true; url: string; facet: Record<string, unknown>; tasks: boolean } | { ok: false; kind: 'inapplicable' | 'blocked'; reason: string };
74
+
75
+ let rpcSeq = 1;
76
+ function envelope(method: string, params: Record<string, unknown>, caps: Record<string, unknown>): Record<string, unknown> {
77
+ return { jsonrpc: '2.0', id: rpcSeq++, method, params: { ...params, _meta: { [META_V]: REV, [META_C]: caps } } };
78
+ }
79
+ function headersFor(method: string, name: string | undefined, bearer: string | null | undefined, accept = 'application/json, text/event-stream'): Record<string, string> {
80
+ const h: Record<string, string> = { 'content-type': 'application/json', accept, 'MCP-Protocol-Version': REV, 'Mcp-Method': method };
81
+ if (name !== undefined) h['Mcp-Name'] = name;
82
+ const key = bearer === undefined ? loadEnv().apiKey : bearer;
83
+ if (key) h['authorization'] = `Bearer ${key}`;
84
+ return h;
85
+ }
86
+
87
+ async function call(url: string, method: string, params: Record<string, unknown>, o: { caps?: Record<string, unknown>; bearer?: string | null; name?: string } = {}): Promise<Rpc> {
88
+ const name = o.name ?? (typeof params['taskId'] === 'string' ? (params['taskId'] as string) : typeof params['name'] === 'string' ? (params['name'] as string) : undefined);
89
+ const res = await fetch(url, { method: 'POST', headers: headersFor(method, name, o.bearer), body: JSON.stringify(envelope(method, params, o.caps ?? TASK_CAPS)) });
90
+ const body = (await res.json().catch(() => ({}))) as { result?: Record<string, unknown>; error?: RpcError };
91
+ return { status: res.status, headers: res.headers, ...body };
92
+ }
93
+
94
+ async function mount(): Promise<Mount> {
95
+ if (!(await v2Discovery())) return { ok: false, kind: 'blocked', reason: 'v2 discovery unreachable' };
96
+ const facet = await familyAdvertised('mcp');
97
+ if (!facet) return { ok: false, kind: 'inapplicable', reason: 'mcp not advertised at major 2' };
98
+ const profiles = Array.isArray(facet['profiles']) ? (facet['profiles'] as unknown[]) : [];
99
+ 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' };
100
+ const urls = Array.isArray(facet['serverUrls']) ? (facet['serverUrls'] as unknown[]).filter((u): u is string => typeof u === 'string' && u.length > 0) : [];
101
+ if (urls.length === 0) return { ok: false, kind: 'blocked', reason: 'mcp.serverMount is advertised with no mcp.serverUrls[0] — the mount is unaddressable' };
102
+ const u = urls[0]!;
103
+ const url = /^https?:\/\//i.test(u) ? u : `${loadEnv().baseUrl}${u.startsWith('/') ? '' : '/'}${u}`;
104
+ const d = await call(url, 'server/discover', {}, { caps: {} });
105
+ const ext = (d.result?.['capabilities'] as { extensions?: Record<string, unknown> } | undefined)?.extensions;
106
+ return { ok: true, url, facet, tasks: ext !== undefined && ext !== null && typeof ext === 'object' && TASKS in ext };
107
+ }
108
+
109
+ /** The task legs: the mount must serve the extension. */
110
+ async function taskMount(): Promise<Mount> {
111
+ const m = await mount();
112
+ if (!m.ok) return m;
113
+ if (!m.tasks) return { ok: false, kind: 'inapplicable', reason: `the mount's server/discover does not list ${TASKS} — the host does not serve MCP Tasks (RFC 0198 §A is a MAY)` };
114
+ return m;
115
+ }
116
+
117
+ function needFixtures(ids: string[]): string | null {
118
+ const missing = ids.filter((id) => !isFixtureAdvertised(id));
119
+ return missing.length === 0 ? null : `fixture(s) ${missing.join(', ')} not in the advertised fixtures[] — the leg calls them as tools`;
120
+ }
121
+
122
+ /** Undo the identity.md §5 one-segment projection (`~XX` → byte). */
123
+ function unproject(segment: string): string {
124
+ const bytes: number[] = [];
125
+ for (let i = 0; i < segment.length; i++) {
126
+ if (segment[i] === '~' && /^[0-9A-Fa-f]{2}$/.test(segment.slice(i + 1, i + 3))) { bytes.push(parseInt(segment.slice(i + 1, i + 3), 16)); i += 2; } else bytes.push(...new TextEncoder().encode(segment[i] as string));
127
+ }
128
+ return new TextDecoder().decode(new Uint8Array(bytes));
129
+ }
130
+
131
+ const createTask = (url: string, tool: string, args: Record<string, unknown> = {}): Promise<Rpc> => call(url, 'tools/call', { name: tool, arguments: args });
132
+ const taskGet = (url: string, taskId: string, bearer?: string | null): Promise<Rpc> => call(url, 'tasks/get', { taskId }, bearer === undefined ? {} : { bearer });
133
+
134
+ async function pollTask(url: string, taskId: string, until: (s: string) => boolean, timeoutMs: number): Promise<Rpc> {
135
+ const deadline = Date.now() + timeoutMs;
136
+ for (;;) {
137
+ const r = await taskGet(url, taskId);
138
+ const s = String(r.result?.['status'] ?? '');
139
+ if (r.error !== undefined || until(s) || Date.now() > deadline) return r;
140
+ await new Promise((ok) => setTimeout(ok, 200));
141
+ }
142
+ }
143
+
144
+ async function runStatus(runId: string): Promise<string | null> {
145
+ const r = await driver.get(`/runs/${encodeURIComponent(runId)}`);
146
+ return r.status === 200 ? String((r.json as { status?: unknown } | null)?.status ?? '') : null;
147
+ }
148
+ async function waitRun(runId: string, wanted: ReadonlySet<string>, timeoutMs: number): Promise<string | null> {
149
+ const deadline = Date.now() + timeoutMs;
150
+ for (;;) {
151
+ const s = await runStatus(runId);
152
+ if ((s !== null && wanted.has(s)) || Date.now() > deadline) return s;
153
+ await new Promise((ok) => setTimeout(ok, 200));
154
+ }
155
+ }
156
+ interface Ev { type?: string; sequence?: number; payload?: Record<string, unknown> }
157
+ async function events(runId: string): Promise<Ev[]> {
158
+ const r = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
159
+ return (r.json as { events?: Ev[] } | undefined)?.events ?? [];
160
+ }
161
+ async function listRunIds(workflowId: string): Promise<string[]> {
162
+ const r = await driver.get(`/runs?workflowId=${encodeURIComponent(workflowId)}&limit=100`);
163
+ return ((r.json as { runs?: Array<{ runId?: string }> } | undefined)?.runs ?? []).map((x) => String(x.runId));
164
+ }
165
+ async function freshRun(workflowId: string, before: ReadonlySet<string>, timeoutMs: number): Promise<string | null> {
166
+ const deadline = Date.now() + timeoutMs;
167
+ for (;;) {
168
+ const fresh = (await listRunIds(workflowId)).filter((x) => !before.has(x));
169
+ if (fresh.length > 0) return fresh[0]!;
170
+ if (Date.now() > deadline) return null;
171
+ await new Promise((ok) => setTimeout(ok, 100));
172
+ }
173
+ }
174
+ async function runListGate(): Promise<string | null> {
175
+ const f = await familyAdvertised('runList');
176
+ if (!f) return 'runList not advertised — the suite has no black-box way to find a run the client never received a handle for';
177
+ return null;
178
+ }
179
+
180
+ /** POST a JSON-RPC request over a raw socket and hand back the socket, so the leg decides when the connection dies. */
181
+ function rawPost(url: string, body: Record<string, unknown>, headers: Record<string, string>): { done: Promise<{ status: number; text: string }>; destroy: () => void; started: Promise<void> } {
182
+ const u = new URL(url);
183
+ const payload = JSON.stringify(body);
184
+ const fn = u.protocol === 'https:' ? httpsRequest : httpRequest;
185
+ let destroyFn: () => void = () => undefined;
186
+ let startedOk: () => void = () => undefined;
187
+ const started = new Promise<void>((ok) => { startedOk = ok; });
188
+ const done = new Promise<{ status: number; text: string }>((ok, fail) => {
189
+ const r = fn(u, { method: 'POST', agent: false, headers: { ...headers, 'content-length': String(Buffer.byteLength(payload)) } }, (res) => {
190
+ let text = '';
191
+ res.setEncoding('utf8');
192
+ res.on('data', (c: string) => { text += c; });
193
+ res.on('end', () => ok({ status: res.statusCode ?? 0, text }));
194
+ res.on('error', (e) => fail(e));
195
+ });
196
+ r.on('error', (e) => fail(e));
197
+ r.on('socket', () => startedOk());
198
+ destroyFn = () => r.destroy();
199
+ r.end(payload);
200
+ });
201
+ return { done, destroy: () => destroyFn(), started };
202
+ }
203
+
204
+ /** Read an SSE response frame by frame (`data:` lines are JSON-RPC messages). */
205
+ async function* sseMessages(res: Response): AsyncGenerator<Record<string, unknown>> {
206
+ const reader = res.body!.getReader();
207
+ const dec = new TextDecoder();
208
+ let buf = '';
209
+ for (;;) {
210
+ const { value, done } = await reader.read();
211
+ if (done) break;
212
+ buf += dec.decode(value, { stream: true });
213
+ let cut: number;
214
+ while ((cut = buf.search(/\r?\n\r?\n/)) >= 0) {
215
+ const frame = buf.slice(0, cut);
216
+ buf = buf.slice(cut).replace(/^\r?\n\r?\n/, '');
217
+ const data = frame.split(/\r?\n/).filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trimStart()).join('\n');
218
+ if (data.length > 0) { try { yield JSON.parse(data) as Record<string, unknown>; } catch { /* a non-JSON frame is not a JSON-RPC message */ } }
219
+ }
220
+ }
221
+ }
222
+
223
+ /** Open `subscriptions/listen` for task ids and return the acknowledgement plus anything received within `windowMs`. */
224
+ async function listen(url: string, taskIds: string[], bearer: string | undefined, windowMs: number): Promise<{ status: number; ack: Record<string, unknown> | null; after: Array<Record<string, unknown>>; error?: RpcError }> {
225
+ const ac = new AbortController();
226
+ const res = await fetch(url, { method: 'POST', signal: ac.signal, headers: headersFor('subscriptions/listen', undefined, bearer, 'text/event-stream, application/json'), body: JSON.stringify(envelope('subscriptions/listen', { notifications: { taskIds } }, TASK_CAPS)) });
227
+ if (!(res.headers.get('content-type') ?? '').includes('text/event-stream')) {
228
+ const body = (await res.json().catch(() => ({}))) as { error?: RpcError };
229
+ return { status: res.status, ack: null, after: [], ...(body.error ? { error: body.error } : {}) };
230
+ }
231
+ let ack: Record<string, unknown> | null = null;
232
+ const after: Array<Record<string, unknown>> = [];
233
+ const timer = setTimeout(() => ac.abort(), windowMs);
234
+ try {
235
+ for await (const msg of sseMessages(res)) {
236
+ if (ack === null && msg['method'] === 'notifications/subscriptions/acknowledged') ack = msg; else after.push(msg);
237
+ }
238
+ } catch { /* the leg's own abort ends the stream */ } finally { clearTimeout(timer); ac.abort(); }
239
+ return { status: res.status, ack, after };
240
+ }
241
+ const ackedIds = (ack: Record<string, unknown> | null): string[] => {
242
+ const n = (ack?.['params'] as { notifications?: { taskIds?: unknown } } | undefined)?.notifications?.taskIds;
243
+ return Array.isArray(n) ? n.map(String) : [];
244
+ };
245
+
246
+ describe('RFC 0198 — v2-mcp-tasks (MCP Tasks on the server mount; disconnect cancels only the run it owns)', () => {
247
+ it('a mount that lists the Tasks extension in server/discover also lists extensions in mcp.features', async () => {
248
+ const m = await taskMount();
249
+ if (!m.ok) return softSkip(m.kind, m.reason);
250
+ const features = Array.isArray(m.facet['features']) ? (m.facet['features'] as unknown[]).map(String) : [];
251
+ expect(features, req(ID_ADVERTISED_VIA_DISCOVER, `${DOC}; RFC 0198 §A.1`, `server/discover lists ${TASKS}, so mcp.features[] MUST list \`extensions\` (one direction only, G9); advertised [${features.join(', ')}]`)).toContain('extensions');
252
+ });
253
+
254
+ it('a tools/call declaring the extension on a run that is not terminal is answered CreateTaskResult', async () => {
255
+ const m = await taskMount();
256
+ if (!m.ok) return softSkip(m.kind, m.reason);
257
+ const missing = needFixtures(['conformance-approval', 'conformance-delay']);
258
+ if (missing) return softSkip('blocked', missing);
259
+ const r = await createTask(m.url, 'conformance-approval');
260
+ expect(r.error, req(ID_TASK_WHEN_NONTERMINAL, `${DOC}; RFC 0198 §B.3`, `tools/call conformance-approval with the extension declared MUST be answered, not refused: ${JSON.stringify(r.error)}`)).toBeUndefined();
261
+ expect(r.result?.['resultType'], req(ID_TASK_WHEN_NONTERMINAL, `${DOC}; RFC 0198 §B.3`, `a non-terminal run MUST be answered CreateTaskResult (resultType task), never InputRequiredResult; got ${JSON.stringify(r.result)}`)).toBe('task');
262
+ expect(['working', 'input_required'], req(ID_TASK_WHEN_NONTERMINAL, 'interop-map.json mcp.tasks.status', `the seed status of a suspending run is working | input_required (got ${String(r.result?.['status'])})`)).toContain(r.result?.['status']);
263
+ const taskId = String(r.result?.['taskId'] ?? '');
264
+ const got = await taskGet(m.url, taskId);
265
+ expect(got.error, req(ID_TASK_WHEN_NONTERMINAL, 'ext-tasks §Task Creation (durably created)', `a returned taskId MUST already resolve on tasks/get: ${JSON.stringify(got.error)}`)).toBeUndefined();
266
+ // Upstream MUST NOT: a CreateTaskResult to a client that did not declare the extension.
267
+ const plain = await call(m.url, 'tools/call', { name: 'conformance-delay', arguments: { delayMs: 200 } }, { caps: {} });
268
+ expect(plain.result?.['resultType'], req(ID_TASK_WHEN_NONTERMINAL, 'ext-tasks §Capability Negotiation', `a tools/call that did not declare ${TASKS} MUST NOT be answered CreateTaskResult`)).not.toBe('task');
269
+ const noCap = await call(m.url, 'tasks/get', { taskId }, { caps: {} });
270
+ expect(noCap.error?.code, req(ID_TASK_WHEN_NONTERMINAL, 'interop-map.json mcp.tasks.fields clientCapabilities.extensions', `tasks/get without the extension declared MUST be -32021 (got ${JSON.stringify(noCap.error ?? noCap.result)})`)).toBe(MISSING_CAPABILITY);
271
+ await call(m.url, 'tasks/cancel', { taskId });
272
+ });
273
+
274
+ it('taskId is the run id in its projected wire form, with an opaque segment of at least 22 characters', async () => {
275
+ const m = await taskMount();
276
+ if (!m.ok) return softSkip(m.kind, m.reason);
277
+ const missing = needFixtures(['conformance-approval']);
278
+ if (missing) return softSkip('blocked', missing);
279
+ const r = await createTask(m.url, 'conformance-approval');
280
+ const taskId = String(r.result?.['taskId'] ?? '');
281
+ const runId = unproject(taskId);
282
+ const match = BOUND.exec(runId);
283
+ expect(match !== null, req(ID_TASK_ID_IS_RUN_ID, `${DOC}; identity.md §5`, `taskId MUST be the run's projected tenant-bound runId <tenantId>~2F<opaque> (got ${JSON.stringify(taskId)})`)).toBe(true);
284
+ expect((match?.[1] ?? '').length, req(ID_TASK_ID_IS_RUN_ID, `${DOC}; RFC 0198 §C.5`, 'the opaque segment carries ≥128 bits (length ≥ 22 is the observable proxy; the entropy itself is unwitnessable)')).toBeGreaterThanOrEqual(MIN_OPAQUE);
285
+ const run = await driver.get(`/runs/${encodeURIComponent(runId)}`);
286
+ expect([run.status, (run.json as { workflowId?: string } | undefined)?.workflowId], req(ID_TASK_ID_IS_RUN_ID, `${DOC}; RFC 0198 §C.5`, `getRun(<taskId unprojected>) MUST be the run the call started (got ${run.status})`)).toEqual([200, 'conformance-approval']);
287
+ await call(m.url, 'tasks/cancel', { taskId });
288
+ });
289
+
290
+ it('tasks/get projects input_required keyed by interruptId, and a failed run as completed with isError true', async () => {
291
+ const m = await taskMount();
292
+ if (!m.ok) return softSkip(m.kind, m.reason);
293
+ const missing = needFixtures(['conformance-approval', 'conformance-failure']);
294
+ if (missing) return softSkip('blocked', missing);
295
+ const r = await createTask(m.url, 'conformance-approval');
296
+ const taskId = String(r.result?.['taskId'] ?? '');
297
+ const t = await pollTask(m.url, taskId, (s) => s === 'input_required', 10_000);
298
+ expect(t.result?.['status'], req(ID_TASK_STATUS_PROJECTION, 'interop-map.json mcp.tasks.status waiting-approval', `a waiting-approval run MUST read input_required (got ${JSON.stringify(t.error ?? t.result)})`)).toBe('input_required');
299
+ expect(t.result?.['resultType'], req(ID_TASK_STATUS_PROJECTION, 'ext-tasks §Task Polling', 'tasks/get carries resultType complete')).toBe('complete');
300
+ const requests = (t.result?.['inputRequests'] ?? {}) as Record<string, { method?: string }>;
301
+ const suspended = (await events(unproject(taskId))).filter((e) => e.type === 'node.suspended').map((e) => String(e.payload?.['interruptId']));
302
+ expect(Object.keys(requests), req(ID_TASK_STATUS_PROJECTION, 'interop-map.json mcp.tasks.status; RFC 0198 §D', `inputRequests MUST carry exactly one key per open interrupt, keyed by its interruptId (node.suspended ${JSON.stringify(suspended)})`)).toEqual(suspended);
303
+ expect(requests[suspended[0] ?? '']?.method, req(ID_TASK_STATUS_PROJECTION, 'interop-map.json mcp.mrtr InputRequiredResult', 'the entry is projected as the MRTR row projects it (elicitation/create)')).toBe('elicitation/create');
304
+ await call(m.url, 'tasks/cancel', { taskId });
305
+
306
+ const f = await createTask(m.url, 'conformance-failure');
307
+ let terminal: Rpc;
308
+ if (f.result?.['resultType'] === 'task') terminal = await pollTask(m.url, String(f.result['taskId']), (s) => ['completed', 'failed', 'cancelled'].includes(s), 10_000);
309
+ else return softSkip('inapplicable', 'conformance-failure finished before the host answered, so it was answered CallToolResult (RFC 0198 §B.4) — no task to project');
310
+ expect([terminal.result?.['status'], (terminal.result?.['result'] as { isError?: unknown } | undefined)?.isError], req(ID_TASK_STATUS_PROJECTION, 'interop-map.json mcp.tasks.status failed; ext-tasks §Task Execution Errors', `a failed run is a tool outcome: completed with result.isError true, never failed (got ${JSON.stringify(terminal.result)})`)).toEqual(['completed', true]);
311
+ });
312
+
313
+ it('tasks/update answered twice resolves the interrupt once and the task completes', async () => {
314
+ const m = await taskMount();
315
+ if (!m.ok) return softSkip(m.kind, m.reason);
316
+ const missing = needFixtures(['conformance-approval']);
317
+ if (missing) return softSkip('blocked', missing);
318
+ const r = await createTask(m.url, 'conformance-approval');
319
+ const taskId = String(r.result?.['taskId'] ?? '');
320
+ const t = await pollTask(m.url, taskId, (s) => s === 'input_required', 10_000);
321
+ const key = Object.keys((t.result?.['inputRequests'] ?? {}) as Record<string, unknown>)[0];
322
+ expect(key, req(ID_TASK_UPDATE_RESOLVES_ONCE, 'interop-map.json mcp.tasks.status', 'the approval task is input_required with one key')).toBeDefined();
323
+ const answer = { inputResponses: { [key!]: { action: 'accept', content: { action: 'accept' } } } };
324
+ const u1 = await call(m.url, 'tasks/update', { taskId, ...answer });
325
+ const u2 = await call(m.url, 'tasks/update', { taskId, ...answer });
326
+ expect([u1.error, u1.result?.['resultType'], u2.error, u2.result?.['resultType']], req(ID_TASK_UPDATE_RESOLVES_ONCE, 'interop-map.json mcp.tasks.methods tasks/update', `both updates MUST be acknowledged with an empty result — a repeat resolves nothing and is not an error (got ${JSON.stringify([u1.error ?? u1.result, u2.error ?? u2.result])})`)).toEqual([undefined, 'complete', undefined, 'complete']);
327
+ const done = await pollTask(m.url, taskId, (s) => ['completed', 'failed', 'cancelled'].includes(s), 10_000);
328
+ expect([done.result?.['status'], (done.result?.['result'] as { isError?: unknown } | undefined)?.isError], req(ID_TASK_UPDATE_RESOLVES_ONCE, 'interop-map.json mcp.tasks.status completed', `the accepted task completes with result.isError false (got ${JSON.stringify(done.result)})`)).toEqual(['completed', false]);
329
+ const resolved = (await events(unproject(taskId))).filter((e) => e.type === 'interrupt.resolved');
330
+ expect(resolved.length, req(ID_TASK_UPDATE_RESOLVES_ONCE, `${DOC}; RFC 0198 §E.7`, 'one interrupt is resolved at most once however often it is answered: exactly one interrupt.resolved')).toBe(1);
331
+ });
332
+
333
+ it('a tasks/update from a caller not in approversList resolves nothing', async () => {
334
+ const m = await taskMount();
335
+ if (!m.ok) return softSkip(m.kind, m.reason);
336
+ const missing = needFixtures(['conformance-approval-approvers']);
337
+ if (missing) return softSkip('blocked', missing);
338
+ // The suite's bearer is not `urn:conformance:listed-approver`, so it is the non-listed resolver by construction.
339
+ const r = await createTask(m.url, 'conformance-approval-approvers');
340
+ const taskId = String(r.result?.['taskId'] ?? '');
341
+ const t = await pollTask(m.url, taskId, (s) => s === 'input_required', 10_000);
342
+ const key = Object.keys((t.result?.['inputRequests'] ?? {}) as Record<string, unknown>)[0];
343
+ expect(key, req(ID_TASK_UPDATE_APPROVER_CHECKED, 'interop-map.json mcp.tasks.status', `the approvers task is input_required (got ${JSON.stringify(t.error ?? t.result)})`)).toBeDefined();
344
+ const u = await call(m.url, 'tasks/update', { taskId, inputResponses: { [key!]: { action: 'accept', content: { action: 'accept' } } } });
345
+ expect(u.error === undefined || u.error.code === INVALID_PARAMS, req(ID_TASK_UPDATE_APPROVER_CHECKED, 'interop-map.json mcp.tasks.methods tasks/update', `a non-approver's update is acknowledged or -32602, never an internal error (got ${JSON.stringify(u.error)})`)).toBe(true);
346
+ await new Promise((ok) => setTimeout(ok, 750));
347
+ expect(await runStatus(unproject(taskId)), req(ID_TASK_UPDATE_APPROVER_CHECKED, `${DOC}; interrupt.md §Approver enforcement; RFC 0198 §E.7`, 'the interrupt stays open: the run is still waiting-approval')).toBe('waiting-approval');
348
+ expect((await events(unproject(taskId))).filter((e) => e.type === 'interrupt.resolved').length, req(ID_TASK_UPDATE_APPROVER_CHECKED, 'interrupt.md §Approver enforcement', 'no interrupt.resolved is recorded')).toBe(0);
349
+ await call(m.url, 'tasks/cancel', { taskId });
350
+ });
351
+
352
+ it('tasks/get appends nothing to the run log', async () => {
353
+ const m = await taskMount();
354
+ if (!m.ok) return softSkip(m.kind, m.reason);
355
+ const missing = needFixtures(['conformance-approval']);
356
+ if (missing) return softSkip('blocked', missing);
357
+ const r = await createTask(m.url, 'conformance-approval');
358
+ const taskId = String(r.result?.['taskId'] ?? '');
359
+ await pollTask(m.url, taskId, (s) => s === 'input_required', 10_000);
360
+ const count = async (): Promise<number> => (await events(unproject(taskId))).filter((e) => !String(e.type).startsWith('heartbeat.')).length;
361
+ const before = await count();
362
+ for (let i = 0; i < 3; i++) { await taskGet(m.url, taskId); await new Promise((ok) => setTimeout(ok, 300)); }
363
+ expect(await count(), req(ID_TASK_GET_READ_ONLY, `${DOC}; RFC 0198 §F.9`, 'the host MUST NOT append to a run\'s log to answer tasks/get (heartbeat.* excluded)')).toBe(before);
364
+ await call(m.url, 'tasks/cancel', { taskId });
365
+ });
366
+
367
+ it('an unreadable task is answered -32602 exactly as a nonexistent one', async () => {
368
+ const other = process.env['OPENWOP_TEST_TENANT_B_API_KEY'];
369
+ 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');
370
+ const m = await taskMount();
371
+ if (!m.ok) return softSkip(m.kind, m.reason);
372
+ const missing = needFixtures(['conformance-approval']);
373
+ if (missing) return softSkip('blocked', missing);
374
+ const r = await createTask(m.url, 'conformance-approval');
375
+ const taskId = String(r.result?.['taskId'] ?? '');
376
+ const mine = await taskGet(m.url, taskId);
377
+ expect(mine.error, req(ID_TASK_UNREADABLE_NOT_FOUND, 'interop-map.json mcp.tasks.methods tasks/get', 'positive control: the owner reads its own task')).toBeUndefined();
378
+ const runId = unproject(taskId);
379
+ const slash = runId.indexOf('/');
380
+ const opaque = runId.slice(slash + 1);
381
+ const tenant = runId.slice(0, slash);
382
+ const fabricated = `${tenant}~2F${'Z'.repeat(opaque.length)}`;
383
+ const foreign = `${tenant === 'conformance-foreign-tenant' ? 'conformance-foreign-tenant-2' : 'conformance-foreign-tenant'}~2F${opaque}`;
384
+ const legs: Array<[string, Rpc]> = [
385
+ ['fabricated same-tenant id', await taskGet(m.url, fabricated)],
386
+ ['real opaque, tenant segment swapped', await taskGet(m.url, foreign)],
387
+ ["tenant B reading tenant A's task", await taskGet(m.url, taskId, other)],
388
+ ];
389
+ for (const [what, g] of legs) {
390
+ expect([g.error?.code, g.result], req(ID_TASK_UNREADABLE_NOT_FOUND, `${DOC}; interop.md §"The operation mappings" Isolation; RFC 0198 §C.6`, `${what}: MUST be -32602 with no result, exactly as a nonexistent task — never 403 / -32603 (got ${g.status} ${JSON.stringify(g.error ?? g.result)})`)).toEqual([INVALID_PARAMS, undefined]);
391
+ }
392
+ const shape = legs.map(([, g]) => JSON.stringify({ status: g.status, code: g.error?.code, message: g.error?.message, data: g.error?.data }));
393
+ expect(new Set(shape).size, req(ID_TASK_UNREADABLE_NOT_FOUND, 'SECURITY/invariants.yaml mcp-task-tenant-scoped', `the three refusals MUST be indistinguishable (status, code, message, data): ${shape.join(' | ')}`)).toBe(1);
394
+ const bCancel = await call(m.url, 'tasks/cancel', { taskId }, { bearer: other });
395
+ expect(bCancel.error?.code, req(ID_TASK_UNREADABLE_NOT_FOUND, 'interop-map.json mcp.tasks.methods tasks/cancel', `tenant B cancelling tenant A's task MUST be -32602 (got ${JSON.stringify(bCancel.error ?? bCancel.result)})`)).toBe(INVALID_PARAMS);
396
+ expect(await runStatus(runId), req(ID_TASK_UNREADABLE_NOT_FOUND, 'SECURITY/invariants.yaml mcp-task-tenant-scoped', 'possession of a taskId grants nothing: the run is untouched by the foreign cancel')).toBe('waiting-approval');
397
+ await call(m.url, 'tasks/cancel', { taskId });
398
+ });
399
+
400
+ it("a subscriptions/listen acknowledgement leaves out a task the caller cannot read", async () => {
401
+ const other = process.env['OPENWOP_TEST_TENANT_B_API_KEY'];
402
+ 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');
403
+ const m = await taskMount();
404
+ if (!m.ok) return softSkip(m.kind, m.reason);
405
+ const missing = needFixtures(['conformance-approval']);
406
+ if (missing) return softSkip('blocked', missing);
407
+ const r = await createTask(m.url, 'conformance-approval');
408
+ const taskId = String(r.result?.['taskId'] ?? '');
409
+ const own = await listen(m.url, [taskId], undefined, 1500);
410
+ if (own.error?.code === -32601 || (own.ack !== null && !ackedIds(own.ack).includes(taskId))) {
411
+ await call(m.url, 'tasks/cancel', { taskId });
412
+ return softSkip('inapplicable', 'the host does not deliver task status notifications on subscriptions/listen (optional upstream): it does not acknowledge even the owner\'s own task, so there is nothing for the cross-caller rule to bind');
413
+ }
414
+ expect(ackedIds(own.ack), req(ID_LISTEN_OMITS_UNREADABLE, 'interop-map.json mcp.tasks.methods notifications/tasks', `positive control: the owner's listen acknowledges its own task (got ${JSON.stringify(own.error ?? own.ack)})`)).toContain(taskId);
415
+ const theirs = await listen(m.url, [taskId], other, 3000);
416
+ expect(theirs.ack, req(ID_LISTEN_OMITS_UNREADABLE, 'subscriptions §Acknowledgment', `tenant B's listen is served (the acknowledgement is the first message) — got ${theirs.status} ${JSON.stringify(theirs.error)}`)).not.toBeNull();
417
+ expect(ackedIds(theirs.ack), req(ID_LISTEN_OMITS_UNREADABLE, `${DOC}; RFC 0198 §C.6`, "a task id the caller cannot read MUST be left out of the acknowledgement, exactly as a nonexistent one")).toEqual([]);
418
+ expect(theirs.after.filter((x) => x['method'] === 'notifications/tasks').length, req(ID_LISTEN_OMITS_UNREADABLE, 'SECURITY/invariants.yaml mcp-task-tenant-scoped', 'and never notified')).toBe(0);
419
+ await call(m.url, 'tasks/cancel', { taskId });
420
+ });
421
+
422
+ it('tasks/cancel cancels the run; on a terminal task it is acknowledged and appends nothing', async () => {
423
+ const m = await taskMount();
424
+ if (!m.ok) return softSkip(m.kind, m.reason);
425
+ const missing = needFixtures(['conformance-approval']);
426
+ if (missing) return softSkip('blocked', missing);
427
+ const r = await createTask(m.url, 'conformance-approval');
428
+ const taskId = String(r.result?.['taskId'] ?? '');
429
+ await pollTask(m.url, taskId, (s) => s === 'input_required', 10_000);
430
+ const c = await call(m.url, 'tasks/cancel', { taskId });
431
+ expect([c.error, c.result?.['resultType']], req(ID_TASK_CANCEL_CANCELS_RUN, 'interop-map.json mcp.tasks.methods tasks/cancel', `tasks/cancel is acknowledged with an empty result (got ${JSON.stringify(c.error ?? c.result)})`)).toEqual([undefined, 'complete']);
432
+ expect(await waitRun(unproject(taskId), new Set(['cancelled']), 10_000), req(ID_TASK_CANCEL_CANCELS_RUN, `${DOC}; RFC 0198 §F.10`, 'the run is cancelled as cancelRun would cancel it, within 10 s')).toBe('cancelled');
433
+ const t = await taskGet(m.url, taskId);
434
+ expect(t.result?.['status'], req(ID_TASK_CANCEL_CANCELS_RUN, 'interop-map.json mcp.tasks.status cancelled', 'tasks/get reads cancelled')).toBe('cancelled');
435
+ const before = (await events(unproject(taskId))).length;
436
+ const again = await call(m.url, 'tasks/cancel', { taskId });
437
+ expect([again.error, again.result?.['resultType']], req(ID_TASK_CANCEL_CANCELS_RUN, 'interop-map.json mcp.tasks.methods tasks/cancel; RFC 0194 §A.2', `a cancel on a terminal task is acknowledged (got ${JSON.stringify(again.error ?? again.result)})`)).toEqual([undefined, 'complete']);
438
+ expect((await events(unproject(taskId))).length, req(ID_TASK_CANCEL_CANCELS_RUN, 'RFC 0194 §A.2', 'and nothing is appended after the terminal event')).toBe(before);
439
+ });
440
+
441
+ it('a client disconnect before the host answers a task-less tools/call cancels the run it started', async () => {
442
+ const m = await mount();
443
+ if (!m.ok) return softSkip(m.kind, m.reason);
444
+ const missing = needFixtures(['conformance-delay']);
445
+ if (missing) return softSkip('blocked', missing);
446
+ const gate = await runListGate();
447
+ if (gate) return softSkip('inapplicable', gate);
448
+ const before = new Set(await listRunIds('conformance-delay'));
449
+ const p = rawPost(m.url, envelope('tools/call', { name: 'conformance-delay', arguments: { delayMs: 8000 } }, {}), headersFor('tools/call', 'conformance-delay', undefined));
450
+ p.done.catch(() => undefined);
451
+ const runId = await freshRun('conformance-delay', before, 3000);
452
+ expect(runId, req(ID_DISCONNECT_CANCELS_RUN, 'interop-map.json mcp.methods tools/call', 'the task-less tools/call started a run listRuns shows')).not.toBeNull();
453
+ await new Promise((ok) => setTimeout(ok, 500));
454
+ p.destroy();
455
+ const s = await waitRun(runId!, new Set(['cancelled', 'completed', 'failed']), 15_000);
456
+ expect(s, req(ID_DISCONNECT_CANCELS_RUN, `${DOC}; RFC 0198 §G.12`, 'until the host has answered, the run belongs to the request: a streamable-HTTP disconnect MUST cancel it (not detach it)')).toBe('cancelled');
457
+ const cancelled = (await events(runId!)).find((e) => e.type === 'run.cancelled');
458
+ expect(cancelled?.payload?.['reason'], req(ID_DISCONNECT_CANCELS_RUN, `${DOC}; run-event-payloads runCancelled.reason`, 'run.cancelled.reason is mcp-request-cancelled')).toBe('mcp-request-cancelled');
459
+ });
460
+
461
+ it('a disconnect after CreateTaskResult never affects the run', async () => {
462
+ const m = await taskMount();
463
+ if (!m.ok) return softSkip(m.kind, m.reason);
464
+ const missing = needFixtures(['conformance-delay']);
465
+ if (missing) return softSkip('blocked', missing);
466
+ const p = rawPost(m.url, envelope('tools/call', { name: 'conformance-delay', arguments: { delayMs: 2500 } }, TASK_CAPS), { ...headersFor('tools/call', 'conformance-delay', undefined), connection: 'keep-alive' });
467
+ const res = await p.done;
468
+ p.destroy();
469
+ const body = JSON.parse(res.text || '{}') as { result?: Record<string, unknown> };
470
+ expect(body.result?.['resultType'], req(ID_TASK_SURVIVES_DISCONNECT, 'RFC 0198 §B.3', `a 2.5 s run is not terminal when the host answers: CreateTaskResult (got ${res.text.slice(0, 200)})`)).toBe('task');
471
+ const taskId = String(body.result?.['taskId']);
472
+ const done = await pollTask(m.url, taskId, (s) => ['completed', 'failed', 'cancelled'].includes(s), 15_000);
473
+ expect(done.result?.['status'], req(ID_TASK_SURVIVES_DISCONNECT, `${DOC}; RFC 0198 §G.13`, 'once the response is sent, a disconnect MUST NOT affect the run: the task reads completed over a new connection')).toBe('completed');
474
+ });
475
+
476
+ it('a blocking tools/call on an SSE stream whose run is cancelled carries no notifications/cancelled and ends isError true', async () => {
477
+ const m = await mount();
478
+ if (!m.ok) return softSkip(m.kind, m.reason);
479
+ const missing = needFixtures(['conformance-cancellable']);
480
+ if (missing) return softSkip('blocked', missing);
481
+ const gate = await runListGate();
482
+ if (gate) return softSkip('inapplicable', gate);
483
+ const before = new Set(await listRunIds('conformance-cancellable'));
484
+ const ac = new AbortController();
485
+ const resP = fetch(m.url, { method: 'POST', signal: ac.signal, headers: headersFor('tools/call', 'conformance-cancellable', undefined, 'text/event-stream, application/json'), body: JSON.stringify(envelope('tools/call', { name: 'conformance-cancellable', arguments: { delayMs: 20_000 } }, {})) });
486
+ const runId = await freshRun('conformance-cancellable', before, 3000);
487
+ expect(runId, req(ID_NO_SERVER_CANCELLED, 'interop-map.json mcp.methods tools/call', 'the tools/call started a run listRuns shows')).not.toBeNull();
488
+ const res = await resP;
489
+ if (!(res.headers.get('content-type') ?? '').includes('text/event-stream')) {
490
+ ac.abort();
491
+ await driver.post(`/runs/${encodeURIComponent(runId!)}/cancel`, {});
492
+ return softSkip('inapplicable', 'the mount answered the blocking call as JSON, not SSE — a JSON response cannot carry a notifications/cancelled frame');
493
+ }
494
+ const cancel = await driver.post(`/runs/${encodeURIComponent(runId!)}/cancel`, {});
495
+ expect(cancel.status, req(ID_NO_SERVER_CANCELLED, 'runs.md §Cancel', `REST cancelRun is accepted (got ${cancel.status})`)).toBeLessThan(300);
496
+ const msgs: Array<Record<string, unknown>> = [];
497
+ const timer = setTimeout(() => ac.abort(), 15_000);
498
+ try { for await (const msg of sseMessages(res)) msgs.push(msg); } catch { /* aborted at the deadline */ } finally { clearTimeout(timer); }
499
+ expect(msgs.filter((x) => x['method'] === 'notifications/cancelled').length, req(ID_NO_SERVER_CANCELLED, `${DOC}; RFC 0198 §G.14; MCP cancellation (servers MUST NOT send notifications/cancelled for any other purpose)`, 'the host MUST NOT use notifications/cancelled to report that a run was cancelled')).toBe(0);
500
+ const final = msgs.find((x) => 'result' in x || 'error' in x) as { result?: { isError?: unknown }; error?: unknown } | undefined;
501
+ expect(final?.result?.isError, req(ID_NO_SERVER_CANCELLED, `${DOC}; RFC 0198 §G.14`, `a cancelled blocking call is answered CallToolResult { isError: true } (got ${JSON.stringify(final)})`)).toBe(true);
502
+ });
503
+ });