@openwop/openwop-conformance 1.106.0 → 1.123.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/README.md +3 -3
- package/coverage.md +6 -0
- package/dist/cli.js +89 -45
- package/dist/lib/certification-bundle-verify.js +274 -0
- package/dist/lib/profiles.js +102 -19
- package/dist/lib/requirement-ledger.js +182 -0
- package/dist/lib/requirement-registry.js +110 -0
- package/dist/lib/scenario-disposition.js +249 -0
- package/dist/lib/soft-skip.js +82 -0
- package/fixtures/conformance-a2a-task-roundtrip.json +1 -1
- package/fixtures.md +1 -1
- package/package.json +1 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/schemas/README.md +1 -0
- package/schemas/capabilities.schema.json +2 -2
- package/schemas/compensation-policy.schema.json +64 -0
- package/schemas/run-snapshot.schema.json +5 -0
- package/schemas/workflow-chain-pack-manifest.schema.json +125 -0
- package/schemas/workflow-definition.schema.json +4 -0
- package/src/cli.ts +99 -45
- package/src/lib/a2a-fake-peer.ts +290 -110
- package/src/lib/certification-bundle-verify.ts +356 -0
- package/src/lib/llm-cache-key-recipe.ts +6 -5
- package/src/lib/mcp-fake-server.ts +235 -72
- package/src/lib/profiles.ts +132 -19
- package/src/lib/requirement-ledger.ts +98 -1
- package/src/lib/requirement-registry.ts +47 -4
- package/src/lib/scenario-disposition.ts +291 -0
- package/src/lib/soft-skip.ts +89 -0
- package/src/lib/workflow-chain-expansion.ts +189 -0
- package/src/scenarios/a2a-1-0-agent-card.test.ts +179 -0
- package/src/scenarios/a2a-1-0-task-roundtrip.test.ts +109 -0
- package/src/scenarios/a2a-card-runtime-consistency.test.ts +167 -0
- package/src/scenarios/a2a-peer-authority.test.ts +99 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +18 -6
- package/src/scenarios/agent-loop-iteration-monotonic.test.ts +3 -2
- package/src/scenarios/agent-loop-stateful-resume.test.ts +3 -2
- package/src/scenarios/agent-loop-version5-shape.test.ts +2 -1
- package/src/scenarios/agent-loop-workspace-snapshot.test.ts +4 -3
- package/src/scenarios/approval-gate-flow.test.ts +5 -4
- package/src/scenarios/artifact-type-store-without-render.test.ts +2 -1
- package/src/scenarios/auth-scim-profile.test.ts +5 -4
- package/src/scenarios/authorization-fail-closed.test.ts +4 -3
- package/src/scenarios/certification-bundle-non-vacuous.test.ts +249 -0
- package/src/scenarios/certification-bundle-redaction.test.ts +182 -0
- package/src/scenarios/certification-floor-enforcement.test.ts +30 -6
- package/src/scenarios/chain-compensation-expansion.test.ts +243 -0
- package/src/scenarios/commitment-fired.test.ts +3 -2
- package/src/scenarios/compensation-behavior.test.ts +63 -0
- package/src/scenarios/compensation-profile.test.ts +144 -1
- package/src/scenarios/conformance-advertised-seam-required.test.ts +118 -0
- package/src/scenarios/conformance-execution-witness.test.ts +104 -0
- package/src/scenarios/core-manifest-and-extension-registry.test.ts +26 -0
- package/src/scenarios/credential-payload-redaction.test.ts +4 -3
- package/src/scenarios/credentials-capability-shape.test.ts +4 -3
- package/src/scenarios/cross-engine-append-behavior.test.ts +5 -4
- package/src/scenarios/cross-engine-append-ordering.test.ts +3 -2
- package/src/scenarios/cross-host-ancestry-endpoint.test.ts +5 -4
- package/src/scenarios/cross-host-causation-shape.test.ts +3 -2
- package/src/scenarios/cross-workspace-isolation.test.ts +4 -3
- package/src/scenarios/deadletter-retry-exhaustion.test.ts +5 -4
- package/src/scenarios/distillation-index-roundtrip.test.ts +3 -2
- package/src/scenarios/distillation-secret-carryforward.test.ts +3 -2
- package/src/scenarios/distillation-shape.test.ts +2 -1
- package/src/scenarios/distillation-stable-archive.test.ts +4 -3
- package/src/scenarios/distillation-token-budget.test.ts +4 -3
- package/src/scenarios/feedback-correction-redaction.test.ts +4 -3
- package/src/scenarios/feedback-cross-tenant-isolation.test.ts +4 -3
- package/src/scenarios/feedback-fork-not-copied.test.ts +6 -5
- package/src/scenarios/feedback-on-terminal-run.test.ts +4 -3
- package/src/scenarios/feedback-record-and-list.test.ts +4 -3
- package/src/scenarios/feedback-unsupported-501.test.ts +3 -2
- package/src/scenarios/fixtures-valid.test.ts +16 -18
- package/src/scenarios/heartbeat-capability-shape.test.ts +2 -1
- package/src/scenarios/heartbeat-fires-once-per-tick.test.ts +4 -3
- package/src/scenarios/heartbeat-idempotent-no-spam.test.ts +6 -5
- package/src/scenarios/heartbeat-runtime-bound.test.ts +4 -3
- package/src/scenarios/mcp-2026-07-28-discover.test.ts +207 -0
- package/src/scenarios/mcp-cache-tenant-scope.test.ts +66 -0
- package/src/scenarios/mcp-current-auth-boundary.test.ts +55 -0
- package/src/scenarios/mcp-extension-opacity.test.ts +79 -0
- package/src/scenarios/mcp-mrtr-roundtrip.test.ts +140 -0
- package/src/scenarios/mcp-stateless-request.test.ts +77 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +4 -3
- package/src/scenarios/mcp-toolcall-redaction.test.ts +2 -1
- package/src/scenarios/memory-attribution-emits-on-write.test.ts +6 -5
- package/src/scenarios/memory-attribution-no-content.test.ts +4 -3
- package/src/scenarios/memory-attribution-replay-stable.test.ts +6 -5
- package/src/scenarios/memory-attribution-tenant-scoped.test.ts +4 -3
- package/src/scenarios/memory-compaction-event-emitted.test.ts +3 -2
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +6 -5
- package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +4 -3
- package/src/scenarios/memory-consolidation-idempotent.test.ts +4 -3
- package/src/scenarios/memory-injection-budget.test.ts +4 -3
- package/src/scenarios/metric-emission.test.ts +4 -3
- package/src/scenarios/multi-agent-confidence-escalation.test.ts +5 -4
- package/src/scenarios/multi-agent-handoff-state-machine.test.ts +4 -3
- package/src/scenarios/multi-agent-memory-lifecycle.test.ts +5 -4
- package/src/scenarios/multi-party-conversation-behavioral.test.ts +3 -2
- package/src/scenarios/multi-region-idempotency-behavior.test.ts +7 -6
- package/src/scenarios/multi-region-idempotency.test.ts +4 -3
- package/src/scenarios/normative-example-extraction.test.ts +242 -0
- package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +4 -3
- package/src/scenarios/oauth-capability-shape.test.ts +4 -3
- package/src/scenarios/oauth-connector-redaction.test.ts +4 -3
- package/src/scenarios/openapi-asyncapi-sdk-parity.test.ts +309 -0
- package/src/scenarios/org-position-no-authority-escalation.test.ts +2 -1
- package/src/scenarios/otel-emission-grpc.test.ts +4 -3
- package/src/scenarios/otel-emission.test.ts +4 -3
- package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +5 -4
- package/src/scenarios/otel-trace-propagation.test.ts +4 -3
- package/src/scenarios/pack-registry-isolation.test.ts +4 -3
- package/src/scenarios/pack-registry-publish.test.ts +65 -3
- package/src/scenarios/pack-registry.test.ts +22 -0
- package/src/scenarios/profile-discovery-core-alias.test.ts +137 -0
- package/src/scenarios/profileDerivation.test.ts +2 -1
- package/src/scenarios/rate-limit-envelope.test.ts +2 -1
- package/src/scenarios/registry-public.test.ts +6 -5
- package/src/scenarios/replay-divergence-at-refusal.test.ts +9 -8
- package/src/scenarios/replay-llm-cache-key-portable.test.ts +19 -13
- package/src/scenarios/replay-llm-cache-key.test.ts +120 -47
- package/src/scenarios/replay-observable-sequence-determinism.test.ts +5 -4
- package/src/scenarios/replay-side-effect-suppression.test.ts +20 -1
- package/src/scenarios/requirement-ledger.test.ts +29 -6
- package/src/scenarios/runner-ledger.test.ts +279 -0
- package/src/scenarios/runtime-requires-install-gate.test.ts +6 -5
- package/src/scenarios/sandbox-memory-cap.test.ts +3 -2
- package/src/scenarios/sandbox-mvp-behavior.test.ts +11 -10
- package/src/scenarios/sandbox-no-host-fs-escape.test.ts +2 -1
- package/src/scenarios/sandbox-timeout-cap.test.ts +3 -2
- package/src/scenarios/stream-text-fixture.test.ts +5 -4
- package/src/scenarios/subrun-approval-fail-closed.test.ts +3 -2
- package/src/scenarios/subrun-approval-gate.test.ts +4 -3
- package/src/scenarios/subrun-attestation-shape.test.ts +2 -1
- package/src/scenarios/subrun-checksum-stable.test.ts +4 -3
- package/src/scenarios/test-seam-unauthenticated.test.ts +61 -11
- package/src/scenarios/tool-hooks-authorization-fail-closed.test.ts +3 -2
- package/src/scenarios/tool-hooks-content-free.test.ts +3 -2
- package/src/scenarios/tool-hooks-rate-limit.test.ts +3 -2
- package/src/scenarios/tool-hooks-secret-redaction.test.ts +3 -2
- package/src/scenarios/tool-hooks-shape.test.ts +2 -1
- package/src/scenarios/trigger-bridge-delivery.test.ts +3 -2
- package/src/scenarios/version-fold.test.ts +5 -4
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +5 -4
- package/src/scenarios/wasm-pack-invoke-completed.test.ts +3 -2
- package/src/scenarios/wasm-pack-invoke-suspended.test.ts +3 -2
- package/src/scenarios/wasm-pack-load.test.ts +4 -3
- package/src/scenarios/wasm-pack-memory-cap.test.ts +4 -3
- package/src/scenarios/wasm-pack-replay-determinism.test.ts +3 -2
- package/src/scenarios/webhook-signed-delivery.test.ts +4 -3
- package/src/scenarios/workflow-primary-output-annotation.test.ts +13 -23
- package/src/scenarios/workspace-cross-tenant-isolation-blackbox.test.ts +3 -2
- package/src/scenarios/workspace-cross-tenant-isolation.test.ts +3 -2
- package/src/setup.ts +95 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0153 §B — `server/discover`, stateless `_meta`, header/body agreement,
|
|
3
|
+
* and the version error — pinned on the suite's own MCP server (server-free),
|
|
4
|
+
* plus the host half against a host that claims `mcp-2026-07-28`.
|
|
5
|
+
*
|
|
6
|
+
* Peer half (always-on): `McpFakeServer` became dual-era at suite 1.113.0.
|
|
7
|
+
* A server that is 2026-07-28-shaped only in its comments would let every §B/§C
|
|
8
|
+
* host leg pass against a 2025-06-18 wire and prove nothing, so this file
|
|
9
|
+
* drives it from the wire: `server/discover` (`resultType`, `supportedVersions`,
|
|
10
|
+
* cache hints, `_meta.serverInfo`), `_meta.protocolVersion` REQUIRED, header ≠
|
|
11
|
+
* body ⇒ `-32020`, unsupported ⇒ `-32022` + `data.supported[]`, header-less ⇒
|
|
12
|
+
* legacy semantics on a dual-era server / `-32022` on a current-only server,
|
|
13
|
+
* `initialize` under the current revision ⇒ method-not-found (loud).
|
|
14
|
+
*
|
|
15
|
+
* Host half (gated on `mcp.profiles ∋ mcp-2026-07-28`, hard-fails under
|
|
16
|
+
* `OPENWOP_REQUIRE_BEHAVIOR=true`): the host's own MCP mount answers
|
|
17
|
+
* `server/discover` with `supportedVersions[]` EQUAL to
|
|
18
|
+
* `capabilities.mcp.protocolVersions` (two documents, one fact — `mcp-integration.md`
|
|
19
|
+
* §B), `resultType: "complete"`, `ttlMs`/`cacheScope`; a header/body mismatch is
|
|
20
|
+
* refused `400` + `-32020`; an unsupported revision is refused `400` + `-32022`.
|
|
21
|
+
* A 2025-06-18 host soft-skips (`blocked`).
|
|
22
|
+
*
|
|
23
|
+
* @see spec/v1/mcp-integration.md §"MCP 2026-07-28 versioned composition" §B
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
27
|
+
import { driver } from '../lib/driver.js';
|
|
28
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
29
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
30
|
+
import { McpFakeServer, MCP_ERR } from '../lib/mcp-fake-server.js';
|
|
31
|
+
|
|
32
|
+
// The first describe drives the in-process McpFakeServer itself; the second (gated) describe
|
|
33
|
+
// talks to the host's own MCP server mount and hands it nothing harness-hosted.
|
|
34
|
+
export const HOST_CALLBACK_NOT_REQUIRED = 'the suite drives both ends itself: the fake-server legs read the in-process McpFakeServer directly and the host-as-server legs POST to the host mount; no harness endpoint is handed to the host';
|
|
35
|
+
|
|
36
|
+
const META_V = 'io.modelcontextprotocol/protocolVersion';
|
|
37
|
+
const META_C = 'io.modelcontextprotocol/clientCapabilities';
|
|
38
|
+
const META_S = 'io.modelcontextprotocol/serverInfo';
|
|
39
|
+
|
|
40
|
+
async function call(endpoint: string, method: string, params: Record<string, unknown>, opts: { version?: string; mcpMethod?: string; mcpName?: string; withMeta?: boolean } = {}) {
|
|
41
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
|
42
|
+
if (opts.version !== undefined) headers['MCP-Protocol-Version'] = opts.version;
|
|
43
|
+
if (opts.mcpMethod !== undefined) headers['Mcp-Method'] = opts.mcpMethod;
|
|
44
|
+
if (opts.mcpName !== undefined) headers['Mcp-Name'] = opts.mcpName;
|
|
45
|
+
const withMeta = opts.withMeta ?? true;
|
|
46
|
+
const p = withMeta && opts.version ? { ...params, _meta: { [META_V]: opts.version, [META_C]: {}, ...((params['_meta'] as Record<string, unknown> | undefined) ?? {}) } } : params;
|
|
47
|
+
const res = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: p }) });
|
|
48
|
+
const body = (await res.json()) as { result?: Record<string, unknown>; error?: { code: number; message: string; data?: Record<string, unknown> } };
|
|
49
|
+
return { status: res.status, ...body };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('RFC 0153 §B — the suite MCP server speaks 2026-07-28 (dual-era McpFakeServer)', () => {
|
|
53
|
+
const server = new McpFakeServer();
|
|
54
|
+
beforeAll(async () => server.start(0));
|
|
55
|
+
afterAll(async () => server.stop());
|
|
56
|
+
|
|
57
|
+
it('server/discover: resultType complete, supportedVersions, capabilities, cache hints, _meta.serverInfo', async () => {
|
|
58
|
+
const r = await call(server.endpoint(), 'server/discover', {}, { version: '2026-07-28', mcpMethod: 'server/discover' });
|
|
59
|
+
expect(r.error).toBeUndefined();
|
|
60
|
+
expect(r.result?.['resultType']).toBe('complete');
|
|
61
|
+
expect(r.result?.['supportedVersions']).toEqual(['2026-07-28', '2025-06-18']);
|
|
62
|
+
expect(typeof r.result?.['ttlMs']).toBe('number');
|
|
63
|
+
expect(['public', 'private']).toContain(r.result?.['cacheScope']);
|
|
64
|
+
expect((r.result?.['_meta'] as Record<string, unknown>)[META_S]).toBeDefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('a request without _meta.protocolVersion under the current revision is refused -32020', async () => {
|
|
68
|
+
const r = await call(server.endpoint(), 'tools/list', {}, { version: '2026-07-28', withMeta: false });
|
|
69
|
+
expect(r.status).toBe(400);
|
|
70
|
+
expect(r.error?.code).toBe(MCP_ERR.HEADER_MISMATCH);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('header ≠ body revision ⇒ -32020 HeaderMismatch (400); Mcp-Method ≠ method ⇒ -32020', async () => {
|
|
74
|
+
const r1 = await call(server.endpoint(), 'tools/list', { _meta: { [META_V]: '2025-11-25', [META_C]: {} } }, { version: '2026-07-28', withMeta: false });
|
|
75
|
+
expect(r1.status).toBe(400);
|
|
76
|
+
expect(r1.error?.code).toBe(MCP_ERR.HEADER_MISMATCH);
|
|
77
|
+
const r2 = await call(server.endpoint(), 'tools/list', {}, { version: '2026-07-28', mcpMethod: 'tools/call' });
|
|
78
|
+
expect(r2.error?.code).toBe(MCP_ERR.HEADER_MISMATCH);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('an unsupported revision ⇒ -32022 UnsupportedProtocolVersion with data.supported[] (400)', async () => {
|
|
82
|
+
const r = await call(server.endpoint(), 'tools/list', {}, { version: '1999-01-01' });
|
|
83
|
+
expect(r.status).toBe(400);
|
|
84
|
+
expect(r.error?.code).toBe(MCP_ERR.UNSUPPORTED_PROTOCOL_VERSION);
|
|
85
|
+
expect(r.error?.data?.['supported']).toEqual(['2026-07-28', '2025-06-18']);
|
|
86
|
+
expect(r.error?.data?.['requested']).toBe('1999-01-01');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('header-less ⇒ legacy semantics on a dual-era server (initialize works); ⇒ -32022 on a current-only server', async () => {
|
|
90
|
+
const legacy = await call(server.endpoint(), 'initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'x', version: '0' } });
|
|
91
|
+
expect(legacy.error).toBeUndefined();
|
|
92
|
+
expect(typeof legacy.result?.['protocolVersion']).toBe('string');
|
|
93
|
+
const only = new McpFakeServer({ protocolVersions: ['2026-07-28'] });
|
|
94
|
+
await only.start(0);
|
|
95
|
+
try {
|
|
96
|
+
const r = await call(only.endpoint(), 'tools/list', {});
|
|
97
|
+
expect(r.status).toBe(400);
|
|
98
|
+
expect(r.error?.code).toBe(MCP_ERR.UNSUPPORTED_PROTOCOL_VERSION);
|
|
99
|
+
} finally {
|
|
100
|
+
await only.stop();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('initialize under 2026-07-28 is method-not-found — the handshake does not exist in this revision', async () => {
|
|
105
|
+
const r = await call(server.endpoint(), 'initialize', {}, { version: '2026-07-28' });
|
|
106
|
+
expect(r.status).toBe(404);
|
|
107
|
+
expect(r.error?.code).toBe(-32601);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('tools/list @2026-07-28 carries resultType + CacheableResult hints and lists the MRTR tool', async () => {
|
|
111
|
+
const r = await call(server.endpoint(), 'tools/list', {}, { version: '2026-07-28', mcpMethod: 'tools/list' });
|
|
112
|
+
expect(r.result?.['resultType']).toBe('complete');
|
|
113
|
+
expect(typeof r.result?.['ttlMs']).toBe('number');
|
|
114
|
+
expect(r.result?.['cacheScope']).toBe('public');
|
|
115
|
+
const names = (r.result?.['tools'] as Array<{ name: string }>).map((t) => t.name);
|
|
116
|
+
expect(names).toEqual(['echo', 'needs_input']);
|
|
117
|
+
// legacy list does NOT carry the current-revision fields
|
|
118
|
+
const legacy = await call(server.endpoint(), 'tools/list', {});
|
|
119
|
+
expect(legacy.result?.['ttlMs']).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('MRTR: needs_input answers input_required (elicitation + opaque requestState) and completes on the retry; missing capability ⇒ -32021', async () => {
|
|
123
|
+
server.reset();
|
|
124
|
+
const noCap = await call(server.endpoint(), 'tools/call', { name: 'needs_input', arguments: {} }, { version: '2026-07-28', mcpMethod: 'tools/call', mcpName: 'needs_input' });
|
|
125
|
+
expect(noCap.error?.code).toBe(MCP_ERR.MISSING_REQUIRED_CLIENT_CAPABILITY);
|
|
126
|
+
expect(noCap.error?.data?.['requiredCapabilities']).toEqual(['elicitation']);
|
|
127
|
+
|
|
128
|
+
const first = await call(server.endpoint(), 'tools/call', { name: 'needs_input', arguments: {}, _meta: { [META_C]: { elicitation: {} } } }, { version: '2026-07-28', mcpMethod: 'tools/call', mcpName: 'needs_input' });
|
|
129
|
+
expect(first.error).toBeUndefined();
|
|
130
|
+
expect(first.result?.['resultType']).toBe('input_required');
|
|
131
|
+
const reqs = first.result?.['inputRequests'] as Record<string, { method: string; params: { mode: string } }>;
|
|
132
|
+
expect(reqs['who']?.method).toBe('elicitation/create');
|
|
133
|
+
expect(reqs['who']?.params.mode).toBe('form');
|
|
134
|
+
const state = first.result?.['requestState'];
|
|
135
|
+
expect(typeof state).toBe('string');
|
|
136
|
+
|
|
137
|
+
// Retry: NEW id, inputResponses + requestState echoed exactly.
|
|
138
|
+
const retry = await call(server.endpoint(), 'tools/call', {
|
|
139
|
+
name: 'needs_input', arguments: {}, requestState: state,
|
|
140
|
+
inputResponses: { who: { action: 'accept', content: { name: 'Ada' } } },
|
|
141
|
+
_meta: { [META_C]: { elicitation: {} } },
|
|
142
|
+
}, { version: '2026-07-28', mcpMethod: 'tools/call', mcpName: 'needs_input' });
|
|
143
|
+
expect(retry.error).toBeUndefined();
|
|
144
|
+
expect(retry.result?.['resultType']).toBe('complete');
|
|
145
|
+
expect(((retry.result?.['content'] as Array<{ text: string }>)[0]).text).toBe('hello Ada');
|
|
146
|
+
|
|
147
|
+
// Tampered / missing state ⇒ refused
|
|
148
|
+
const bad = await call(server.endpoint(), 'tools/call', {
|
|
149
|
+
name: 'needs_input', arguments: {}, requestState: 'forged',
|
|
150
|
+
inputResponses: { who: { action: 'accept', content: { name: 'Eve' } } },
|
|
151
|
+
_meta: { [META_C]: { elicitation: {} } },
|
|
152
|
+
}, { version: '2026-07-28', mcpMethod: 'tools/call', mcpName: 'needs_input' });
|
|
153
|
+
expect(bad.error?.code).toBe(-32602);
|
|
154
|
+
// Both calls were independent requests recorded under the current revision.
|
|
155
|
+
expect(server.invocations().filter((i) => i.revision === '2026-07-28').length).toBeGreaterThanOrEqual(3);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ─── Host half ───────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
const PROFILE = 'mcp-2026-07-28';
|
|
162
|
+
interface McpCaps { readonly supported?: boolean; readonly protocolVersions?: readonly string[]; readonly profiles?: readonly string[]; readonly serverMount?: { supported?: boolean } }
|
|
163
|
+
async function mcp(): Promise<McpCaps | undefined> {
|
|
164
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
165
|
+
return capabilityFamily<McpCaps>(disco.json, 'mcp');
|
|
166
|
+
}
|
|
167
|
+
async function claimsCurrent(): Promise<boolean> {
|
|
168
|
+
const caps = await mcp();
|
|
169
|
+
return caps?.supported === true && (caps.profiles ?? []).includes(PROFILE) && caps.serverMount?.supported === true;
|
|
170
|
+
}
|
|
171
|
+
async function hostRpc(method: string, params: Record<string, unknown>, headers: Record<string, string>) {
|
|
172
|
+
// The reference host mounts its MCP server at /v1/host/sample/mcp (mcp-server-* scenarios).
|
|
173
|
+
const res = await driver.post('/v1/host/sample/mcp', { jsonrpc: '2.0', id: 1, method, params }, { headers });
|
|
174
|
+
return { status: res.status, body: res.json as { result?: Record<string, unknown>; error?: { code: number; data?: Record<string, unknown> } } };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §B — host as MCP server: server/discover and version refusal (gated on mcp.profiles ∋ mcp-2026-07-28)', () => {
|
|
178
|
+
it('server/discover reports supportedVersions equal to capabilities.mcp.protocolVersions, with resultType + cache hints', async () => {
|
|
179
|
+
if (!behaviorGate(PROFILE, await claimsCurrent())) return;
|
|
180
|
+
const caps = (await mcp())!;
|
|
181
|
+
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; // mount not wired at the sample path — the mcp-server-* legs cover that gap
|
|
183
|
+
expect(r.status, driver.describe('mcp-integration.md §B', 'server/discover is a server MUST under 2026-07-28')).toBe(200);
|
|
184
|
+
expect(r.body.result?.['resultType']).toBe('complete');
|
|
185
|
+
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());
|
|
186
|
+
expect(typeof r.body.result?.['ttlMs'], driver.describe('mcp-integration.md §D', 'server/discover is cacheable: ttlMs REQUIRED')).toBe('number');
|
|
187
|
+
expect(['public', 'private']).toContain(r.body.result?.['cacheScope']);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('a header/body revision mismatch is refused 400 + -32020; an unsupported revision 400 + -32022', async () => {
|
|
191
|
+
if (!behaviorGate(PROFILE, await claimsCurrent())) return;
|
|
192
|
+
// Header ≠ body with a body revision the host DOES support, so the leg
|
|
193
|
+
// isolates HeaderMismatchError (-32020) from UnsupportedProtocolVersion
|
|
194
|
+
// (-32022). The earlier vector (`2025-11-25` in the body) was BOTH
|
|
195
|
+
// mismatched and unsupported, and a host that checked support first
|
|
196
|
+
// answered -32022 — the leg was testing an unstated precedence
|
|
197
|
+
// (mcp-integration.md §B: agreement is checked before selection).
|
|
198
|
+
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;
|
|
200
|
+
expect(mismatch.status, driver.describe('mcp-integration.md §B', 'header ≠ body MUST be refused 400 (HeaderMismatchError -32020) — fail closed')).toBe(400);
|
|
201
|
+
expect(mismatch.body.error?.code).toBe(MCP_ERR.HEADER_MISMATCH);
|
|
202
|
+
const unsupported = await hostRpc('tools/list', { _meta: { [META_V]: '1999-01-01', [META_C]: {} } }, { 'MCP-Protocol-Version': '1999-01-01', 'Mcp-Method': 'tools/list' });
|
|
203
|
+
expect(unsupported.status, driver.describe('mcp-integration.md §B', 'an unsupported revision MUST be refused 400 (UnsupportedProtocolVersionError -32022)')).toBe(400);
|
|
204
|
+
expect(unsupported.body.error?.code).toBe(MCP_ERR.UNSUPPORTED_PROTOCOL_VERSION);
|
|
205
|
+
expect(Array.isArray(unsupported.body.error?.data?.['supported']), driver.describe('mcp-integration.md §B', 'the refusal MUST list data.supported[]')).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0153 §D — cache scope follows the tenant boundary (invariant
|
|
3
|
+
* `mcp-cache-tenant-scoped`, named by RFC 0153 §E).
|
|
4
|
+
*
|
|
5
|
+
* `mcp-integration.md` §D: an OpenWOP host acting as MCP server derives its
|
|
6
|
+
* lists from a tenant-scoped registry, so `cacheScope` MUST be `"private"`
|
|
7
|
+
* whenever the result depends on the caller's tenant/workspace/principal/
|
|
8
|
+
* authorization — on a multi-tenant host, always. `"public"` is permitted only
|
|
9
|
+
* when the result is byte-identical for every caller.
|
|
10
|
+
*
|
|
11
|
+
* Non-vacuity: with a second credential (`OPENWOP_TEST_SECONDARY_API_KEY`) the
|
|
12
|
+
* leg lists tools under both callers; if the lists DIFFER the host MUST have
|
|
13
|
+
* said `private` — a `public` list that differs per caller is exactly the
|
|
14
|
+
* cross-context cache poisoning the invariant forbids. Without a second key the
|
|
15
|
+
* leg can only check the shape and says so (`blocked` for the cross-caller
|
|
16
|
+
* half via the ledger note).
|
|
17
|
+
*
|
|
18
|
+
* Gate: `mcp.profiles ∋ mcp-2026-07-28` + `serverMount.supported`.
|
|
19
|
+
*
|
|
20
|
+
* @see spec/v1/mcp-integration.md §"MCP 2026-07-28 versioned composition" §D
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect } from 'vitest';
|
|
24
|
+
import { driver } from '../lib/driver.js';
|
|
25
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
26
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
27
|
+
|
|
28
|
+
const PROFILE = 'mcp-2026-07-28';
|
|
29
|
+
const META_V = 'io.modelcontextprotocol/protocolVersion';
|
|
30
|
+
const META_C = 'io.modelcontextprotocol/clientCapabilities';
|
|
31
|
+
|
|
32
|
+
interface McpCaps { readonly supported?: boolean; readonly profiles?: readonly string[]; readonly serverMount?: { supported?: boolean } }
|
|
33
|
+
async function claimsCurrent(): Promise<boolean> {
|
|
34
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
35
|
+
const caps = capabilityFamily<McpCaps>(disco.json, 'mcp');
|
|
36
|
+
return caps?.supported === true && (caps.profiles ?? []).includes(PROFILE) && caps.serverMount?.supported === true;
|
|
37
|
+
}
|
|
38
|
+
async function listAs(bearer?: string) {
|
|
39
|
+
const headers: Record<string, string> = { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/list' };
|
|
40
|
+
if (bearer) headers['authorization'] = `Bearer ${bearer}`;
|
|
41
|
+
const res = await driver.post('/v1/host/sample/mcp', { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: { [META_V]: '2026-07-28', [META_C]: {} } } }, { headers });
|
|
42
|
+
return { status: res.status, body: res.json as { result?: { tools?: unknown[]; cacheScope?: string; ttlMs?: number } } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §D — mcp-cache-tenant-scope (host as server, gated)', () => {
|
|
46
|
+
it('a per-caller list is cacheScope private; a public list is byte-identical across callers', async () => {
|
|
47
|
+
if (!behaviorGate(PROFILE, await claimsCurrent())) return;
|
|
48
|
+
const mine = await listAs();
|
|
49
|
+
if (mine.status === 404 || mine.status === 403) return;
|
|
50
|
+
expect(['public', 'private'], driver.describe('mcp-integration.md §D', 'cacheScope MUST be present on tools/list')).toContain(mine.body.result?.cacheScope);
|
|
51
|
+
const other = process.env.OPENWOP_TEST_SECONDARY_API_KEY;
|
|
52
|
+
if (!other) {
|
|
53
|
+
// Shape only; the cross-caller half is unobservable without a second credential.
|
|
54
|
+
// eslint-disable-next-line no-console
|
|
55
|
+
console.warn('[mcp-cache-tenant-scope] OPENWOP_TEST_SECONDARY_API_KEY not set — cross-caller half not exercised (blocked)');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const theirs = await listAs(other);
|
|
59
|
+
if (theirs.status !== 200) return;
|
|
60
|
+
const same = JSON.stringify(mine.body.result?.tools) === JSON.stringify(theirs.body.result?.tools);
|
|
61
|
+
if (!same) {
|
|
62
|
+
expect(mine.body.result?.cacheScope, driver.describe('mcp-integration.md §D', 'a list that differs per caller MUST be cacheScope private — a public list that differs is cross-context cache poisoning (mcp-cache-tenant-scoped)')).toBe('private');
|
|
63
|
+
expect(theirs.body.result?.cacheScope).toBe('private');
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0153 §E — an anonymous MCP principal MUST NOT be the production default
|
|
3
|
+
* for an advertised current profile (invariant `mcp-peer-no-authority-escalation`
|
|
4
|
+
* is the sibling; this leg is the authentication boundary).
|
|
5
|
+
*
|
|
6
|
+
* `mcp-integration.md` §E: a host that advertises `mcp-2026-07-28` MUST require
|
|
7
|
+
* authentication on its MCP endpoint in production, unless it advertises RFC
|
|
8
|
+
* 0132 `anonymousActor` and routes anonymous MCP callers through that surface's
|
|
9
|
+
* rules. Black-box: an UNAUTHENTICATED `tools/list` at the current-profile mount
|
|
10
|
+
* MUST be refused (`401`/`403`), or the host MUST advertise `anonymousActor`.
|
|
11
|
+
*
|
|
12
|
+
* Gate: `mcp.profiles ∋ mcp-2026-07-28` + `serverMount.supported`. Non-vacuous:
|
|
13
|
+
* the leg first proves the authenticated call succeeds at the same path, so a
|
|
14
|
+
* refusal cannot be a wrong-path 404.
|
|
15
|
+
*
|
|
16
|
+
* @see spec/v1/mcp-integration.md §"MCP 2026-07-28 versioned composition" §E
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from 'vitest';
|
|
20
|
+
import { driver } from '../lib/driver.js';
|
|
21
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
22
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
23
|
+
|
|
24
|
+
const PROFILE = 'mcp-2026-07-28';
|
|
25
|
+
const META_V = 'io.modelcontextprotocol/protocolVersion';
|
|
26
|
+
const META_C = 'io.modelcontextprotocol/clientCapabilities';
|
|
27
|
+
|
|
28
|
+
interface McpCaps { readonly supported?: boolean; readonly profiles?: readonly string[]; readonly serverMount?: { supported?: boolean } }
|
|
29
|
+
async function disco() {
|
|
30
|
+
const d = await driver.get('/.well-known/openwop');
|
|
31
|
+
return { mcp: capabilityFamily<McpCaps>(d.json, 'mcp'), anon: capabilityFamily<{ supported?: boolean }>(d.json, 'anonymousActor') };
|
|
32
|
+
}
|
|
33
|
+
const REQ = { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: { [META_V]: '2026-07-28', [META_C]: {} } } };
|
|
34
|
+
const HDR = { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/list' };
|
|
35
|
+
|
|
36
|
+
describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §E — mcp-current-auth-boundary (host as server, gated)', () => {
|
|
37
|
+
it('an unauthenticated current-profile request is refused, unless anonymousActor is advertised', async () => {
|
|
38
|
+
const { mcp, anon } = await disco();
|
|
39
|
+
const claims = mcp?.supported === true && (mcp.profiles ?? []).includes(PROFILE) && mcp.serverMount?.supported === true;
|
|
40
|
+
if (!behaviorGate(PROFILE, claims)) return;
|
|
41
|
+
const authed = await driver.post('/v1/host/sample/mcp', REQ, { headers: HDR });
|
|
42
|
+
if (authed.status === 404 || authed.status === 403) return; // mount not at the sample path
|
|
43
|
+
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('/v1/host/sample/mcp', REQ, { headers: HDR, authenticated: false });
|
|
45
|
+
if (anon?.supported === true) {
|
|
46
|
+
// Anonymous is permitted only through the RFC 0132 surface; a 200 here is that surface answering.
|
|
47
|
+
expect([200, 401, 403]).toContain(anonymous.status);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
expect(
|
|
51
|
+
[401, 403],
|
|
52
|
+
driver.describe('mcp-integration.md §E', 'an anonymous MCP principal MUST NOT be the production default for an advertised current profile — refuse (401/403) or advertise anonymousActor'),
|
|
53
|
+
).toContain(anonymous.status);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0153 §D — extensions and `_meta` are opaque; they never confer authority
|
|
3
|
+
* (invariant `mcp-extension-no-authority`, named by RFC 0153 §E).
|
|
4
|
+
*
|
|
5
|
+
* CLIENT half (host calls the suite's server): the tool result carries
|
|
6
|
+
* `_meta['io.example/authority']: { grantScopes, approve: true }` and an
|
|
7
|
+
* `io.modelcontextprotocol/ui` key. `mcp-integration.md` §D: every extension and
|
|
8
|
+
* every `_meta` key is opaque unless a named mapping exists (only OTel context
|
|
9
|
+
* and `logLevel` are named); none may widen scopes, advance approvals, or reach
|
|
10
|
+
* secrets. Observed via the ADDITIVE seam field: request `scenario:
|
|
11
|
+
* "extension-asserts-authority"` → response `extensionAuthority: {
|
|
12
|
+
* scopesWidened, approvalAdvanced }` (`host-sample-test-seams.md` §23); a host
|
|
13
|
+
* that does not report it is `blocked`.
|
|
14
|
+
*
|
|
15
|
+
* SERVER half (host as 2026-07-28 server): a request whose `_meta` carries an
|
|
16
|
+
* unknown extension key and an unknown `capabilities.extensions` entry MUST be
|
|
17
|
+
* processed normally (`resultType: complete`) — opacity means ignored, not
|
|
18
|
+
* refused and not honoured.
|
|
19
|
+
*
|
|
20
|
+
* @see spec/v1/mcp-integration.md §"MCP 2026-07-28 versioned composition" §D
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect } from 'vitest';
|
|
24
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
25
|
+
import { driver } from '../lib/driver.js';
|
|
26
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
27
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
28
|
+
import { getMcpFakeServer } from '../lib/mcp-fake-server.js';
|
|
29
|
+
|
|
30
|
+
export const REQUIRES_HOST_CALLBACK = "the host issues MCP JSON-RPC calls to the suite's fake server via /v1/host/sample/mcp/invoke (serverUrl), which the server records";
|
|
31
|
+
|
|
32
|
+
const META_V = 'io.modelcontextprotocol/protocolVersion';
|
|
33
|
+
const META_C = 'io.modelcontextprotocol/clientCapabilities';
|
|
34
|
+
interface McpCaps { readonly supported?: boolean; readonly protocolVersions?: readonly string[]; readonly profiles?: readonly string[]; readonly serverMount?: { supported?: boolean } }
|
|
35
|
+
async function mcp(): Promise<McpCaps | undefined> {
|
|
36
|
+
const d = await driver.get('/.well-known/openwop');
|
|
37
|
+
return capabilityFamily<McpCaps>(d.json, 'mcp');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §D — mcp-extension-opacity (gated)', () => {
|
|
41
|
+
it('client half: an extension _meta asserting authority in a tool result changes nothing the host authorizes', async () => {
|
|
42
|
+
const caps = await mcp();
|
|
43
|
+
if (!behaviorGate('mcp.extensionOpacity', caps?.supported === true && (caps.protocolVersions ?? []).includes('2026-07-28'))) return;
|
|
44
|
+
const server = getMcpFakeServer();
|
|
45
|
+
if (server === null) return softSkip('blocked', 'the suite MCP fake server is not started in this run — the host-as-client leg cannot be driven');
|
|
46
|
+
server.reset();
|
|
47
|
+
server.setNextResultAssertsAuthority(true);
|
|
48
|
+
const drive = await driver.post('/v1/host/sample/mcp/invoke', { serverUrl: server.endpoint(), tool: 'echo', arguments: { text: 'opaque' }, scenario: 'extension-asserts-authority' });
|
|
49
|
+
if (drive.status === 404 || drive.status === 403) {
|
|
50
|
+
// Advertised mcp-2026-07-28 but the invoke seam answered {drive.status}: not observable here.
|
|
51
|
+
// Default mode records `blocked` (RFC 0148 §A); OPENWOP_REQUIRE_BEHAVIOR=true fails
|
|
52
|
+
// an advertised-missing seam (RFC 0148 §B). A 403 is NOT a pass.
|
|
53
|
+
return seamAbsent(`host advertises mcp-2026-07-28 but the invoke seam /v1/host/sample/mcp/invoke answered ${drive.status}`);
|
|
54
|
+
}
|
|
55
|
+
expect(server.invocations().some((i) => i.method === 'tools/call'), 'the host MUST have called the tool').toBe(true);
|
|
56
|
+
const rep = (drive.json as { extensionAuthority?: { scopesWidened?: boolean; approvalAdvanced?: boolean } }).extensionAuthority;
|
|
57
|
+
if (rep === undefined) {
|
|
58
|
+
expect(rep, driver.describe('host-sample-test-seams.md §23', 'the seam SHOULD report `extensionAuthority: { scopesWidened, approvalAdvanced }` for scenario "extension-asserts-authority"; until it does the requirement is `blocked`, not passed')).toBeDefined();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
expect(rep.scopesWidened, driver.describe('mcp-integration.md §D', 'an extension MUST NOT gain tool authority or scope by appearing in _meta (mcp-extension-no-authority)')).toBe(false);
|
|
62
|
+
expect(rep.approvalAdvanced, driver.describe('mcp-integration.md §D/§E', 'MCP content MUST NOT advance approval gates')).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('server half: unknown _meta extension keys on a request are ignored, not refused and not honoured', async () => {
|
|
66
|
+
const caps = await mcp();
|
|
67
|
+
const claims = caps?.supported === true && (caps.profiles ?? []).includes('mcp-2026-07-28') && caps.serverMount?.supported === true;
|
|
68
|
+
if (!behaviorGate('mcp-2026-07-28', claims)) return;
|
|
69
|
+
const res = await driver.post('/v1/host/sample/mcp', {
|
|
70
|
+
jsonrpc: '2.0', id: 1, method: 'tools/list',
|
|
71
|
+
params: { _meta: { [META_V]: '2026-07-28', [META_C]: { extensions: { 'io.example/authority': { admin: true } } }, 'io.example/authority': { grantScopes: ['*'] } } },
|
|
72
|
+
}, { headers: { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/list' } });
|
|
73
|
+
if (res.status === 404 || res.status === 403) return seamAbsent(`host advertises mcp-2026-07-28 but the MCP server mount /v1/host/sample/mcp answered ${res.status}`);
|
|
74
|
+
const body = res.json as { result?: { resultType?: string }; error?: { code: number } };
|
|
75
|
+
expect(res.status, driver.describe('mcp-integration.md §D', 'an unknown extension is opaque: the request MUST be processed normally, neither refused nor granted anything')).toBe(200);
|
|
76
|
+
expect(body.error).toBeUndefined();
|
|
77
|
+
expect(body.result?.resultType).toBe('complete');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0153 §C — Multi Round-Trip Requests, both directions.
|
|
3
|
+
*
|
|
4
|
+
* CLIENT half (host calls the suite's MCP server): `mcp-integration.md` §C.1 —
|
|
5
|
+
* when a tool answers `input_required`, the host MUST NOT hold a live callback;
|
|
6
|
+
* it MUST treat the initial request and the retry as ONE logical invocation,
|
|
7
|
+
* gather the elicitation input (a `clarification` interrupt in a real run; the
|
|
8
|
+
* seam MAY answer it programmatically), retry with a NEW JSON-RPC id carrying
|
|
9
|
+
* `inputResponses[key]` and the `requestState` echoed EXACTLY, and MUST NOT
|
|
10
|
+
* cache the interim result. Observed through the ADDITIVE seam contract
|
|
11
|
+
* (`host-sample-test-seams.md` §23): `POST /v1/host/sample/mcp/invoke
|
|
12
|
+
* { serverUrl, tool: "needs_input", clientCapabilities: { elicitation: {} },
|
|
13
|
+
* elicitationAnswer: { name: "Ada" } }` → `200 { negotiatedVersion, mrtr: {
|
|
14
|
+
* inputRequiredSeen, retried, requestStateEchoed, result } }`. The suite's server
|
|
15
|
+
* records both calls, so `requestStateEchoed` is cross-checked from the wire, not
|
|
16
|
+
* taken from the host's report. A host without the `mrtr` block is `blocked`.
|
|
17
|
+
*
|
|
18
|
+
* SERVER half (host as 2026-07-28 MCP server): §C.2 — a `tools/call` on an
|
|
19
|
+
* exposed workflow that reaches `waiting-input` MUST answer `input_required`
|
|
20
|
+
* with an `elicitation/create` in `inputRequests` and a `requestState`, and the
|
|
21
|
+
* retry with `inputResponses` MUST resolve it (`resultType: complete`). Uses the
|
|
22
|
+
* same `core.openwop.mcp.expose-tool` + `handle-elicitation` fixture shape the
|
|
23
|
+
* legacy bridge leg uses. A tampered `requestState` MUST be refused.
|
|
24
|
+
*
|
|
25
|
+
* Gate: `mcp.supported && mcp.protocolVersions ∋ 2026-07-28` for the client
|
|
26
|
+
* half (the seam is version-independent); `mcp.profiles ∋ mcp-2026-07-28` +
|
|
27
|
+
* `serverMount.supported` for the server half. Needs the fake server
|
|
28
|
+
* (`OPENWOP_MCP_FAKE_SERVER=true`) for the client half.
|
|
29
|
+
*
|
|
30
|
+
* @see spec/v1/mcp-integration.md §"MCP 2026-07-28 versioned composition" §C
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { describe, it, expect } from 'vitest';
|
|
34
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
35
|
+
import { driver } from '../lib/driver.js';
|
|
36
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
37
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
38
|
+
import { getMcpFakeServer } from '../lib/mcp-fake-server.js';
|
|
39
|
+
|
|
40
|
+
export const REQUIRES_HOST_CALLBACK = "the host issues MCP JSON-RPC calls (including the MRTR input_required round trip) to the suite's fake server via /v1/host/sample/mcp/invoke (serverUrl)";
|
|
41
|
+
|
|
42
|
+
const META_V = 'io.modelcontextprotocol/protocolVersion';
|
|
43
|
+
const META_C = 'io.modelcontextprotocol/clientCapabilities';
|
|
44
|
+
|
|
45
|
+
interface McpCaps { readonly supported?: boolean; readonly protocolVersions?: readonly string[]; readonly profiles?: readonly string[]; readonly serverMount?: { supported?: boolean; elicitationBridge?: boolean } }
|
|
46
|
+
async function mcp(): Promise<McpCaps | undefined> {
|
|
47
|
+
const d = await driver.get('/.well-known/openwop');
|
|
48
|
+
return capabilityFamily<McpCaps>(d.json, 'mcp');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §C — mcp-mrtr-roundtrip, client half (host calls a 2026-07-28 server; gated)', () => {
|
|
52
|
+
it('input_required → gather → retry with inputResponses + echoed requestState, as one logical invocation', async () => {
|
|
53
|
+
const caps = await mcp();
|
|
54
|
+
if (!behaviorGate('mcp.mrtr.client', caps?.supported === true && (caps.protocolVersions ?? []).includes('2026-07-28'))) return;
|
|
55
|
+
const server = getMcpFakeServer();
|
|
56
|
+
if (server === null) return softSkip('blocked', 'the suite MCP fake server is not started in this run — the client half cannot be driven');
|
|
57
|
+
server.reset();
|
|
58
|
+
const drive = await driver.post('/v1/host/sample/mcp/invoke', {
|
|
59
|
+
serverUrl: server.endpoint(),
|
|
60
|
+
tool: 'needs_input',
|
|
61
|
+
clientCapabilities: { elicitation: {} },
|
|
62
|
+
elicitationAnswer: { name: 'Ada' },
|
|
63
|
+
});
|
|
64
|
+
if (drive.status === 404 || drive.status === 403) {
|
|
65
|
+
// Advertised mcp-2026-07-28 but the invoke seam answered {drive.status}: not observable here.
|
|
66
|
+
// Default mode records `blocked` (RFC 0148 §A); OPENWOP_REQUIRE_BEHAVIOR=true fails
|
|
67
|
+
// an advertised-missing seam (RFC 0148 §B). A 403 is NOT a pass.
|
|
68
|
+
return seamAbsent(`host advertises mcp-2026-07-28 but the invoke seam /v1/host/sample/mcp/invoke answered ${drive.status}`);
|
|
69
|
+
}
|
|
70
|
+
const calls = server.invocations().filter((i) => i.method === 'tools/call');
|
|
71
|
+
expect(calls.length, driver.describe('RFCS/0153 §C', 'the host MUST have called the tool at least once for this leg to mean anything')).toBeGreaterThan(0);
|
|
72
|
+
const mrtr = (drive.json as { mrtr?: { inputRequiredSeen?: boolean; retried?: boolean; requestStateEchoed?: boolean; result?: unknown } }).mrtr;
|
|
73
|
+
if (mrtr === undefined) {
|
|
74
|
+
expect(mrtr, driver.describe('host-sample-test-seams.md §23', 'the invoke seam SHOULD report `mrtr: { inputRequiredSeen, retried, requestStateEchoed, result }` for tool "needs_input"; until it does this requirement is unobservable and resolves to `blocked`')).toBeDefined();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
expect(mrtr.inputRequiredSeen, driver.describe('mcp-integration.md §C.1', 'the host MUST recognise resultType input_required')).toBe(true);
|
|
78
|
+
expect(mrtr.retried, driver.describe('mcp-integration.md §C.1', 'the host MUST retry the ORIGINAL request with inputResponses (a live callback is not the current profile)')).toBe(true);
|
|
79
|
+
// Cross-check from the wire: two tools/call, second carries requestState identical to what the server issued.
|
|
80
|
+
expect(calls.length, driver.describe('mcp-integration.md §C.1', 'initial + retry = two independent JSON-RPC requests on the wire')).toBeGreaterThanOrEqual(2);
|
|
81
|
+
const retry = calls[calls.length - 1]!.params as { requestState?: string; inputResponses?: Record<string, unknown> };
|
|
82
|
+
expect(typeof retry.requestState, driver.describe('mcp-integration.md §C.1', 'requestState MUST be echoed exactly on the retry')).toBe('string');
|
|
83
|
+
expect(retry.requestState?.startsWith('mrtr:needs_input:'), 'the echoed requestState is the value the server issued, byte-exact').toBe(true);
|
|
84
|
+
expect(retry.inputResponses?.['who'], driver.describe('mcp-integration.md §C.1', 'inputResponses MUST answer every requested key')).toBeDefined();
|
|
85
|
+
expect(mrtr.requestStateEchoed).toBe(true);
|
|
86
|
+
for (const c of calls) {
|
|
87
|
+
expect(c.revision, driver.describe('mcp-integration.md §B', 'both calls MUST carry the current revision')).toBe('2026-07-28');
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §C — mcp-mrtr-roundtrip, server half (host as 2026-07-28 server; gated on mcp-2026-07-28 + serverMount)', () => {
|
|
93
|
+
it('a suspending tool answers input_required with elicitation + requestState; the retry resolves it; a forged requestState is refused', async () => {
|
|
94
|
+
const caps = await mcp();
|
|
95
|
+
const claims = caps?.supported === true && (caps.profiles ?? []).includes('mcp-2026-07-28') && caps.serverMount?.supported === true;
|
|
96
|
+
if (!behaviorGate('mcp-2026-07-28', claims)) return;
|
|
97
|
+
// Register a workflow that exposes a tool and asks for input (the legacy
|
|
98
|
+
// elicitation-bridge fixture shape); under the current profile the ask MUST
|
|
99
|
+
// surface as MRTR, not as a live elicitation/create callback.
|
|
100
|
+
const TOOL = `mrtr_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
101
|
+
const reg = await driver.post('/v1/host/sample/workflows', {
|
|
102
|
+
workflowId: `mcp.mrtr.${TOOL}`,
|
|
103
|
+
nodes: [
|
|
104
|
+
{ nodeId: 'expose', typeId: 'core.openwop.mcp.expose-tool', config: { name: TOOL, description: 'MRTR conformance tool', inputSchema: { type: 'object', properties: {} } } },
|
|
105
|
+
{ nodeId: 'ask', typeId: 'core.openwop.mcp.handle-elicitation', config: { message: 'What is your name?', requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } } },
|
|
106
|
+
],
|
|
107
|
+
// The sample-workflow seam's registration shape (`nodeId` / `edgeId` /
|
|
108
|
+
// `sourceNodeId` / `targetNodeId` — the same convention every other leg
|
|
109
|
+
// posting to `/v1/host/sample/workflows` uses for nodes), NOT the RFC 0013
|
|
110
|
+
// workflow-CHAIN `{ from, to }` shape this leg posted until 2026-08-16
|
|
111
|
+
// (S17: the first 2026-07-28 host answered 400 validation_error and the
|
|
112
|
+
// leg walked on to tools/call for a tool that was never registered).
|
|
113
|
+
// Note the seam shape differs from the canonical WorkflowDefinition
|
|
114
|
+
// (`workflow-definition.schema.json` keys nodes and edges by `id`); the
|
|
115
|
+
// divergence is recorded in host-sample-test-seams.md.
|
|
116
|
+
edges: [{ edgeId: 'expose-ask', sourceNodeId: 'expose', targetNodeId: 'ask' }],
|
|
117
|
+
});
|
|
118
|
+
if (reg.status === 404 || reg.status === 403) return seamAbsent(`host advertises mcp-2026-07-28 with a server mount but the sample-workflow seam /v1/host/sample/workflows answered ${reg.status}`);
|
|
119
|
+
// A 4xx here is a SUITE defect (the fixture this leg posts is malformed),
|
|
120
|
+
// not seam absence and not a host finding — fail loudly at the source.
|
|
121
|
+
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
|
+
const hdr = { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/call', 'Mcp-Name': TOOL };
|
|
123
|
+
const first = await driver.post('/v1/host/sample/mcp', { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: TOOL, arguments: {}, _meta: { [META_V]: '2026-07-28', [META_C]: { elicitation: {} } } } }, { headers: hdr });
|
|
124
|
+
if (first.status === 404) return seamAbsent('host advertises an MCP server mount but /v1/host/sample/mcp answered 404');
|
|
125
|
+
const r1 = first.json as { result?: { resultType?: string; inputRequests?: Record<string, { method?: string }>; requestState?: string }; error?: { code: number } };
|
|
126
|
+
expect(r1.error, driver.describe('mcp-integration.md §C.2', `tools/call MUST NOT error: ${JSON.stringify(r1.error)}`)).toBeUndefined();
|
|
127
|
+
expect(r1.result?.resultType, driver.describe('mcp-integration.md §C.2', 'a run reaching waiting-input MUST answer the in-flight tools/call with resultType input_required (not a live callback)')).toBe('input_required');
|
|
128
|
+
const key = Object.keys(r1.result?.inputRequests ?? {})[0];
|
|
129
|
+
expect(key, driver.describe('mcp-integration.md §C.2', 'inputRequests MUST carry the elicitation')).toBeDefined();
|
|
130
|
+
expect(r1.result?.inputRequests?.[key!]?.method).toBe('elicitation/create');
|
|
131
|
+
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('/v1/host/sample/mcp', { 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
|
+
const r2 = retry.json as { result?: { resultType?: string }; error?: { code: number } };
|
|
134
|
+
expect(r2.error).toBeUndefined();
|
|
135
|
+
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('/v1/host/sample/mcp', { 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
|
+
const r3 = forged.json as { result?: { resultType?: string }; error?: { code: number } };
|
|
138
|
+
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
|
+
});
|