diff --git a/docker-entrypoint.adt-server.sh b/docker-entrypoint.adt-server.sh index bc0ee740..7ee0cb2e 100644 --- a/docker-entrypoint.adt-server.sh +++ b/docker-entrypoint.adt-server.sh @@ -16,7 +16,7 @@ fi # neither mounted nor accepted by the runtime configuration. case "${ADT_SERVER_MCP_ENABLED:-false}" in false) - unset ADT_SERVER_MCP_PUBLIC_KEY_FILE ADT_SERVER_MCP_KEY_ID ADT_SERVER_MCP_ISSUER ADT_SERVER_MCP_ALLOWED_HOSTS + unset ADT_SERVER_MCP_PUBLIC_KEY_FILE ADT_SERVER_MCP_KEY_ID ADT_SERVER_MCP_ISSUER ADT_SERVER_MCP_ALLOWED_HOSTS ADT_SERVER_MCP_SAFE_EXECUTE_ENABLED ADT_SERVER_MCP_AUTHORIZATION_CONSUMER_URL ADT_SERVER_MCP_OUTCOME_REPORTER_URL ;; true) if [ -z "${ADT_SERVER_MCP_PUBLIC_KEY_FILE:-}" ] || [ ! -s "$ADT_SERVER_MCP_PUBLIC_KEY_FILE" ]; then diff --git a/packages/adt-server/src/mcp-runtime.ts b/packages/adt-server/src/mcp-runtime.ts index 873d37e1..9ef082b1 100644 --- a/packages/adt-server/src/mcp-runtime.ts +++ b/packages/adt-server/src/mcp-runtime.ts @@ -142,6 +142,74 @@ function allowedHostsFromEnv(env: RuntimeEnvironment): string[] | undefined { return [...new Set(hosts)]; } +type ConsumeExecutionAuthorization = NonNullable< + ToolContext['consumeExecutionAuthorization'] +>; +type ReportExecutionOutcome = NonNullable< + ToolContext['reportExecutionOutcome'] +>; + +function createHttpAuthorizationConsumer( + url: string, + fetchImpl: typeof globalThis.fetch = globalThis.fetch, +): ConsumeExecutionAuthorization { + const endpoint = new URL(url).toString(); + return async (input) => { + const response = await fetchImpl(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${input.authorizationToken}`, + }, + body: JSON.stringify(input), + }); + return response.ok; + }; +} + +function createHttpOutcomeReporter( + url: string, + fetchImpl: typeof globalThis.fetch = globalThis.fetch, +): ReportExecutionOutcome { + const endpoint = new URL(url).toString(); + return async (input) => { + const response = await fetchImpl(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${input.authorizationToken}`, + }, + body: JSON.stringify(input), + }); + return response.ok; + }; +} + +function safeExecuteHooksFromEnv( + env: RuntimeEnvironment, + fetchImpl: typeof globalThis.fetch = globalThis.fetch, +): { + consumeExecutionAuthorization?: ConsumeExecutionAuthorization; + reportExecutionOutcome?: ReportExecutionOutcome; +} { + const consumerUrl = configuredValue( + env, + 'ADT_SERVER_MCP_AUTHORIZATION_CONSUMER_URL', + ); + const reporterUrl = configuredValue( + env, + 'ADT_SERVER_MCP_OUTCOME_REPORTER_URL', + ); + return { + consumeExecutionAuthorization: consumerUrl + ? createHttpAuthorizationConsumer(consumerUrl, fetchImpl) + : undefined, + reportExecutionOutcome: reporterUrl + ? createHttpOutcomeReporter(reporterUrl, fetchImpl) + : undefined, + }; +} + function safeExecuteEnabled(env: RuntimeEnvironment): boolean { const configured = configuredValue( env, @@ -198,10 +266,16 @@ export async function createAdtServerMcpOptions( } const executeWithDeadline = options.executeWithDeadline ?? executeWithDeadlineAndAbort; + const envHooks = enableSafeExecute + ? safeExecuteHooksFromEnv(options.env) + : undefined; const safeExecuteOptions = (() => { if (!enableSafeExecute) return {}; - const consumeExecutionAuthorization = options.consumeExecutionAuthorization; - const reportExecutionOutcome = options.reportExecutionOutcome; + const consumeExecutionAuthorization = + options.consumeExecutionAuthorization ?? + envHooks?.consumeExecutionAuthorization; + const reportExecutionOutcome = + options.reportExecutionOutcome ?? envHooks?.reportExecutionOutcome; if (!consumeExecutionAuthorization || !reportExecutionOutcome) { throw new Error( 'ADT Server MCP safe_execute requires deployment-owned authorization and outcome hooks', diff --git a/packages/adt-server/tests/mcp-runtime.test.ts b/packages/adt-server/tests/mcp-runtime.test.ts index 9e30d9a1..83b63c0f 100644 --- a/packages/adt-server/tests/mcp-runtime.test.ts +++ b/packages/adt-server/tests/mcp-runtime.test.ts @@ -271,6 +271,187 @@ test('safe_execute preserves injected authorization hooks unchanged', async () = } }); +test('safe_execute creates HTTP hooks from environment URLs', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'adt-server-mcp-')); + const publicKeyPath = path.join(directory, 'public.pem'); + const { publicKey } = generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + await writeFile( + publicKeyPath, + publicKey.export({ type: 'spki', format: 'pem' }), + 'utf8', + ); + + const fetchCalls: { url: string; init: RequestInit }[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + fetchCalls.push({ url: input.toString(), init: init as RequestInit }); + return new Response(null, { status: 204 }); + }; + + try { + const options = await createAdtServerMcpOptions({ + env: { + ADT_SERVER_MCP_PUBLIC_KEY_FILE: publicKeyPath, + ADT_SERVER_MCP_KEY_ID: 'adt-mcp-test', + ADT_SERVER_MCP_ISSUER: 'adt-api', + ADT_SERVER_MCP_SAFE_EXECUTE_ENABLED: 'true', + ADT_SERVER_MCP_AUTHORIZATION_CONSUMER_URL: 'http://auth.test/consume', + ADT_SERVER_MCP_OUTCOME_REPORTER_URL: 'http://auth.test/report', + }, + brokerOptions, + }); + + assert.ok(options); + assert.strictEqual( + typeof options?.consumeExecutionAuthorization, + 'function', + ); + assert.strictEqual(typeof options?.reportExecutionOutcome, 'function'); + + const consumeInput = { + authorizationId: 'auth-1', + authorizationToken: 'token-abc', + principal: 'user-1', + scopeId: '11111111-1111-4111-8111-111111111111', + executionId: '22222222-2222-4222-8222-222222222222', + systemSid: 'DEV', + resourceKeys: ['CLAS:ZCL_FOO'], + destination: 'dev', + operationId: 'atc_run' as const, + policy: { + operationId: 'atc_run' as const, + check: 'atc' as const, + maxDurationMs: 10_000, + maxResultBytes: 1_000_000, + maxFindings: 100, + maxObjects: 100, + maxPackages: 10, + maxVariants: 5, + }, + }; + const consumeResult = await options?.consumeExecutionAuthorization?.( + consumeInput as any, + ); + assert.strictEqual(consumeResult, true); + assert.strictEqual(fetchCalls.length, 1); + assert.strictEqual(fetchCalls[0].url, 'http://auth.test/consume'); + assert.strictEqual(fetchCalls[0].init.method, 'POST'); + assert.strictEqual( + (fetchCalls[0].init.headers as Record).Authorization, + 'Bearer token-abc', + ); + const consumeBody = JSON.parse(fetchCalls[0].init.body as string); + assert.strictEqual(consumeBody.authorizationId, 'auth-1'); + assert.strictEqual(consumeBody.operationId, 'atc_run'); + + const reportInput = { + authorizationId: 'auth-1', + authorizationToken: 'token-abc', + outcome: 'succeeded' as const, + }; + const reportResult = await options?.reportExecutionOutcome?.( + reportInput as any, + ); + assert.strictEqual(reportResult, true); + assert.strictEqual(fetchCalls.length, 2); + assert.strictEqual(fetchCalls[1].url, 'http://auth.test/report'); + const reportBody = JSON.parse(fetchCalls[1].init.body as string); + assert.strictEqual(reportBody.outcome, 'succeeded'); + } finally { + globalThis.fetch = originalFetch; + await rm(directory, { recursive: true, force: true }); + } +}); + +test('safe_execute HTTP hooks return false on non-2xx responses', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'adt-server-mcp-')); + const publicKeyPath = path.join(directory, 'public.pem'); + const { publicKey } = generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + await writeFile( + publicKeyPath, + publicKey.export({ type: 'spki', format: 'pem' }), + 'utf8', + ); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(null, { status: 403 }); + + try { + const options = await createAdtServerMcpOptions({ + env: { + ADT_SERVER_MCP_PUBLIC_KEY_FILE: publicKeyPath, + ADT_SERVER_MCP_KEY_ID: 'adt-mcp-test', + ADT_SERVER_MCP_ISSUER: 'adt-api', + ADT_SERVER_MCP_SAFE_EXECUTE_ENABLED: 'true', + ADT_SERVER_MCP_AUTHORIZATION_CONSUMER_URL: 'http://auth.test/consume', + ADT_SERVER_MCP_OUTCOME_REPORTER_URL: 'http://auth.test/report', + }, + brokerOptions, + }); + + const result = await options?.consumeExecutionAuthorization?.({ + authorizationId: 'auth-1', + authorizationToken: 'token-abc', + principal: 'user-1', + scopeId: '11111111-1111-4111-8111-111111111111', + executionId: '22222222-2222-4222-8222-222222222222', + systemSid: 'DEV', + resourceKeys: ['CLAS:ZCL_FOO'], + destination: 'dev', + operationId: 'atc_run', + policy: { + operationId: 'atc_run', + check: 'atc', + maxDurationMs: 10_000, + maxResultBytes: 1_000_000, + maxFindings: 100, + maxObjects: 100, + maxPackages: 10, + maxVariants: 5, + }, + } as any); + assert.strictEqual(result, false); + } finally { + globalThis.fetch = originalFetch; + await rm(directory, { recursive: true, force: true }); + } +}); + +test('safe_execute still requires hooks when only one environment URL is set', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'adt-server-mcp-')); + const publicKeyPath = path.join(directory, 'public.pem'); + const { publicKey } = generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + await writeFile( + publicKeyPath, + publicKey.export({ type: 'spki', format: 'pem' }), + 'utf8', + ); + + try { + await assert.rejects( + createAdtServerMcpOptions({ + env: { + ADT_SERVER_MCP_PUBLIC_KEY_FILE: publicKeyPath, + ADT_SERVER_MCP_KEY_ID: 'adt-mcp-test', + ADT_SERVER_MCP_ISSUER: 'adt-api', + ADT_SERVER_MCP_SAFE_EXECUTE_ENABLED: 'true', + ADT_SERVER_MCP_AUTHORIZATION_CONSUMER_URL: 'http://auth.test/consume', + }, + brokerOptions, + }), + /deployment-owned authorization and outcome hooks/i, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + test('rejects a non-P-256 public key before it can enable MCP', async () => { const directory = await mkdtemp(path.join(os.tmpdir(), 'adt-server-mcp-')); const publicKeyPath = path.join(directory, 'public.pem');