@openwop/openwop-conformance 1.132.0 → 1.134.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/pack-manifests/workflow-chain-sample.pack.json +203 -11
- package/package.json +1 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/src/lib/driver.ts +3 -1
- package/src/lib/mcp-mount.ts +39 -0
- package/src/scenarios/mcp-2026-07-28-discover.test.ts +5 -3
- package/src/scenarios/mcp-cache-tenant-scope.test.ts +4 -2
- package/src/scenarios/mcp-current-auth-boundary.test.ts +5 -3
- package/src/scenarios/mcp-extension-opacity.test.ts +2 -1
- package/src/scenarios/mcp-mrtr-roundtrip.test.ts +4 -3
- package/src/scenarios/mcp-server-elicitation-bridge.test.ts +4 -2
- package/src/scenarios/mcp-server-prompt-roundtrip.test.ts +4 -2
- package/src/scenarios/mcp-server-resource-roundtrip.test.ts +4 -2
- package/src/scenarios/mcp-server-sampling-bridge.test.ts +4 -2
- package/src/scenarios/mcp-server-tool-roundtrip.test.ts +4 -2
- package/src/scenarios/mcp-server-untrusted-args.test.ts +5 -3
- package/src/scenarios/mcp-stateless-request.test.ts +6 -5
- package/src/scenarios/mcp-version-negotiation.test.ts +3 -3
- package/src/scenarios/workflow-chain-host-expansion.test.ts +95 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vendor.openwop.workflow-chain-sample",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"kind": "workflow-chain",
|
|
5
5
|
"description": "Reference workflow-chain pack proving the RFC 0013 manifest format works end-to-end across schema validation, registry indexing, and the reference expansion library. Two chains: a 1-node 'summarize text' chain showing literal parameter substitution, and a 2-node 'fetch-and-summarize' chain showing multi-node DAG composition with edge wiring and capability propagation. In-tree only (NOT published to packs.openwop.dev); the canonical proof that the chain-pack contract is implementable with real-world-shaped content.",
|
|
6
6
|
"author": "openwop project",
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
"description": "Drag-tile that expands to a single core.ai.callPrompt with the summarization system prompt + author-supplied length / tone parameters substituted in. Mirrors the 'Generate PRD' positive example from workflow-chain-packs.md §Examples — the canonical 1-node chain shape.",
|
|
19
19
|
"parameters": {
|
|
20
20
|
"type": "object",
|
|
21
|
-
"required": [
|
|
21
|
+
"required": [
|
|
22
|
+
"sourceText"
|
|
23
|
+
],
|
|
22
24
|
"properties": {
|
|
23
25
|
"sourceText": {
|
|
24
26
|
"type": "string",
|
|
@@ -26,7 +28,11 @@
|
|
|
26
28
|
},
|
|
27
29
|
"targetLength": {
|
|
28
30
|
"type": "string",
|
|
29
|
-
"enum": [
|
|
31
|
+
"enum": [
|
|
32
|
+
"one-sentence",
|
|
33
|
+
"one-paragraph",
|
|
34
|
+
"executive-summary"
|
|
35
|
+
],
|
|
30
36
|
"default": "one-paragraph",
|
|
31
37
|
"description": "How long the summary should be."
|
|
32
38
|
},
|
|
@@ -43,7 +49,10 @@
|
|
|
43
49
|
"id": "summarize-call",
|
|
44
50
|
"typeId": "core.ai.callPrompt",
|
|
45
51
|
"name": "Summarize",
|
|
46
|
-
"position": {
|
|
52
|
+
"position": {
|
|
53
|
+
"x": 0,
|
|
54
|
+
"y": 0
|
|
55
|
+
},
|
|
47
56
|
"config": {
|
|
48
57
|
"systemPrompt": "You are an expert summarizer. Produce a {{params.targetLength}} summary in a {{params.tone}} tone. Avoid lists unless the source uses them. Avoid hedging.\n\nSource text:\n{{params.sourceText}}",
|
|
49
58
|
"envelopeType": "summary.v1",
|
|
@@ -60,7 +69,9 @@
|
|
|
60
69
|
"description": "The summarized text from the AI call's envelope payload."
|
|
61
70
|
}
|
|
62
71
|
},
|
|
63
|
-
"capabilities": [
|
|
72
|
+
"capabilities": [
|
|
73
|
+
"cacheable"
|
|
74
|
+
]
|
|
64
75
|
},
|
|
65
76
|
{
|
|
66
77
|
"chainId": "vendor.openwop.workflow-chain-sample.fetch-and-summarize",
|
|
@@ -69,7 +80,9 @@
|
|
|
69
80
|
"description": "Drag-tile that expands to a two-node DAG: core.openwop.http.get fetches the URL, then core.ai.callPrompt summarizes the response body. Demonstrates multi-node chain composition with edge wiring, parameter substitution across multiple nodes, and capability propagation (side-effectful from the HTTP fetch propagates to both expanded nodes per spec §Capability propagation).",
|
|
70
81
|
"parameters": {
|
|
71
82
|
"type": "object",
|
|
72
|
-
"required": [
|
|
83
|
+
"required": [
|
|
84
|
+
"url"
|
|
85
|
+
],
|
|
73
86
|
"properties": {
|
|
74
87
|
"url": {
|
|
75
88
|
"type": "string",
|
|
@@ -78,7 +91,11 @@
|
|
|
78
91
|
},
|
|
79
92
|
"targetLength": {
|
|
80
93
|
"type": "string",
|
|
81
|
-
"enum": [
|
|
94
|
+
"enum": [
|
|
95
|
+
"one-sentence",
|
|
96
|
+
"one-paragraph",
|
|
97
|
+
"executive-summary"
|
|
98
|
+
],
|
|
82
99
|
"default": "one-paragraph"
|
|
83
100
|
}
|
|
84
101
|
}
|
|
@@ -89,7 +106,10 @@
|
|
|
89
106
|
"id": "fetch",
|
|
90
107
|
"typeId": "core.openwop.http.get",
|
|
91
108
|
"name": "Fetch URL",
|
|
92
|
-
"position": {
|
|
109
|
+
"position": {
|
|
110
|
+
"x": 0,
|
|
111
|
+
"y": 0
|
|
112
|
+
},
|
|
93
113
|
"config": {
|
|
94
114
|
"url": "{{params.url}}",
|
|
95
115
|
"timeoutMs": 30000
|
|
@@ -100,7 +120,10 @@
|
|
|
100
120
|
"id": "summarize",
|
|
101
121
|
"typeId": "core.ai.callPrompt",
|
|
102
122
|
"name": "Summarize",
|
|
103
|
-
"position": {
|
|
123
|
+
"position": {
|
|
124
|
+
"x": 280,
|
|
125
|
+
"y": 0
|
|
126
|
+
},
|
|
104
127
|
"config": {
|
|
105
128
|
"systemPrompt": "Produce a {{params.targetLength}} summary of the fetched page content. Strip navigation, ads, and boilerplate. Focus on the article body.",
|
|
106
129
|
"envelopeType": "summary.v1",
|
|
@@ -110,7 +133,10 @@
|
|
|
110
133
|
}
|
|
111
134
|
],
|
|
112
135
|
"edges": [
|
|
113
|
-
{
|
|
136
|
+
{
|
|
137
|
+
"from": "fetch.body",
|
|
138
|
+
"to": "summarize.sourceText"
|
|
139
|
+
}
|
|
114
140
|
]
|
|
115
141
|
},
|
|
116
142
|
"outputs": {
|
|
@@ -123,7 +149,173 @@
|
|
|
123
149
|
"description": "Echo of the input URL for downstream reference."
|
|
124
150
|
}
|
|
125
151
|
},
|
|
126
|
-
"capabilities": [
|
|
152
|
+
"capabilities": [
|
|
153
|
+
"side-effectful"
|
|
154
|
+
]
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"chainId": "vendor.openwop.workflow-chain-sample.reserve-and-notify",
|
|
158
|
+
"version": "1.0.0",
|
|
159
|
+
"label": "Reserve & Notify (compensating)",
|
|
160
|
+
"description": "RFC 0157 fixture — a two-node chain whose first node declares an RFC 0151 §B inverse action (`compensation`, retry-bounded, `inputMapping` derived from the recorded reserve output + a {{params.*}} literal) and whose second node declares `irreversibleEffect: true` (RFC 0151 UQ4). Carries NO chain-level policy, so it is acceptable on any host — the node declaration describes an inverse action; only a policy requests an unwind. The host-expansion witness asserts both declarations survive verbatim onto the expanded nodes (params substituted, node-id refs re-prefixed) and that neither is silently dropped by a node-rebuilding allowlist.",
|
|
161
|
+
"parameters": {
|
|
162
|
+
"type": "object",
|
|
163
|
+
"required": [
|
|
164
|
+
"reservationUrl"
|
|
165
|
+
],
|
|
166
|
+
"properties": {
|
|
167
|
+
"reservationUrl": {
|
|
168
|
+
"type": "string",
|
|
169
|
+
"format": "uri",
|
|
170
|
+
"description": "Reservation endpoint. Surfaced via {{params.reservationUrl}} in the reserve node config and its inverse action's inputMapping."
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
"dag": {
|
|
175
|
+
"nodes": [
|
|
176
|
+
{
|
|
177
|
+
"id": "reserve",
|
|
178
|
+
"typeId": "core.openwop.http.get",
|
|
179
|
+
"name": "Reserve",
|
|
180
|
+
"position": {
|
|
181
|
+
"x": 0,
|
|
182
|
+
"y": 0
|
|
183
|
+
},
|
|
184
|
+
"config": {
|
|
185
|
+
"url": "{{params.reservationUrl}}",
|
|
186
|
+
"timeoutMs": 30000
|
|
187
|
+
},
|
|
188
|
+
"inputs": {},
|
|
189
|
+
"compensation": {
|
|
190
|
+
"nodeTypeId": "core.openwop.http.get",
|
|
191
|
+
"inputMapping": {
|
|
192
|
+
"url": "{{params.reservationUrl}}",
|
|
193
|
+
"reservationId": "${nodes.reserve.output.body}"
|
|
194
|
+
},
|
|
195
|
+
"retry": {
|
|
196
|
+
"maxAttempts": 3,
|
|
197
|
+
"backoffMs": 250
|
|
198
|
+
},
|
|
199
|
+
"requiresApproval": false
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"id": "notify",
|
|
204
|
+
"typeId": "core.ai.callPrompt",
|
|
205
|
+
"name": "Notify",
|
|
206
|
+
"position": {
|
|
207
|
+
"x": 280,
|
|
208
|
+
"y": 0
|
|
209
|
+
},
|
|
210
|
+
"config": {
|
|
211
|
+
"systemPrompt": "Write a one-line confirmation that the reservation at {{params.reservationUrl}} was placed.",
|
|
212
|
+
"envelopeType": "summary.v1",
|
|
213
|
+
"provider": "anthropic"
|
|
214
|
+
},
|
|
215
|
+
"inputs": {},
|
|
216
|
+
"irreversibleEffect": true
|
|
217
|
+
}
|
|
218
|
+
],
|
|
219
|
+
"edges": [
|
|
220
|
+
{
|
|
221
|
+
"from": "reserve",
|
|
222
|
+
"to": "notify"
|
|
223
|
+
}
|
|
224
|
+
]
|
|
225
|
+
},
|
|
226
|
+
"capabilities": [
|
|
227
|
+
"side-effectful"
|
|
228
|
+
]
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"chainId": "vendor.openwop.workflow-chain-sample.reserve-and-notify-with-policy",
|
|
232
|
+
"version": "1.0.0",
|
|
233
|
+
"label": "Reserve & Notify (compensating, with unwind policy)",
|
|
234
|
+
"description": "RFC 0157 fixture — the same two-node chain PLUS a chain-level compensation POLICY (a mirror of `compensation-policy.schema.json`). On a host that advertises `capabilities.compensation` the policy becomes the registered definition's `settings.compensation`; on a host that does NOT advertise it, expansion of this chain MUST be refused with `capability_required` (`compensation.md` §\"Workflow policy\") rather than accepted silently. The host-expansion witness accepts either outcome and asserts it matches the advert.",
|
|
235
|
+
"parameters": {
|
|
236
|
+
"type": "object",
|
|
237
|
+
"required": [
|
|
238
|
+
"reservationUrl"
|
|
239
|
+
],
|
|
240
|
+
"properties": {
|
|
241
|
+
"reservationUrl": {
|
|
242
|
+
"type": "string",
|
|
243
|
+
"format": "uri",
|
|
244
|
+
"description": "Reservation endpoint. Surfaced via {{params.reservationUrl}} in the reserve node config and its inverse action's inputMapping."
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
"dag": {
|
|
249
|
+
"nodes": [
|
|
250
|
+
{
|
|
251
|
+
"id": "reserve",
|
|
252
|
+
"typeId": "core.openwop.http.get",
|
|
253
|
+
"name": "Reserve",
|
|
254
|
+
"position": {
|
|
255
|
+
"x": 0,
|
|
256
|
+
"y": 0
|
|
257
|
+
},
|
|
258
|
+
"config": {
|
|
259
|
+
"url": "{{params.reservationUrl}}",
|
|
260
|
+
"timeoutMs": 30000
|
|
261
|
+
},
|
|
262
|
+
"inputs": {},
|
|
263
|
+
"compensation": {
|
|
264
|
+
"nodeTypeId": "core.openwop.http.get",
|
|
265
|
+
"inputMapping": {
|
|
266
|
+
"url": "{{params.reservationUrl}}",
|
|
267
|
+
"reservationId": "${nodes.reserve.output.body}"
|
|
268
|
+
},
|
|
269
|
+
"retry": {
|
|
270
|
+
"maxAttempts": 3,
|
|
271
|
+
"backoffMs": 250
|
|
272
|
+
},
|
|
273
|
+
"requiresApproval": false
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
"id": "notify",
|
|
278
|
+
"typeId": "core.ai.callPrompt",
|
|
279
|
+
"name": "Notify",
|
|
280
|
+
"position": {
|
|
281
|
+
"x": 280,
|
|
282
|
+
"y": 0
|
|
283
|
+
},
|
|
284
|
+
"config": {
|
|
285
|
+
"systemPrompt": "Write a one-line confirmation that the reservation at {{params.reservationUrl}} was placed.",
|
|
286
|
+
"envelopeType": "summary.v1",
|
|
287
|
+
"provider": "anthropic"
|
|
288
|
+
},
|
|
289
|
+
"inputs": {},
|
|
290
|
+
"irreversibleEffect": true
|
|
291
|
+
}
|
|
292
|
+
],
|
|
293
|
+
"edges": [
|
|
294
|
+
{
|
|
295
|
+
"from": "reserve",
|
|
296
|
+
"to": "notify"
|
|
297
|
+
}
|
|
298
|
+
]
|
|
299
|
+
},
|
|
300
|
+
"capabilities": [
|
|
301
|
+
"side-effectful"
|
|
302
|
+
],
|
|
303
|
+
"compensation": {
|
|
304
|
+
"profileVersion": "1",
|
|
305
|
+
"orderingModel": "reverse-completion",
|
|
306
|
+
"triggers": [
|
|
307
|
+
"node-failure",
|
|
308
|
+
"run-cancel"
|
|
309
|
+
],
|
|
310
|
+
"retry": {
|
|
311
|
+
"maxAttempts": 3,
|
|
312
|
+
"backoffMs": 250
|
|
313
|
+
},
|
|
314
|
+
"timeoutMs": 30000,
|
|
315
|
+
"exhaustedDisposition": "record-outcome",
|
|
316
|
+
"approvalScope": "declared",
|
|
317
|
+
"onParentCancel": "continue"
|
|
318
|
+
}
|
|
127
319
|
}
|
|
128
320
|
]
|
|
129
321
|
}
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of this vendored schemas/ copy. See conformance/README.md \u00a7\"Resolving the contract\". Compare against the stamp in your installed @openwop/openwop-conformance to detect a stale hand-copied contract.",
|
|
3
|
-
"suiteVersion": "1.
|
|
4
|
-
"corpusCommit": "
|
|
3
|
+
"suiteVersion": "1.134.0",
|
|
4
|
+
"corpusCommit": "fbb82fa8ea94c25358a63fda98bd49fdb528693a"
|
|
5
5
|
}
|
package/src/lib/driver.ts
CHANGED
|
@@ -35,7 +35,9 @@ class OpenWOPDriver {
|
|
|
35
35
|
init: OpenWOPRequestInit = {},
|
|
36
36
|
): Promise<OpenWOPResponse> {
|
|
37
37
|
const env = loadEnv();
|
|
38
|
-
|
|
38
|
+
// An absolute URL is used as-is (a host may advertise its MCP server mount or
|
|
39
|
+
// an A2A endpoint on another origin); a path is joined to the base URL.
|
|
40
|
+
const url = /^https?:\/\//i.test(path) ? path : `${env.baseUrl}${path}`;
|
|
39
41
|
|
|
40
42
|
const headers: Record<string, string> = {
|
|
41
43
|
Accept: 'application/json',
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a host mounts its MCP server (RFC 0153 §B / `mcp-integration.md`
|
|
3
|
+
* §"MCP server composition").
|
|
4
|
+
*
|
|
5
|
+
* The reference boots mount it at the sample-seam path
|
|
6
|
+
* `/v1/host/sample/mcp`, and until suite 1.134.0 every server-side MCP
|
|
7
|
+
* scenario hard-coded that path. The v1.0 discovery baseline has always let a
|
|
8
|
+
* host SAY where its mount is — `capabilities.mcp.serverUrls: string[]`
|
|
9
|
+
* (`mcp-discoverability.test.ts`) — and the first deployed host to advertise
|
|
10
|
+
* `mcp-2026-07-28` mounted at `/v1/host/openwop-app/mcp`, advertised exactly
|
|
11
|
+
* that, and watched ten `server/discover` legs 404 against the sample path and
|
|
12
|
+
* return early (S25, openwop-app H38, 2026-08-17). So: read the advert first,
|
|
13
|
+
* fall back to the sample path, and never treat "the mount answered 404" as a
|
|
14
|
+
* pass — that is `seamAbsent` (the host advertised a server mount it does not
|
|
15
|
+
* serve where it said).
|
|
16
|
+
*/
|
|
17
|
+
import { driver } from './driver.js';
|
|
18
|
+
import { capabilityFamily } from './discovery-capabilities.js';
|
|
19
|
+
|
|
20
|
+
export const SAMPLE_MCP_MOUNT = '/v1/host/sample/mcp';
|
|
21
|
+
|
|
22
|
+
interface McpAdvert {
|
|
23
|
+
readonly supported?: boolean;
|
|
24
|
+
readonly serverUrls?: unknown;
|
|
25
|
+
readonly serverMount?: { readonly supported?: boolean };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The MCP server mount to POST JSON-RPC to: `capabilities.mcp.serverUrls[0]`
|
|
30
|
+
* when the host advertises one (a path is joined to the base URL by the driver;
|
|
31
|
+
* an absolute URL is used as-is), else the sample-seam path.
|
|
32
|
+
*/
|
|
33
|
+
export async function mcpServerMount(): Promise<string> {
|
|
34
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
35
|
+
const mcp = capabilityFamily<McpAdvert>(disco.json, 'mcp');
|
|
36
|
+
const urls = Array.isArray(mcp?.serverUrls) ? (mcp?.serverUrls as unknown[]) : [];
|
|
37
|
+
const first = urls.find((u): u is string => typeof u === 'string' && u.length > 0);
|
|
38
|
+
return first ?? SAMPLE_MCP_MOUNT;
|
|
39
|
+
}
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
|
|
26
26
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
27
27
|
import { driver } from '../lib/driver.js';
|
|
28
|
+
import { seamAbsent } from '../lib/soft-skip.js';
|
|
29
|
+
import { mcpServerMount } from '../lib/mcp-mount.js';
|
|
28
30
|
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
29
31
|
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
30
32
|
import { McpFakeServer, MCP_ERR } from '../lib/mcp-fake-server.js';
|
|
@@ -170,7 +172,7 @@ async function claimsCurrent(): Promise<boolean> {
|
|
|
170
172
|
}
|
|
171
173
|
async function hostRpc(method: string, params: Record<string, unknown>, headers: Record<string, string>) {
|
|
172
174
|
// The reference host mounts its MCP server at /v1/host/sample/mcp (mcp-server-* scenarios).
|
|
173
|
-
const res = await driver.post(
|
|
175
|
+
const res = await driver.post(await mcpServerMount(), { jsonrpc: '2.0', id: 1, method, params }, { headers });
|
|
174
176
|
return { status: res.status, body: res.json as { result?: Record<string, unknown>; error?: { code: number; data?: Record<string, unknown> } } };
|
|
175
177
|
}
|
|
176
178
|
|
|
@@ -179,7 +181,7 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §B — host as MCP ser
|
|
|
179
181
|
if (!behaviorGate(PROFILE, await claimsCurrent())) return;
|
|
180
182
|
const caps = (await mcp())!;
|
|
181
183
|
const r = await hostRpc('server/discover', { _meta: { [META_V]: '2026-07-28', [META_C]: {} } }, { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'server/discover' });
|
|
182
|
-
if (r.status === 404 || r.status === 403) return
|
|
184
|
+
if (r.status === 404 || r.status === 403) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${r.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
|
|
183
185
|
expect(r.status, driver.describe('mcp-integration.md §B', 'server/discover is a server MUST under 2026-07-28')).toBe(200);
|
|
184
186
|
expect(r.body.result?.['resultType']).toBe('complete');
|
|
185
187
|
expect([...((r.body.result?.['supportedVersions'] as string[] | undefined) ?? [])].sort(), driver.describe('mcp-integration.md §B', 'server/discover.supportedVersions MUST equal capabilities.mcp.protocolVersions — two documents, one fact')).toEqual([...(caps.protocolVersions ?? [])].sort());
|
|
@@ -196,7 +198,7 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §B — host as MCP ser
|
|
|
196
198
|
// answered -32022 — the leg was testing an unstated precedence
|
|
197
199
|
// (mcp-integration.md §B: agreement is checked before selection).
|
|
198
200
|
const mismatch = await hostRpc('tools/list', { _meta: { [META_V]: '2025-06-18', [META_C]: {} } }, { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/list' });
|
|
199
|
-
if (mismatch.status === 404 || mismatch.status === 403) return;
|
|
201
|
+
if (mismatch.status === 404 || mismatch.status === 403) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${mismatch.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
|
|
200
202
|
expect(mismatch.status, driver.describe('mcp-integration.md §B', 'header ≠ body MUST be refused 400 (HeaderMismatchError -32020) — fail closed')).toBe(400);
|
|
201
203
|
expect(mismatch.body.error?.code).toBe(MCP_ERR.HEADER_MISMATCH);
|
|
202
204
|
const unsupported = await hostRpc('tools/list', { _meta: { [META_V]: '1999-01-01', [META_C]: {} } }, { 'MCP-Protocol-Version': '1999-01-01', 'Mcp-Method': 'tools/list' });
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
|
|
23
23
|
import { describe, it, expect } from 'vitest';
|
|
24
24
|
import { driver } from '../lib/driver.js';
|
|
25
|
+
import { seamAbsent } from '../lib/soft-skip.js';
|
|
26
|
+
import { mcpServerMount } from '../lib/mcp-mount.js';
|
|
25
27
|
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
26
28
|
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
27
29
|
|
|
@@ -38,7 +40,7 @@ async function claimsCurrent(): Promise<boolean> {
|
|
|
38
40
|
async function listAs(bearer?: string) {
|
|
39
41
|
const headers: Record<string, string> = { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/list' };
|
|
40
42
|
if (bearer) headers['authorization'] = `Bearer ${bearer}`;
|
|
41
|
-
const res = await driver.post(
|
|
43
|
+
const res = await driver.post(await mcpServerMount(), { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: { [META_V]: '2026-07-28', [META_C]: {} } } }, { headers });
|
|
42
44
|
return { status: res.status, body: res.json as { result?: { tools?: unknown[]; cacheScope?: string; ttlMs?: number } } };
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -46,7 +48,7 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §D — mcp-cache-tenan
|
|
|
46
48
|
it('a per-caller list is cacheScope private; a public list is byte-identical across callers', async () => {
|
|
47
49
|
if (!behaviorGate(PROFILE, await claimsCurrent())) return;
|
|
48
50
|
const mine = await listAs();
|
|
49
|
-
if (mine.status === 404 || mine.status === 403) return;
|
|
51
|
+
if (mine.status === 404 || mine.status === 403) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${mine.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
|
|
50
52
|
expect(['public', 'private'], driver.describe('mcp-integration.md §D', 'cacheScope MUST be present on tools/list')).toContain(mine.body.result?.cacheScope);
|
|
51
53
|
const other = process.env.OPENWOP_TEST_SECONDARY_API_KEY;
|
|
52
54
|
if (!other) {
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
|
|
19
19
|
import { describe, it, expect } from 'vitest';
|
|
20
20
|
import { driver } from '../lib/driver.js';
|
|
21
|
+
import { seamAbsent } from '../lib/soft-skip.js';
|
|
22
|
+
import { mcpServerMount } from '../lib/mcp-mount.js';
|
|
21
23
|
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
22
24
|
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
23
25
|
|
|
@@ -38,10 +40,10 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §E — mcp-current-aut
|
|
|
38
40
|
const { mcp, anon } = await disco();
|
|
39
41
|
const claims = mcp?.supported === true && (mcp.profiles ?? []).includes(PROFILE) && mcp.serverMount?.supported === true;
|
|
40
42
|
if (!behaviorGate(PROFILE, claims)) return;
|
|
41
|
-
const authed = await driver.post(
|
|
42
|
-
if (authed.status === 404 || authed.status === 403) return
|
|
43
|
+
const authed = await driver.post(await mcpServerMount(), REQ, { headers: HDR });
|
|
44
|
+
if (authed.status === 404 || authed.status === 403) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${authed.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
|
|
43
45
|
expect(authed.status, driver.describe('mcp-integration.md §E', 'the authenticated call MUST succeed at the same path, so a refusal below is not a wrong path')).toBe(200);
|
|
44
|
-
const anonymous = await driver.post(
|
|
46
|
+
const anonymous = await driver.post(await mcpServerMount(), REQ, { headers: HDR, authenticated: false });
|
|
45
47
|
if (anon?.supported === true) {
|
|
46
48
|
// Anonymous is permitted only through the RFC 0132 surface; a 200 here is that surface answering.
|
|
47
49
|
expect([200, 401, 403]).toContain(anonymous.status);
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { describe, it, expect } from 'vitest';
|
|
24
24
|
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
25
25
|
import { driver } from '../lib/driver.js';
|
|
26
|
+
import { mcpServerMount } from '../lib/mcp-mount.js';
|
|
26
27
|
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
27
28
|
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
28
29
|
import { getMcpFakeServer } from '../lib/mcp-fake-server.js';
|
|
@@ -66,7 +67,7 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §D — mcp-extension-o
|
|
|
66
67
|
const caps = await mcp();
|
|
67
68
|
const claims = caps?.supported === true && (caps.profiles ?? []).includes('mcp-2026-07-28') && caps.serverMount?.supported === true;
|
|
68
69
|
if (!behaviorGate('mcp-2026-07-28', claims)) return;
|
|
69
|
-
const res = await driver.post(
|
|
70
|
+
const res = await driver.post(await mcpServerMount(), {
|
|
70
71
|
jsonrpc: '2.0', id: 1, method: 'tools/list',
|
|
71
72
|
params: { _meta: { [META_V]: '2026-07-28', [META_C]: { extensions: { 'io.example/authority': { admin: true } } }, 'io.example/authority': { grantScopes: ['*'] } } },
|
|
72
73
|
}, { headers: { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/list' } });
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
import { describe, it, expect } from 'vitest';
|
|
34
34
|
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
35
35
|
import { driver } from '../lib/driver.js';
|
|
36
|
+
import { mcpServerMount } from '../lib/mcp-mount.js';
|
|
36
37
|
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
37
38
|
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
38
39
|
import { getMcpFakeServer } from '../lib/mcp-fake-server.js';
|
|
@@ -120,7 +121,7 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §C — mcp-mrtr-roundt
|
|
|
120
121
|
// not seam absence and not a host finding — fail loudly at the source.
|
|
121
122
|
expect(reg.status, driver.describe('host-sample-test-seams.md §"sample workflows"', `the registration fixture MUST be accepted; a 4xx means the fixture this leg posts is malformed (suite defect): ${JSON.stringify(reg.json).slice(0, 200)}`)).toBeLessThan(400);
|
|
122
123
|
const hdr = { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/call', 'Mcp-Name': TOOL };
|
|
123
|
-
const first = await driver.post(
|
|
124
|
+
const first = await driver.post(await mcpServerMount(), { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: TOOL, arguments: {}, _meta: { [META_V]: '2026-07-28', [META_C]: { elicitation: {} } } } }, { headers: hdr });
|
|
124
125
|
if (first.status === 404) return seamAbsent('host advertises an MCP server mount but /v1/host/sample/mcp answered 404');
|
|
125
126
|
const r1 = first.json as { result?: { resultType?: string; inputRequests?: Record<string, { method?: string }>; requestState?: string }; error?: { code: number } };
|
|
126
127
|
expect(r1.error, driver.describe('mcp-integration.md §C.2', `tools/call MUST NOT error: ${JSON.stringify(r1.error)}`)).toBeUndefined();
|
|
@@ -129,11 +130,11 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §C — mcp-mrtr-roundt
|
|
|
129
130
|
expect(key, driver.describe('mcp-integration.md §C.2', 'inputRequests MUST carry the elicitation')).toBeDefined();
|
|
130
131
|
expect(r1.result?.inputRequests?.[key!]?.method).toBe('elicitation/create');
|
|
131
132
|
expect(typeof r1.result?.requestState, driver.describe('mcp-integration.md §C.2', 'requestState MUST be present (opaque, integrity-protected, bound to principal/TTL/request/runId/interrupt token)')).toBe('string');
|
|
132
|
-
const retry = await driver.post(
|
|
133
|
+
const retry = await driver.post(await mcpServerMount(), { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: TOOL, arguments: {}, requestState: r1.result!.requestState, inputResponses: { [key!]: { action: 'accept', content: { name: 'Ada' } } }, _meta: { [META_V]: '2026-07-28', [META_C]: { elicitation: {} } } } }, { headers: hdr });
|
|
133
134
|
const r2 = retry.json as { result?: { resultType?: string }; error?: { code: number } };
|
|
134
135
|
expect(r2.error).toBeUndefined();
|
|
135
136
|
expect(['complete', 'input_required'], driver.describe('mcp-integration.md §C.2', 'the retry MUST resolve the interrupt (complete) or ask for the next pending input')).toContain(r2.result?.resultType);
|
|
136
|
-
const forged = await driver.post(
|
|
137
|
+
const forged = await driver.post(await mcpServerMount(), { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: TOOL, arguments: {}, requestState: 'forged', inputResponses: { [key!]: { action: 'accept', content: { name: 'Eve' } } }, _meta: { [META_V]: '2026-07-28', [META_C]: { elicitation: {} } } } }, { headers: hdr });
|
|
137
138
|
const r3 = forged.json as { result?: { resultType?: string }; error?: { code: number } };
|
|
138
139
|
expect(r3.error !== undefined || forged.status >= 400, driver.describe('mcp-integration.md §C.2', 'a requestState that fails integrity verification MUST be refused (upstream: attacker-controlled input)')).toBe(true);
|
|
139
140
|
});
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
|
|
14
14
|
import { describe, it, expect } from 'vitest';
|
|
15
15
|
import { driver } from '../lib/driver.js';
|
|
16
|
+
import { seamAbsent } from '../lib/soft-skip.js';
|
|
17
|
+
import { mcpServerMount } from '../lib/mcp-mount.js';
|
|
16
18
|
|
|
17
19
|
interface DiscoveryDoc {
|
|
18
20
|
capabilities?: Record<string, unknown>;
|
|
@@ -31,7 +33,7 @@ async function rpc(method: string, params?: Record<string, unknown>) {
|
|
|
31
33
|
const id = Math.floor(Math.random() * 1e6);
|
|
32
34
|
const req: Record<string, unknown> = { jsonrpc: '2.0', id, method };
|
|
33
35
|
if (params !== undefined) req.params = params;
|
|
34
|
-
const res = await driver.post(
|
|
36
|
+
const res = await driver.post(await mcpServerMount(), req);
|
|
35
37
|
return { status: res.status, body: res.json as { result?: unknown; error?: { code: number; message: string } } };
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -71,7 +73,7 @@ describe('mcp-server-elicitation-bridge: behavioral (RFC 0020 §A point 3)', ()
|
|
|
71
73
|
required: ['name'],
|
|
72
74
|
},
|
|
73
75
|
});
|
|
74
|
-
if (r.status === 404) return;
|
|
76
|
+
if (r.status === 404) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${r.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
|
|
75
77
|
expect(r.status, 'JSON-RPC envelope MUST 200').toBe(200);
|
|
76
78
|
const dispatched = !!r.body.result || (!!r.body.error && r.body.error.code !== -32601);
|
|
77
79
|
expect(
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { describe, it, expect } from 'vitest';
|
|
10
10
|
import { driver } from '../lib/driver.js';
|
|
11
|
+
import { seamAbsent } from '../lib/soft-skip.js';
|
|
12
|
+
import { mcpServerMount } from '../lib/mcp-mount.js';
|
|
11
13
|
|
|
12
14
|
interface DiscoveryDoc {
|
|
13
15
|
capabilities?: Record<string, unknown>;
|
|
@@ -26,7 +28,7 @@ async function rpc(method: string, params?: Record<string, unknown>) {
|
|
|
26
28
|
const id = Math.floor(Math.random() * 1e6);
|
|
27
29
|
const req: Record<string, unknown> = { jsonrpc: '2.0', id, method };
|
|
28
30
|
if (params !== undefined) req.params = params;
|
|
29
|
-
const res = await driver.post(
|
|
31
|
+
const res = await driver.post(await mcpServerMount(), req);
|
|
30
32
|
return { status: res.status, body: res.json as { result?: unknown; error?: { code: number; message: string } } };
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -65,7 +67,7 @@ describe('mcp-server-prompt-roundtrip: behavioral (RFC 0020)', () => {
|
|
|
65
67
|
if (!(await registerPromptWorkflow())) return;
|
|
66
68
|
|
|
67
69
|
const list = await rpc('prompts/list');
|
|
68
|
-
if (list.status === 404) return;
|
|
70
|
+
if (list.status === 404) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${list.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
|
|
69
71
|
const prompts = (list.body.result as { prompts?: Array<{ name: string }> } | undefined)?.prompts ?? [];
|
|
70
72
|
expect(
|
|
71
73
|
prompts.find((p) => p.name === PROMPT_NAME),
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
import { describe, it, expect } from 'vitest';
|
|
12
12
|
import { driver } from '../lib/driver.js';
|
|
13
|
+
import { seamAbsent } from '../lib/soft-skip.js';
|
|
14
|
+
import { mcpServerMount } from '../lib/mcp-mount.js';
|
|
13
15
|
|
|
14
16
|
interface DiscoveryDoc {
|
|
15
17
|
capabilities?: Record<string, unknown>;
|
|
@@ -28,7 +30,7 @@ async function rpc(method: string, params?: Record<string, unknown>) {
|
|
|
28
30
|
const id = Math.floor(Math.random() * 1e6);
|
|
29
31
|
const req: Record<string, unknown> = { jsonrpc: '2.0', id, method };
|
|
30
32
|
if (params !== undefined) req.params = params;
|
|
31
|
-
const res = await driver.post(
|
|
33
|
+
const res = await driver.post(await mcpServerMount(), req);
|
|
32
34
|
return { status: res.status, body: res.json as { result?: unknown; error?: { code: number; message: string } } };
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -67,7 +69,7 @@ describe('mcp-server-resource-roundtrip: behavioral (RFC 0020)', () => {
|
|
|
67
69
|
if (!(await registerResourceWorkflow())) return;
|
|
68
70
|
|
|
69
71
|
const list = await rpc('resources/list');
|
|
70
|
-
if (list.status === 404) return;
|
|
72
|
+
if (list.status === 404) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${list.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
|
|
71
73
|
const resources = (list.body.result as { resources?: Array<{ uri: string }> } | undefined)?.resources ?? [];
|
|
72
74
|
expect(
|
|
73
75
|
resources.find((r) => r.uri === RESOURCE_URI),
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
|
|
17
17
|
import { describe, it, expect } from 'vitest';
|
|
18
18
|
import { driver } from '../lib/driver.js';
|
|
19
|
+
import { seamAbsent } from '../lib/soft-skip.js';
|
|
20
|
+
import { mcpServerMount } from '../lib/mcp-mount.js';
|
|
19
21
|
|
|
20
22
|
interface DiscoveryDoc {
|
|
21
23
|
capabilities?: Record<string, unknown>;
|
|
@@ -34,7 +36,7 @@ async function rpc(method: string, params?: Record<string, unknown>) {
|
|
|
34
36
|
const id = Math.floor(Math.random() * 1e6);
|
|
35
37
|
const req: Record<string, unknown> = { jsonrpc: '2.0', id, method };
|
|
36
38
|
if (params !== undefined) req.params = params;
|
|
37
|
-
const res = await driver.post(
|
|
39
|
+
const res = await driver.post(await mcpServerMount(), req);
|
|
38
40
|
return { status: res.status, body: res.json as { result?: unknown; error?: { code: number; message: string } } };
|
|
39
41
|
}
|
|
40
42
|
|
|
@@ -70,7 +72,7 @@ describe('mcp-server-sampling-bridge: behavioral (RFC 0020 §A point 3)', () =>
|
|
|
70
72
|
messages: [{ role: 'user', content: { type: 'text', text: 'ping' } }],
|
|
71
73
|
maxTokens: 16,
|
|
72
74
|
});
|
|
73
|
-
if (r.status === 404) return;
|
|
75
|
+
if (r.status === 404) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${r.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
|
|
74
76
|
expect(r.status, 'JSON-RPC envelope MUST 200').toBe(200);
|
|
75
77
|
const dispatched = !!r.body.result || (!!r.body.error && r.body.error.code !== -32601);
|
|
76
78
|
expect(
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
import { describe, it, expect } from 'vitest';
|
|
16
16
|
import { driver } from '../lib/driver.js';
|
|
17
|
+
import { seamAbsent } from '../lib/soft-skip.js';
|
|
18
|
+
import { mcpServerMount } from '../lib/mcp-mount.js';
|
|
17
19
|
|
|
18
20
|
interface DiscoveryDoc {
|
|
19
21
|
capabilities?: Record<string, unknown>;
|
|
@@ -32,7 +34,7 @@ async function rpc(method: string, params?: Record<string, unknown>): Promise<{
|
|
|
32
34
|
const id = Math.floor(Math.random() * 1e6);
|
|
33
35
|
const req: Record<string, unknown> = { jsonrpc: '2.0', id, method };
|
|
34
36
|
if (params !== undefined) req.params = params;
|
|
35
|
-
const res = await driver.post(
|
|
37
|
+
const res = await driver.post(await mcpServerMount(), req);
|
|
36
38
|
return { status: res.status, body: res.json as { result?: unknown; error?: { code: number; message: string } } };
|
|
37
39
|
}
|
|
38
40
|
|
|
@@ -83,7 +85,7 @@ describe('mcp-server-tool-roundtrip: behavioral (RFC 0020 §A points 1-2)', () =
|
|
|
83
85
|
if (!registered) return; // host doesn't expose workflow registration
|
|
84
86
|
|
|
85
87
|
const list = await rpc('tools/list');
|
|
86
|
-
if (list.status === 404) return
|
|
88
|
+
if (list.status === 404) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${list.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
|
|
87
89
|
expect(list.status, 'tools/list MUST 200').toBe(200);
|
|
88
90
|
const tools = (list.body.result as { tools?: Array<{ name: string }> } | undefined)?.tools ?? [];
|
|
89
91
|
const found = tools.find((t) => t.name === TEST_TOOL_NAME);
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
import { describe, it, expect } from 'vitest';
|
|
14
14
|
import { driver } from '../lib/driver.js';
|
|
15
|
+
import { seamAbsent } from '../lib/soft-skip.js';
|
|
16
|
+
import { mcpServerMount } from '../lib/mcp-mount.js';
|
|
15
17
|
|
|
16
18
|
interface DiscoveryDoc {
|
|
17
19
|
capabilities?: Record<string, unknown>;
|
|
@@ -30,7 +32,7 @@ async function rpc(method: string, params?: Record<string, unknown>) {
|
|
|
30
32
|
const id = Math.floor(Math.random() * 1e6);
|
|
31
33
|
const req: Record<string, unknown> = { jsonrpc: '2.0', id, method };
|
|
32
34
|
if (params !== undefined) req.params = params;
|
|
33
|
-
const res = await driver.post(
|
|
35
|
+
const res = await driver.post(await mcpServerMount(), req);
|
|
34
36
|
return { status: res.status, body: res.json as { result?: unknown; error?: { code: number; message: string; data?: unknown } } };
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -77,7 +79,7 @@ describe('mcp-server-untrusted-args: behavioral (RFC 0020 §D)', () => {
|
|
|
77
79
|
name: TEST_TOOL_NAME,
|
|
78
80
|
arguments: { wrongField: 'no' },
|
|
79
81
|
});
|
|
80
|
-
if (r.status === 404) return;
|
|
82
|
+
if (r.status === 404) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${r.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
|
|
81
83
|
expect(r.status, 'JSON-RPC envelope MUST 200').toBe(200);
|
|
82
84
|
expect(
|
|
83
85
|
r.body.error?.code,
|
|
@@ -96,7 +98,7 @@ describe('mcp-server-untrusted-args: behavioral (RFC 0020 §D)', () => {
|
|
|
96
98
|
name: TEST_TOOL_NAME,
|
|
97
99
|
arguments: { text: 'hello' },
|
|
98
100
|
});
|
|
99
|
-
if (r.status === 404) return;
|
|
101
|
+
if (r.status === 404) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${r.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
|
|
100
102
|
expect(r.status).toBe(200);
|
|
101
103
|
if (r.body.error) {
|
|
102
104
|
expect(r.body.error.code, 'valid args MUST NOT trigger -32602').not.toBe(-32602);
|
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
|
|
21
21
|
import { describe, it, expect } from 'vitest';
|
|
22
22
|
import { driver } from '../lib/driver.js';
|
|
23
|
+
import { mcpServerMount } from '../lib/mcp-mount.js';
|
|
23
24
|
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
24
25
|
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
25
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
26
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
26
27
|
|
|
27
28
|
const PROFILE = 'mcp-2026-07-28';
|
|
28
29
|
const META_V = 'io.modelcontextprotocol/protocolVersion';
|
|
@@ -37,7 +38,7 @@ async function claimsCurrent(): Promise<boolean> {
|
|
|
37
38
|
}
|
|
38
39
|
async function list() {
|
|
39
40
|
const res = await driver.post(
|
|
40
|
-
|
|
41
|
+
await mcpServerMount(),
|
|
41
42
|
{ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: { [META_V]: '2026-07-28', [META_C]: {}, [META_I]: { name: 'openwop-conformance', version: 'suite' } } } },
|
|
42
43
|
{ headers: { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/list' } },
|
|
43
44
|
);
|
|
@@ -49,12 +50,12 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §B — mcp-header-body
|
|
|
49
50
|
if (!behaviorGate(PROFILE, await claimsCurrent())) return;
|
|
50
51
|
const meta = { [META_V]: '2026-07-28', [META_C]: {}, [META_I]: { name: 'openwop-conformance', version: 'suite' } };
|
|
51
52
|
// (a) Mcp-Method header disagrees with the JSON-RPC method
|
|
52
|
-
const m = await driver.post(
|
|
53
|
+
const m = await driver.post(await mcpServerMount(), { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: meta } }, { headers: { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'resources/list' } });
|
|
53
54
|
if (m.status === 404 || m.status === 403) return softSkip('blocked', `MCP server mount /v1/host/sample/mcp answered ${m.status}`);
|
|
54
55
|
expect(m.status, driver.describe('mcp-integration.md §B', 'Mcp-Method MUST equal the body method; disagreement MUST be refused 400 (mcp-header-body-consistent)')).toBe(400);
|
|
55
56
|
expect((m.json as { error?: { code?: number } }).error?.code, driver.describe('mcp-integration.md §B', 'the refusal is HeaderMismatchError -32020')).toBe(-32020);
|
|
56
57
|
// (b) Mcp-Name header disagrees with params.name on tools/call
|
|
57
|
-
const n = await driver.post(
|
|
58
|
+
const n = await driver.post(await mcpServerMount(), { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'echo', arguments: {}, _meta: meta } }, { headers: { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/call', 'Mcp-Name': 'not-echo' } });
|
|
58
59
|
expect(n.status, driver.describe('mcp-integration.md §B', 'Mcp-Name MUST equal params.name; disagreement MUST be refused 400 (mcp-header-body-consistent)')).toBe(400);
|
|
59
60
|
expect((n.json as { error?: { code?: number } }).error?.code, driver.describe('mcp-integration.md §B', 'the refusal is HeaderMismatchError -32020')).toBe(-32020);
|
|
60
61
|
});
|
|
@@ -64,7 +65,7 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §B — mcp-stateless-r
|
|
|
64
65
|
it('tools/list succeeds with no initialize and no session; result carries resultType + cache hints; two connections agree', async () => {
|
|
65
66
|
if (!behaviorGate(PROFILE, await claimsCurrent())) return;
|
|
66
67
|
const a = await list();
|
|
67
|
-
if (a.status === 404 || a.status === 403) return
|
|
68
|
+
if (a.status === 404 || a.status === 403) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${a.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
|
|
68
69
|
expect(a.status, driver.describe('mcp-integration.md §B', 'a core request MUST succeed without a prior initialize or a session header')).toBe(200);
|
|
69
70
|
expect(a.body.error, driver.describe('mcp-integration.md §B', `stateless tools/list MUST NOT error: ${JSON.stringify(a.body.error)}`)).toBeUndefined();
|
|
70
71
|
expect(a.body.result?.resultType, driver.describe('mcp-integration.md §B', 'every current-revision result carries resultType')).toBe('complete');
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { describe, it, expect } from 'vitest';
|
|
28
28
|
import { driver } from '../lib/driver.js';
|
|
29
|
+
import { seamAbsent } from '../lib/soft-skip.js';
|
|
29
30
|
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
30
31
|
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
31
32
|
import { getMcpFakeServer } from '../lib/mcp-fake-server.js';
|
|
@@ -123,8 +124,7 @@ describe('RFC 0153 §A/§B — MCP revision negotiation', () => {
|
|
|
123
124
|
const caps = await mcp();
|
|
124
125
|
server.reset();
|
|
125
126
|
const drive = await driver.post('/v1/host/sample/mcp/invoke', { serverUrl: server.endpoint() });
|
|
126
|
-
if (drive.status === 404 || drive.status === 403) return;
|
|
127
|
-
// Discovery is a promise about behavior. A host that negotiates a revision
|
|
127
|
+
if (drive.status === 404 || drive.status === 403) return seamAbsent(`host advertises mcp version negotiation but the invoke seam /v1/host/sample/mcp/invoke answered ${drive.status} — the host-as-client legs are unobservable (host-sample-test-seams.md)`);
|
|
128
128
|
// it never advertised has made its own discovery document unreliable, which
|
|
129
129
|
// is worse than advertising nothing — a consumer that read it made a
|
|
130
130
|
// decision on a fact that was not true.
|
|
@@ -149,7 +149,7 @@ describe('RFC 0153 §A/§B — MCP revision negotiation', () => {
|
|
|
149
149
|
serverUrl: server.endpoint(),
|
|
150
150
|
requestVersion: '1999-01-01',
|
|
151
151
|
});
|
|
152
|
-
if (drive.status === 404 || drive.status === 403) return;
|
|
152
|
+
if (drive.status === 404 || drive.status === 403) return seamAbsent(`host advertises mcp version negotiation but the invoke seam /v1/host/sample/mcp/invoke answered ${drive.status} — the host-as-client legs are unobservable (host-sample-test-seams.md)`);
|
|
153
153
|
expect(
|
|
154
154
|
drive.status >= 400,
|
|
155
155
|
driver.describe('RFCS/0153 §B', 'an unsupported revision MUST fail rather than silently proceed'),
|
|
@@ -45,7 +45,10 @@ import { driver } from '../lib/driver.js';
|
|
|
45
45
|
import { loadEnv } from '../lib/env.js';
|
|
46
46
|
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
47
47
|
import { FIXTURES_DIR } from '../lib/paths.js';
|
|
48
|
-
import { expandChain, type WorkflowChain } from '../lib/workflow-chain-expansion.js';
|
|
48
|
+
import { expandChain, expandChainWithCompensation, type WorkflowChain, type WorkflowChainWithCompensation } from '../lib/workflow-chain-expansion.js';
|
|
49
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
50
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
51
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
49
52
|
|
|
50
53
|
const PROFILE = 'workflowChainPacks.hostExpansionSeam';
|
|
51
54
|
const EXPAND_PATH = '/v1/host/sample/workflow-chain:expand';
|
|
@@ -64,6 +67,9 @@ const PACK = JSON.parse(
|
|
|
64
67
|
const SAMPLE_PACK = PACK.name; // vendor.openwop.workflow-chain-sample
|
|
65
68
|
const CHAIN_1_NODE = 'vendor.openwop.workflow-chain-sample.summarize-text';
|
|
66
69
|
const CHAIN_2_NODE = 'vendor.openwop.workflow-chain-sample.fetch-and-summarize';
|
|
70
|
+
// RFC 0157 fixtures (pack 1.1.0, suite 1.133.0): node declarations + irreversibleEffect, without / with a chain policy.
|
|
71
|
+
const CHAIN_COMP = 'vendor.openwop.workflow-chain-sample.reserve-and-notify';
|
|
72
|
+
const CHAIN_COMP_POLICY = 'vendor.openwop.workflow-chain-sample.reserve-and-notify-with-policy';
|
|
67
73
|
|
|
68
74
|
function chainById(chainId: string): WorkflowChain {
|
|
69
75
|
const c = PACK.chains.find((x) => x.chainId === chainId);
|
|
@@ -154,7 +160,19 @@ describe('workflow-chain-host-expansion: live host wraps expansion algorithm cor
|
|
|
154
160
|
|
|
155
161
|
expect(body.chainId).toBe(CHAIN_1_NODE);
|
|
156
162
|
expect(body.packName).toBe(SAMPLE_PACK);
|
|
157
|
-
|
|
163
|
+
// The host serves the fixture from ITS conformance pin; the fixture gained two
|
|
164
|
+
// RFC 0157 chains at pack 1.1.0 (suite 1.133.0) without touching the two
|
|
165
|
+
// chains these legs expand, so an older pin is a note, not a mismatch — the
|
|
166
|
+
// expansions below still compare like for like. A NEWER pack than this suite
|
|
167
|
+
// knows is a mismatch (the suite cannot vouch for chains it has not seen).
|
|
168
|
+
expect(typeof body.packVersion).toBe('string');
|
|
169
|
+
if (body.packVersion !== PACK.version) {
|
|
170
|
+
const [hm, hn] = body.packVersion.split('.').map(Number);
|
|
171
|
+
const [fm, fn] = PACK.version.split('.').map(Number);
|
|
172
|
+
const older = hm! < fm! || (hm === fm && hn! < fn!);
|
|
173
|
+
expect(older, driver.describe('workflow-chain-host-expansion', `host bundles workflow-chain-sample ${body.packVersion}; this suite's fixture is ${PACK.version} — a host pack NEWER than the suite is unaccounted for`)).toBe(true);
|
|
174
|
+
softSkip('blocked', `host bundles workflow-chain-sample ${body.packVersion} (suite fixture ${PACK.version}) — the RFC 0157 chains are absent there until its conformance pin catches up`);
|
|
175
|
+
}
|
|
158
176
|
expect(typeof body.expansionId).toBe('string');
|
|
159
177
|
expect(body.expansionId.length).toBeGreaterThan(0);
|
|
160
178
|
|
|
@@ -250,4 +268,79 @@ describe('workflow-chain-host-expansion: live host wraps expansion algorithm cor
|
|
|
250
268
|
expect(res.status).toBe(422);
|
|
251
269
|
expect((res.json as { error: string }).error).toBe('invalid_request');
|
|
252
270
|
});
|
|
271
|
+
it('RFC 0157 — a compensating chain expands through the live path with `compensation` and `irreversibleEffect` carried verbatim onto the expanded nodes', async () => {
|
|
272
|
+
if (!behaviorGate(PROFILE, await isExpansionAdvertised())) return;
|
|
273
|
+
const parameters = { reservationUrl: 'https://example.com/reservations' };
|
|
274
|
+
const res = await driver.post(EXPAND_PATH, { packName: SAMPLE_PACK, chainId: CHAIN_COMP, parameters });
|
|
275
|
+
if (res.status === 404 && readErrorCode(res.json) === 'chain_not_found') {
|
|
276
|
+
// The host serves the bundled fixture from its PINNED conformance package;
|
|
277
|
+
// the RFC 0157 chains arrived with pack 1.1.0 (suite 1.133.0). Older pin ⇒
|
|
278
|
+
// the chain does not exist there yet — unobservable, not a failure.
|
|
279
|
+
return softSkip('blocked', `host's bundled workflow-chain-sample pack predates 1.1.0 (chain ${CHAIN_COMP} answered chain_not_found) — the RFC 0157 carry is unobservable until the host's conformance pin reaches 1.133.0`);
|
|
280
|
+
}
|
|
281
|
+
expect(res.status, driver.describe('workflow-chain-packs.md §"Compensation (RFC 0157)"', 'a chain whose nodes declare an inverse action / irreversibleEffect (no policy) is acceptable on ANY host — the declaration describes, only the policy requests an unwind')).toBe(200);
|
|
282
|
+
const body = res.json as unknown as {
|
|
283
|
+
expansionId: string;
|
|
284
|
+
nodes: Array<{ id: string; compensation?: unknown; irreversibleEffect?: boolean }>;
|
|
285
|
+
};
|
|
286
|
+
const expected = expandChainWithCompensation(chainById(CHAIN_COMP) as unknown as WorkflowChainWithCompensation, {
|
|
287
|
+
expansionId: body.expansionId,
|
|
288
|
+
params: parameters,
|
|
289
|
+
isTypeIdResolvable: () => true,
|
|
290
|
+
});
|
|
291
|
+
expect(body.nodes).toHaveLength(expected.nodes.length);
|
|
292
|
+
for (const ref of expected.nodes) {
|
|
293
|
+
const node = body.nodes.find((n) => n.id === ref.id);
|
|
294
|
+
expect(node, driver.describe('workflow-chain-packs.md §Expansion semantics', `expanded node ${ref.id} present`)).toBeDefined();
|
|
295
|
+
const refC = (ref as unknown as { compensation?: unknown }).compensation;
|
|
296
|
+
expect(
|
|
297
|
+
node?.compensation,
|
|
298
|
+
driver.describe('workflow-chain-packs.md §"Compensation (RFC 0157)" rules 5b/6b', `node ${ref.id}: the inverse-action declaration MUST survive expansion verbatim (params substituted, node-id refs re-prefixed) — a node-rebuilding allowlist that drops it silently loses the unwind`),
|
|
299
|
+
).toEqual(refC);
|
|
300
|
+
expect(
|
|
301
|
+
node?.irreversibleEffect,
|
|
302
|
+
driver.describe('workflow-chain-packs.md §"Compensation (RFC 0157)" rule 6c', `node ${ref.id}: irreversibleEffect MUST be copied unchanged`),
|
|
303
|
+
).toEqual((ref as unknown as { irreversibleEffect?: boolean }).irreversibleEffect);
|
|
304
|
+
}
|
|
305
|
+
// Non-vacuity: the fixture DOES declare both, so at least one node of each kind was compared.
|
|
306
|
+
expect(expected.nodes.some((n) => (n as unknown as { compensation?: unknown }).compensation !== undefined)).toBe(true);
|
|
307
|
+
expect(expected.nodes.some((n) => (n as unknown as { irreversibleEffect?: boolean }).irreversibleEffect === true)).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('RFC 0157 — a chain carrying an unwind POLICY is refused with capability_required on a host that does not advertise compensation, and expands on one that does', async () => {
|
|
311
|
+
if (!behaviorGate(PROFILE, await isExpansionAdvertised())) return;
|
|
312
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
313
|
+
const advertises = capabilityFamily<{ supported?: boolean }>(disco.json, 'compensation')?.supported === true;
|
|
314
|
+
const parameters = { reservationUrl: 'https://example.com/reservations' };
|
|
315
|
+
const res = await driver.post(EXPAND_PATH, { packName: SAMPLE_PACK, chainId: CHAIN_COMP_POLICY, parameters });
|
|
316
|
+
if (res.status === 404 && readErrorCode(res.json) === 'chain_not_found') {
|
|
317
|
+
return softSkip('blocked', `host's bundled workflow-chain-sample pack predates 1.1.0 (chain ${CHAIN_COMP_POLICY} answered chain_not_found) — unobservable until the host's conformance pin reaches 1.133.0`);
|
|
318
|
+
}
|
|
319
|
+
if (!advertises) {
|
|
320
|
+
// compensation.md §"Workflow policy": a host that does NOT advertise the family
|
|
321
|
+
// MUST refuse a chain carrying a policy rather than accept a promise it will
|
|
322
|
+
// never honour. Accepting silently is the advertise-and-opt-out failure with
|
|
323
|
+
// the sign flipped.
|
|
324
|
+
expect(res.status >= 400, driver.describe('compensation.md §"Workflow policy"', 'a non-advertising host MUST refuse a chain carrying `compensation` (policy) — 4xx, not 200')).toBe(true);
|
|
325
|
+
expect(readErrorCode(res.json), driver.describe('capabilities.md §"Unsupported capability — refusal contract"', '`capability_required`')).toBe('capability_required');
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
expect(res.status, driver.describe('workflow-chain-packs.md §"Compensation (RFC 0157)"', 'an advertising host expands the policy-carrying chain')).toBe(200);
|
|
329
|
+
const body = res.json as unknown as { expansionId: string; nodes: unknown[]; settings?: { compensation?: unknown } };
|
|
330
|
+
const expected = expandChainWithCompensation(chainById(CHAIN_COMP_POLICY) as unknown as WorkflowChainWithCompensation, {
|
|
331
|
+
expansionId: body.expansionId,
|
|
332
|
+
params: parameters,
|
|
333
|
+
isTypeIdResolvable: () => true,
|
|
334
|
+
});
|
|
335
|
+
expect(body.nodes).toHaveLength(expected.nodes.length);
|
|
336
|
+
// The policy's destination is the registered definition's settings.compensation
|
|
337
|
+
// (rule 9b). This seam returns the expanded fragment; whether it echoes
|
|
338
|
+
// `settings` is host-optional, so the policy carry is asserted when visible
|
|
339
|
+
// and noted when not — never assumed.
|
|
340
|
+
if (body.settings?.compensation !== undefined) {
|
|
341
|
+
expect(body.settings.compensation, driver.describe('workflow-chain-packs.md §"Compensation (RFC 0157)" rule 9b', 'the chain policy becomes settings.compensation, copied verbatim')).toEqual(expected.settingsCompensation);
|
|
342
|
+
} else {
|
|
343
|
+
softSkip('blocked', 'the expand seam does not echo `settings`, so the policy → settings.compensation carry (rule 9b) is unobservable through it; the node carry above was witnessed');
|
|
344
|
+
}
|
|
345
|
+
});
|
|
253
346
|
});
|