diff --git a/packages/adt-mcp/src/lib/tools/scope-catalogue.ts b/packages/adt-mcp/src/lib/tools/scope-catalogue.ts index 4b4c8b45..01cbb33a 100644 --- a/packages/adt-mcp/src/lib/tools/scope-catalogue.ts +++ b/packages/adt-mcp/src/lib/tools/scope-catalogue.ts @@ -374,7 +374,11 @@ function isScopedReadResourceAllowed( name: string, arguments_: Record, ): boolean { - if (name !== 'get_object' && name !== 'get_object_structure') return true; + // Fail closed: a scoped read must not reach tools that take unbounded + // resource identifiers (e.g. `cts_*` transport reads). The toolNames + // contract whitelist already blocks them at parse time; this keeps the + // dispatch layer equally strict if that whitelist ever widens. + if (name !== 'get_object' && name !== 'get_object_structure') return false; if (scoped.resourceKeys.length === 0) return false; const key = canonicalObjectKey(arguments_.objectType, arguments_.objectName); return Boolean(key && scoped.resourceKeys.includes(key)); diff --git a/packages/adt-mcp/tests/adt-execution-policy.test.ts b/packages/adt-mcp/tests/adt-execution-policy.test.ts index 3473427a..9d38e2ee 100644 --- a/packages/adt-mcp/tests/adt-execution-policy.test.ts +++ b/packages/adt-mcp/tests/adt-execution-policy.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; +import { describe, it } from 'vitest'; import { isMcpInvocationDispatchPolicySupported, parseScopedAdtInvocationPolicy, diff --git a/packages/adt-mcp/tests/delegated-assistant-catalogue.test.ts b/packages/adt-mcp/tests/delegated-assistant-catalogue.test.ts index b14f9a86..eacbcdab 100644 --- a/packages/adt-mcp/tests/delegated-assistant-catalogue.test.ts +++ b/packages/adt-mcp/tests/delegated-assistant-catalogue.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import test from 'node:test'; +import { test } from 'vitest'; import { isMcpToolAllowed, isMcpToolListed, diff --git a/packages/adt-mcp/tests/delegated-assistant-policy.test.ts b/packages/adt-mcp/tests/delegated-assistant-policy.test.ts index 9f72d59b..afe48098 100644 --- a/packages/adt-mcp/tests/delegated-assistant-policy.test.ts +++ b/packages/adt-mcp/tests/delegated-assistant-policy.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; +import { describe, it } from 'vitest'; import { parseDelegatedAssistantReadPolicy, type TrustedMcpInvocationClaims, diff --git a/packages/adt-mcp/tests/destination-registry.test.ts b/packages/adt-mcp/tests/destination-registry.test.ts index 10b0bf2b..c37eae3f 100644 --- a/packages/adt-mcp/tests/destination-registry.test.ts +++ b/packages/adt-mcp/tests/destination-registry.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import test from 'node:test'; +import { test } from 'vitest'; import { createDestinationContextRegistry, type DestinationContextFactory, diff --git a/packages/adt-mcp/tests/flow-checkout-tr.test.ts b/packages/adt-mcp/tests/flow-checkout-tr.test.ts index 2c8fbc56..fc23eb17 100644 --- a/packages/adt-mcp/tests/flow-checkout-tr.test.ts +++ b/packages/adt-mcp/tests/flow-checkout-tr.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { mkdtemp, realpath } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import test from 'node:test'; +import { test } from 'vitest'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { AdtClient } from '@abapify/adt-client'; import type { FormatPlugin } from '@abapify/adt-plugin'; diff --git a/packages/adt-mcp/tests/http-auth.test.ts b/packages/adt-mcp/tests/http-auth.test.ts index 42d316b1..9c5e4793 100644 --- a/packages/adt-mcp/tests/http-auth.test.ts +++ b/packages/adt-mcp/tests/http-auth.test.ts @@ -16,7 +16,7 @@ * an allowed (authenticated) call, and 401 for an unauthenticated one. * That's enough to distinguish "auth passed" from "auth blocked". */ -import { describe, it, before, after } from 'node:test'; +import { describe, it, beforeAll, afterAll } from 'vitest'; import { tlsFetch, startTestServer } from './_tls-fixtures.js'; import assert from 'node:assert'; @@ -82,12 +82,12 @@ async function probeMcp( describe('adt-mcp HTTP auth — mode=none (default)', () => { let server: RunningHttpServer; - before(async () => { + beforeAll(async () => { server = await startTestServer({ registry: emptyRegistry(), }); }); - after(async () => { + afterAll(async () => { await server.close(); }); @@ -109,14 +109,14 @@ describe('adt-mcp HTTP auth — mode=none (default)', () => { describe('adt-mcp HTTP auth — mode=bearer', () => { let server: RunningHttpServer; const token = 'super-secret-test-token-abc123'; - before(async () => { + beforeAll(async () => { server = await startTestServer({ authMode: 'bearer', authToken: token, registry: emptyRegistry(), }); }); - after(async () => { + afterAll(async () => { await server.close(); }); @@ -169,13 +169,13 @@ describe('adt-mcp HTTP auth — mode=bearer', () => { describe('adt-mcp HTTP auth — mode=proxy (trustForwardedAuth)', () => { let server: RunningHttpServer; - before(async () => { + beforeAll(async () => { server = await startTestServer({ trustForwardedAuth: true, registry: emptyRegistry(), }); }); - after(async () => { + afterAll(async () => { await server.close(); }); @@ -196,13 +196,13 @@ describe('adt-mcp HTTP auth — mode=proxy (trustForwardedAuth)', () => { describe('adt-mcp HTTP — CORS', () => { let server: RunningHttpServer; - before(async () => { + beforeAll(async () => { server = await startTestServer({ allowedOrigins: ['https://app.example.com'], registry: emptyRegistry(), }); }); - after(async () => { + afterAll(async () => { await server.close(); }); diff --git a/packages/adt-mcp/tests/http-changeset.test.ts b/packages/adt-mcp/tests/http-changeset.test.ts index 9b45232a..323303b9 100644 --- a/packages/adt-mcp/tests/http-changeset.test.ts +++ b/packages/adt-mcp/tests/http-changeset.test.ts @@ -11,7 +11,7 @@ * - rollback path releases the lock on the mock server */ -import { describe, it, before, after } from 'node:test'; +import { describe, it, beforeAll, afterAll } from 'vitest'; import { createTlsTransport, startTestServer } from './_tls-fixtures.js'; import assert from 'node:assert'; @@ -53,7 +53,7 @@ function parseToolText(result: unknown): { json: unknown; isError: boolean } { } describe('adt-mcp HTTP — Wave 3 changesets', () => { - before(async () => { + beforeAll(async () => { mockAdt = createMockAdtServer(); const info = await mockAdt.start(); mockPort = info.port; @@ -65,7 +65,7 @@ describe('adt-mcp HTTP — Wave 3 changesets', () => { }); }); - after(async () => { + afterAll(async () => { await http?.close(); await mockAdt?.stop(); }); diff --git a/packages/adt-mcp/tests/http-destination-scope.test.ts b/packages/adt-mcp/tests/http-destination-scope.test.ts index 228260cb..977dd82f 100644 --- a/packages/adt-mcp/tests/http-destination-scope.test.ts +++ b/packages/adt-mcp/tests/http-destination-scope.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import test from 'node:test'; +import { test } from 'vitest'; import { tlsFetch, createTlsTransport, diff --git a/packages/adt-mcp/tests/http-handler.test.ts b/packages/adt-mcp/tests/http-handler.test.ts index 16655efe..aa63f426 100644 --- a/packages/adt-mcp/tests/http-handler.test.ts +++ b/packages/adt-mcp/tests/http-handler.test.ts @@ -2,7 +2,7 @@ * Verifies that the HTTP MCP transport can be composed under a listener that * is owned by the embedding application. */ -import { after, describe, it } from 'node:test'; +import { afterAll, describe, it } from 'vitest'; import assert from 'node:assert'; import http from 'node:http'; import { createHttpMcpHandler } from '../src/lib/http/server.js'; @@ -11,7 +11,7 @@ import { createSessionRegistry } from '../src/lib/session/registry.js'; describe('createHttpMcpHandler', () => { const listeners: http.Server[] = []; - after(async () => { + afterAll(async () => { await Promise.all( listeners.map(async (listener) => { if (!listener.listening) return; diff --git a/packages/adt-mcp/tests/http-integration.test.ts b/packages/adt-mcp/tests/http-integration.test.ts index 8e952588..54996512 100644 --- a/packages/adt-mcp/tests/http-integration.test.ts +++ b/packages/adt-mcp/tests/http-integration.test.ts @@ -7,7 +7,7 @@ * sap_disconnect lifecycle end-to-end. */ -import { describe, it, before, after } from 'node:test'; +import { describe, it, beforeAll, afterAll } from 'vitest'; import { createTlsTransport, startTestServer } from './_tls-fixtures.js'; import assert from 'node:assert'; @@ -41,7 +41,7 @@ function buildMockParams(): ConnectionParams { } describe('adt-mcp HTTP integration', () => { - before(async () => { + beforeAll(async () => { mockAdt = createMockAdtServer(); const info = await mockAdt.start(); mockPort = info.port; @@ -60,7 +60,7 @@ describe('adt-mcp HTTP integration', () => { }); }); - after(async () => { + afterAll(async () => { await http?.close(); await mockAdt?.stop(); }); diff --git a/packages/adt-mcp/tests/http-invocation-auth.test.ts b/packages/adt-mcp/tests/http-invocation-auth.test.ts index 7c51623b..7420fcb3 100644 --- a/packages/adt-mcp/tests/http-invocation-auth.test.ts +++ b/packages/adt-mcp/tests/http-invocation-auth.test.ts @@ -4,7 +4,7 @@ * and scope, and that a session cannot be continued with another JTI. */ import assert from 'node:assert/strict'; -import test from 'node:test'; +import { test } from 'vitest'; import { tlsFetch, createTlsTransport, diff --git a/packages/adt-mcp/tests/http-invocation.test.ts b/packages/adt-mcp/tests/http-invocation.test.ts index c14797aa..1e3115ec 100644 --- a/packages/adt-mcp/tests/http-invocation.test.ts +++ b/packages/adt-mcp/tests/http-invocation.test.ts @@ -3,7 +3,7 @@ * real ES256 JWS values — the verifier never trusts decoded, unsigned data. */ import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; +import { beforeAll, describe, it } from 'vitest'; import { generateKeyPair, SignJWT, @@ -25,7 +25,7 @@ let privateKey: CryptoKey; let publicKey: CryptoKey; let verifier: ReturnType; -before(async () => { +beforeAll(async () => { ({ privateKey, publicKey } = await generateKeyPair('ES256')); verifier = createMcpInvocationVerifier({ publicKey, @@ -106,8 +106,11 @@ describe('MCP invocation verifier', () => { classes: ['server', 'read'], destinationKeys: ['trl-rise'], correlationId: 'correlation-001', - constraint: { systemSid: 'TRL', frozenScope: ['ZCL_ADT_REVIEW'] }, - limits: { maxSourceBytes: 65_536 }, + constraint: Object.assign(Object.create(null), { + systemSid: 'TRL', + frozenScope: ['ZCL_ADT_REVIEW'], + }), + limits: Object.assign(Object.create(null), { maxSourceBytes: 65_536 }), }); assert.ok(verified); assert.ok(Object.isFrozen(verified)); diff --git a/packages/adt-mcp/tests/http-oauth.test.ts b/packages/adt-mcp/tests/http-oauth.test.ts index 4abd6978..edb51ff5 100644 --- a/packages/adt-mcp/tests/http-oauth.test.ts +++ b/packages/adt-mcp/tests/http-oauth.test.ts @@ -13,7 +13,7 @@ * the `onOAuthUserHint` internal hook to verify the claim-extraction * logic end-to-end. */ -import { describe, it, before, after } from 'node:test'; +import { describe, it, beforeAll, afterAll } from 'vitest'; import { tlsFetch, startTestServer } from './_tls-fixtures.js'; import assert from 'node:assert'; @@ -154,7 +154,7 @@ describe('adt-mcp HTTP auth — mode=oauth (JWKS explicit)', () => { let server: RunningHttpServer; const capturedHints: (UserHint | undefined)[] = []; - before(async () => { + beforeAll(async () => { __resetOAuthDiscoveryCacheForTests(); idp = await startMockIdp(); server = await startTestServer({ @@ -170,7 +170,7 @@ describe('adt-mcp HTTP auth — mode=oauth (JWKS explicit)', () => { registry: emptyRegistry(), }); }); - after(async () => { + afterAll(async () => { await server.close(); await idp.close(); }); @@ -274,7 +274,7 @@ describe('adt-mcp HTTP auth — mode=oauth (OIDC discovery fallback)', () => { let idp: MockIdp; let server: RunningHttpServer; - before(async () => { + beforeAll(async () => { __resetOAuthDiscoveryCacheForTests(); idp = await startMockIdp(); // No jwksUri → force discovery. @@ -287,7 +287,7 @@ describe('adt-mcp HTTP auth — mode=oauth (OIDC discovery fallback)', () => { registry: emptyRegistry(), }); }); - after(async () => { + afterAll(async () => { await server.close(); await idp.close(); }); diff --git a/packages/adt-mcp/tests/integration.test.ts b/packages/adt-mcp/tests/integration.test.ts index bb0fe45e..fcfde9a8 100644 --- a/packages/adt-mcp/tests/integration.test.ts +++ b/packages/adt-mcp/tests/integration.test.ts @@ -1,7 +1,7 @@ /** * Integration tests for the adt-mcp package. * - * Uses node:test (native Node.js test runner) and the MCP SDK's + * Uses vitest and the MCP SDK's * InMemoryTransport so we can exercise every tool without stdio. * * A lightweight mock ADT HTTP server provides fixture responses. diff --git a/packages/adt-mcp/tests/safe-execute-enforcement.test.ts b/packages/adt-mcp/tests/safe-execute-enforcement.test.ts index ca10dda6..b7f7fc7f 100644 --- a/packages/adt-mcp/tests/safe-execute-enforcement.test.ts +++ b/packages/adt-mcp/tests/safe-execute-enforcement.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; +import { describe, it } from 'vitest'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { destinationModeServer } from '../src/lib/tools/destination-mode.js'; import { registerAtcRunTool } from '../src/lib/tools/atc-run.js'; diff --git a/packages/adt-mcp/tests/scope-enforcement.test.ts b/packages/adt-mcp/tests/scope-enforcement.test.ts index 2d0e8ef5..add37709 100644 --- a/packages/adt-mcp/tests/scope-enforcement.test.ts +++ b/packages/adt-mcp/tests/scope-enforcement.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import test from 'node:test'; +import { test } from 'vitest'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -80,7 +80,7 @@ test('a read-scoped caller can dispatch a permitted read tool', async () => { assert.strictEqual(calls, 1); }); -test('ATC analysis is permitted by ordinary read authority', async () => { +test('ATC analysis is denied to ordinary read authority', async () => { const target = new CapturingServer(); let calls = 0; const readServer = destinationModeServer(target as unknown as McpServer, { @@ -91,16 +91,15 @@ test('ATC analysis is permitted by ordinary read authority', async () => { return { content: [{ type: 'text' as const, text: 'permitted' }] }; }); - const permitted = await target.handlers.get('atc_run')!( + const denied = await target.handlers.get('atc_run')!( { destination: 'dev' }, { sessionId: 'session-1' }, ); - assert.notStrictEqual(permitted.isError, true); - assert.strictEqual(permitted.content[0]?.text, 'permitted'); - assert.strictEqual(calls, 1); + assert.strictEqual(denied.isError, true); + assert.strictEqual(calls, 0); assert.strictEqual( MCP_TOOL_SCOPE_CATALOGUE.run_unit_tests?.operationClass, - 'read', + 'safe_execute', ); }); @@ -186,6 +185,60 @@ test('a caller with malformed trusted classes is denied without throwing', async assert.strictEqual(handlerCalls, 0); }); +test('a read-scoped caller cannot dispatch unbounded read tools even if toolNames names them', async () => { + // The adt-execution contract whitelist only admits get_object / + // get_object_structure. This simulates a widened whitelist naming a CTS + // transport read: dispatch must still fail closed at the resource layer + // because a transport number is not a bindable canonical object key. + const target = new CapturingServer(); + const access: McpRequestAccess = { + classes: ['server', 'read'], + destinationKeys: ['dev'], + scoped: { + tokenId: 'read-jti', + principal: 'engineer@example.invalid', + correlationId: 'scoped:execution:read', + scopeId: '11111111-1111-4111-8111-111111111111', + executionId: '22222222-2222-4222-8222-222222222222', + systemSid: 'TST', + resourceKeys: ['CLAS:ZCL_RELEASE_GATE'], + toolNames: ['get_object', 'cts_get_transport'], + operationClass: 'read', + maxToolCalls: 5, + }, + }; + const server = destinationModeServer(target as unknown as McpServer, { + requestAccess: () => access, + }); + let handlerCalls = 0; + server.tool('cts_get_transport', {}, async () => { + handlerCalls++; + return { content: [{ type: 'text' as const, text: 'unexpected' }] }; + }); + server.tool('get_object', {}, async () => ({ + content: [{ type: 'text' as const, text: 'permitted' }], + })); + + const denied = await target.handlers.get('cts_get_transport')!( + { destination: 'dev', transport: 'DEVK900123' }, + { sessionId: 'session-1' }, + ); + assert.strictEqual(denied.isError, true); + assert.strictEqual(denied.content[0]?.text, 'mcp_scope_denied'); + assert.strictEqual(handlerCalls, 0); + + const permitted = await target.handlers.get('get_object')!( + { + destination: 'dev', + objectType: 'CLAS', + objectName: 'ZCL_RELEASE_GATE', + }, + { sessionId: 'session-1' }, + ); + assert.strictEqual(permitted.isError, undefined); + assert.strictEqual(permitted.content[0]?.text, 'permitted'); +}); + test('a direct write dispatch is denied before its handler or destination lease', async () => { let leases = 0; const destinations = destinationRegistry(() => leases++); diff --git a/packages/adt-mcp/tests/source-capabilities.test.ts b/packages/adt-mcp/tests/source-capabilities.test.ts index 0015281c..e9fe4734 100644 --- a/packages/adt-mcp/tests/source-capabilities.test.ts +++ b/packages/adt-mcp/tests/source-capabilities.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import test from 'node:test'; +import { test } from 'vitest'; import { SourceCapabilityError, createSourceCapabilityRegistry, diff --git a/packages/adt-mcp/tests/source-version-metadata.test.ts b/packages/adt-mcp/tests/source-version-metadata.test.ts index e965fae8..46edf53e 100644 --- a/packages/adt-mcp/tests/source-version-metadata.test.ts +++ b/packages/adt-mcp/tests/source-version-metadata.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import test from 'node:test'; +import { test } from 'vitest'; import { toMcpSourceVersionListing } from '../src/lib/tools/list-source-versions.js'; test('source-version metadata never exposes a component or immutable source URI', () => { diff --git a/packages/adt-mcp/tests/transport-source-manifest-capabilities.test.ts b/packages/adt-mcp/tests/transport-source-manifest-capabilities.test.ts index c875ec61..de9b0e26 100644 --- a/packages/adt-mcp/tests/transport-source-manifest-capabilities.test.ts +++ b/packages/adt-mcp/tests/transport-source-manifest-capabilities.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import test from 'node:test'; +import { test } from 'vitest'; import { toMcpTransportSourceManifest } from '../src/lib/tools/cts-transport-source-manifest.js'; import { createSourceCapabilityRegistry } from '../src/lib/source-capabilities.js'; diff --git a/packages/adt-mcp/vitest.config.ts b/packages/adt-mcp/vitest.config.ts index d0b99179..8996a048 100644 --- a/packages/adt-mcp/vitest.config.ts +++ b/packages/adt-mcp/vitest.config.ts @@ -4,6 +4,6 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['tests/integration.test.ts'], + include: ['tests/**/*.test.ts'], }, });