diff --git a/packages/adt-client/src/adapter.ts b/packages/adt-client/src/adapter.ts index f608df2ac..cb7dedafa 100644 --- a/packages/adt-client/src/adapter.ts +++ b/packages/adt-client/src/adapter.ts @@ -10,6 +10,7 @@ import type { HttpAdapter, HttpRequestOptions } from '@abapify/adt-contracts'; import type { ResponsePlugin, ResponseContext } from './plugins/types'; import { SessionManager } from './utils/session'; import { createAdtError } from './errors'; +import { activeAdtAbortSignal } from './cancellation'; // Re-export HttpAdapter type for consumers /** @@ -123,6 +124,32 @@ async function readResponseTextBounded( return new TextDecoder().decode(combined); } +function executionAbortController(): { + abortController: AbortController; + dispose: () => void; +} { + const abortController = new AbortController(); + const executionSignal = activeAdtAbortSignal(); + if (!executionSignal) { + return { abortController, dispose: () => undefined }; + } + + const abortFromExecution = () => + abortController.abort(executionSignal.reason); + if (executionSignal.aborted) { + abortFromExecution(); + } else { + executionSignal.addEventListener('abort', abortFromExecution, { + once: true, + }); + } + return { + abortController, + dispose: () => + executionSignal.removeEventListener('abort', abortFromExecution), + }; +} + /** * Create ADT HTTP adapter with Basic or SAML Authentication and plugin support */ @@ -176,6 +203,8 @@ export function createAdtAdapter(config: AdtAdapterConfig): AdtHttpAdapter { async request( options: HttpRequestOptions, ): Promise { + const executionSignal = activeAdtAbortSignal(); + executionSignal?.throwIfAborted(); logger?.debug('=== ADAPTER REQUEST START ==='); logger?.debug('options.url:', options.url); logger?.debug('options.method:', options.method); @@ -218,12 +247,12 @@ export function createAdtAdapter(config: AdtAdapterConfig): AdtHttpAdapter { logger?.debug( 'Adapter: Initializing CSRF token before write operation', ); - await sessionManager.initializeCsrf( - baseUrl, + await sessionManager.initializeCsrf(baseUrl, { authHeader, // undefined for cookie auth — SessionManager uses stored cookies client, language, - ); + signal: executionSignal, + }); } // Prepare headers (pass URL for ETag lookup on PUT/PATCH) @@ -241,8 +270,7 @@ export function createAdtAdapter(config: AdtAdapterConfig): AdtHttpAdapter { // Get schemas from speci's standard fields // Check if bodySchema is Serializable (has build method from adt-schemas) let bodySerializableSchema: - | { build: (data: unknown) => string } - | undefined; + { build: (data: unknown) => string } | undefined; if (options.bodySchema && typeof options.bodySchema === 'object') { if ( 'build' in options.bodySchema && @@ -363,6 +391,7 @@ export function createAdtAdapter(config: AdtAdapterConfig): AdtHttpAdapter { method: options.method, headers, body: requestBody, + signal: executionSignal, }); // Process response for session management (cookies, CSRF, ETags) @@ -538,32 +567,36 @@ export function createAdtAdapter(config: AdtAdapterConfig): AdtHttpAdapter { }; if (authHeader) headers.Authorization = authHeader; - const abortController = new AbortController(); - // nosemgrep - const response = await fetch(url, { - method: 'GET', - headers, - signal: abortController.signal, - }); - sessionManager.processResponse(response, url.pathname); - - const text = await readResponseTextBounded( - response, - maxBytes, - abortController, - ); + const { abortController, dispose } = executionAbortController(); + try { + // nosemgrep + const response = await fetch(url, { + method: 'GET', + headers, + signal: abortController.signal, + }); + sessionManager.processResponse(response, url.pathname); - if (!response.ok) { - throw createAdtError( - response.status, - response.statusText, - url.toString(), - 'GET', - text, + const text = await readResponseTextBounded( + response, + maxBytes, + abortController, ); - } - return text; + if (!response.ok) { + throw createAdtError( + response.status, + response.statusText, + url.toString(), + 'GET', + text, + ); + } + + return text; + } finally { + dispose(); + } }, }; } diff --git a/packages/adt-client/src/cancellation.ts b/packages/adt-client/src/cancellation.ts new file mode 100644 index 000000000..ba0b965cd --- /dev/null +++ b/packages/adt-client/src/cancellation.ts @@ -0,0 +1,20 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +const adtAbortSignal = new AsyncLocalStorage(); + +/** + * Runs an ADT operation with an execution-scoped abort signal. Async local + * storage keeps concurrent clients and invocations isolated without mutating + * shared client state. + */ +export async function runWithAdtAbortSignal( + signal: AbortSignal, + operation: () => Promise, +): Promise { + signal.throwIfAborted(); + return await adtAbortSignal.run(signal, operation); +} + +export function activeAdtAbortSignal(): AbortSignal | undefined { + return adtAbortSignal.getStore(); +} diff --git a/packages/adt-client/src/index.ts b/packages/adt-client/src/index.ts index 8c410cd40..93e19b365 100644 --- a/packages/adt-client/src/index.ts +++ b/packages/adt-client/src/index.ts @@ -35,6 +35,7 @@ export { type AdtHttpAdapter, type BoundedTextRequestOptions, } from './adapter'; +export { runWithAdtAbortSignal } from './cancellation'; // Export plugins export { diff --git a/packages/adt-client/src/utils/session.ts b/packages/adt-client/src/utils/session.ts index f257cf495..7daae1403 100644 --- a/packages/adt-client/src/utils/session.ts +++ b/packages/adt-client/src/utils/session.ts @@ -280,6 +280,9 @@ export class ETagManager { * Session Manager - Orchestrates cookies and CSRF tokens */ export class SessionManager { + private static readonly SESSION_PATH_PREFIX = + '/sap/bc/adt/core/http/sessions/'; + private readonly cookieStore = new CookieStore(); private readonly csrfManager = new CsrfTokenManager(); private readonly etagManager = new ETagManager(); @@ -390,6 +393,48 @@ export class SessionManager { this.etagManager.clear(url); } + /** + * Reject session paths that could send cleanup requests off-target. + * Only relative paths under the ADT sessions endpoint (or same-origin + * absolute URLs with that path) are accepted. + */ + private isTrustedSessionPath({ + baseUrl, + sessionPath, + }: { + baseUrl: string; + sessionPath: string; + }): boolean { + try { + const parsed = new URL(sessionPath, baseUrl); + const base = new URL(baseUrl); + return ( + parsed.origin === base.origin && + parsed.pathname.startsWith(SessionManager.SESSION_PATH_PREFIX) + ); + } catch { + return false; + } + } + + private extractTrustedSessionPath({ + baseUrl, + body, + }: { + baseUrl: string; + body: string; + }): string | undefined { + const match = body.match(/href="([^"]*\/sessions\/[^"]*)"/); + const sessionPath = match?.[1]; + if (!sessionPath || !this.isTrustedSessionPath({ baseUrl, sessionPath })) { + this.logger?.warn( + 'Session: Created security session returned an untrusted path', + ); + return undefined; + } + return sessionPath; + } + /** * Initialize CSRF token using the Eclipse ADT security session flow: * @@ -400,132 +445,357 @@ export class SessionManager { * The CSRF token survives the session deletion and remains valid for * all subsequent lock/unlock operations. Deleting the session frees * the slot — SAP allows only one security session per user. - * - * @param baseUrl - SAP system base URL - * @param authHeader - Authorization header (Basic/Bearer), or undefined for cookie auth - * @param client - SAP client number - * @param language - SAP language */ + private resolveInitializeCsrfArgs(...args: unknown[]): { + authHeader?: string; + client?: string; + language?: string; + signal?: AbortSignal; + } { + if (args.length === 1) { + const first = args[0]; + if (first !== null) { + if (typeof first === 'object') { + return first as { + authHeader?: string; + client?: string; + language?: string; + signal?: AbortSignal; + }; + } + } + } + return { + authHeader: typeof args[0] === 'string' ? args[0] : undefined, + client: typeof args[1] === 'string' ? args[1] : undefined, + language: typeof args[2] === 'string' ? args[2] : undefined, + signal: args[3] instanceof AbortSignal ? args[3] : undefined, + }; + } + async initializeCsrf( baseUrl: string, authHeader?: string, client?: string, language?: string, - ): Promise { - const sessionsUrl = new URL('/sap/bc/adt/core/http/sessions', baseUrl); + signal?: AbortSignal, + ): Promise; + async initializeCsrf( + baseUrl: string, + options?: { + authHeader?: string; + client?: string; + language?: string; + signal?: AbortSignal; + }, + ): Promise; + async initializeCsrf(baseUrl: string, ...args: unknown[]): Promise { + const { authHeader, client, language, signal } = + this.resolveInitializeCsrfArgs(...args); + + signal?.throwIfAborted(); + const sessionsUrl = this.buildSessionsUrl(baseUrl, client, language); + let sessionPath: string | undefined; - if (client) { - sessionsUrl.searchParams.append('sap-client', client); - } - if (language) { - sessionsUrl.searchParams.append('sap-language', language); + try { + sessionPath = await this.createSecuritySession( + baseUrl, + sessionsUrl, + authHeader, + signal, + ); + if (!sessionPath) return false; + if (!(await this.acquireCsrfToken(sessionsUrl, authHeader, signal))) { + this.deleteSecuritySessionWithFallback({ + sessionPath, + baseUrl, + authHeader, + client, + language, + }).catch(() => undefined); + return false; + } + + const acquiredCsrfToken = this.csrfManager.getCached(); + if (acquiredCsrfToken) { + await this.deleteSecuritySession({ + sessionPath, + csrfToken: acquiredCsrfToken, + baseUrl, + authHeader, + client, + signal, + }); + } + signal?.throwIfAborted(); + + return true; + } catch (error) { + return this.handleCsrfInitializationError(error, { + baseUrl, + authHeader, + client, + language, + sessionPath, + signal, + }); } + } - /** Build common headers, optionally including Authorization and cookies */ - const baseHeaders = (): Record => { - const h: Record = { - Accept: 'application/vnd.sap.adt.core.http.session.v3+xml', - 'X-sap-adt-sessiontype': 'stateful', - }; - if (authHeader) h.Authorization = authHeader; - const cookie = this.cookieStore.getCookieHeader(); - if (cookie) h.Cookie = cookie; - return h; + private buildSessionsUrl( + baseUrl: string, + client?: string, + language?: string, + ): URL { + const url = new URL('/sap/bc/adt/core/http/sessions', baseUrl); + if (client) url.searchParams.append('sap-client', client); + if (language) url.searchParams.append('sap-language', language); + return url; + } + + private buildSessionHeaders(authHeader?: string): Record { + const headers: Record = { + Accept: 'application/vnd.sap.adt.core.http.session.v3+xml', + 'X-sap-adt-sessiontype': 'stateful', }; + if (authHeader) headers.Authorization = authHeader; + const cookie = this.cookieStore.getCookieHeader(); + if (cookie) headers.Cookie = cookie; + return headers; + } + + private async createSecuritySession( + baseUrl: string, + sessionsUrl: URL, + authHeader?: string, + signal?: AbortSignal, + ): Promise { + this.logger?.debug('Session: Creating security session'); + // nosemgrep + const response = await fetch(sessionsUrl, { + method: 'GET', + headers: { + ...this.buildSessionHeaders(authHeader), + 'x-sap-security-session': 'create', + }, + signal, + }); + signal?.throwIfAborted(); + + if (!response.ok) { + this.logger?.warn( + `Session: Security session creation failed with status ${response.status}`, + ); + return undefined; + } + this.processResponse(response); + + const body = await response.text(); + signal?.throwIfAborted(); + return this.extractTrustedSessionPath({ baseUrl, body }); + } + + private async acquireCsrfToken( + sessionsUrl: URL, + authHeader?: string, + signal?: AbortSignal, + ): Promise { + this.logger?.debug('Session: Fetching CSRF token'); + // nosemgrep + const response = await fetch(sessionsUrl, { + method: 'GET', + headers: { + ...this.buildSessionHeaders(authHeader), + 'x-sap-security-session': 'use', + 'x-csrf-token': 'Fetch', + }, + signal, + }); + signal?.throwIfAborted(); + + if (!response.ok) { + this.logger?.warn( + `Session: CSRF fetch failed with status ${response.status}`, + ); + return false; + } + this.processResponse(response); + + if (!this.csrfManager.hasCached()) { + this.logger?.warn( + 'Session: CSRF fetch succeeded but no token found in response', + ); + return false; + } + + this.securitySessionActive = true; + this.logger?.debug('Session: CSRF token acquired'); + return true; + } + private async deleteSecuritySession({ + sessionPath, + csrfToken, + baseUrl, + authHeader, + client, + signal, + }: { + sessionPath: string; + csrfToken: string; + baseUrl: string; + authHeader?: string; + client?: string; + signal?: AbortSignal; + }): Promise { + if (!this.isTrustedSessionPath({ baseUrl, sessionPath })) { + this.logger?.warn( + 'Session: Refusing to delete security session with untrusted path', + ); + return; + } + const deleteUrl = new URL(sessionPath, baseUrl); + if (client) deleteUrl.searchParams.append('sap-client', client); try { - // ── Step 1: Create security session ────────────────────────── - this.logger?.debug('Session: Creating security session'); - const createResponse = await fetch(sessionsUrl.toString(), { - method: 'GET', + this.logger?.debug(`Session: Deleting security session ${sessionPath}`); + // nosemgrep + await fetch(deleteUrl, { + method: 'DELETE', headers: { - ...baseHeaders(), - 'x-sap-security-session': 'create', + ...this.buildSessionHeaders(authHeader), + 'x-sap-security-session': 'use', + 'x-csrf-token': csrfToken, }, + signal: signal ?? AbortSignal.timeout(5_000), }); - - if (!createResponse.ok) { - this.logger?.warn( - `Session: Security session creation failed with status ${createResponse.status}`, - ); - return false; - } - - // Extract cookies (sap-contextid) from the create response - this.processResponse(createResponse); - - // Extract session URL from response body for later DELETE - const createBody = await createResponse.text(); - const sessionHrefMatch = createBody.match( - /href="([^"]*\/sessions\/[^"]*)"/, + this.logger?.debug('Session: Security session deleted'); + } catch { + this.logger?.debug( + 'Session: Failed to delete security session (will expire)', ); - const sessionPath = sessionHrefMatch?.[1]; + } + } - // ── Step 2: Fetch CSRF token within the session ────────────── - this.logger?.debug('Session: Fetching CSRF token'); - const csrfResponse = await fetch(sessionsUrl.toString(), { + private async fetchSecuritySessionCsrfToken({ + baseUrl, + authHeader, + client, + language, + signal, + }: { + baseUrl: string; + authHeader?: string; + client?: string; + language?: string; + signal?: AbortSignal; + }): Promise { + const sessionsUrl = this.buildSessionsUrl(baseUrl, client, language); + try { + this.logger?.debug( + 'Session: Fetching CSRF token for security session cleanup', + ); + // nosemgrep + const response = await fetch(sessionsUrl, { method: 'GET', headers: { - ...baseHeaders(), + ...this.buildSessionHeaders(authHeader), 'x-sap-security-session': 'use', 'x-csrf-token': 'Fetch', }, + signal: signal ?? AbortSignal.timeout(5_000), }); - - if (!csrfResponse.ok) { - this.logger?.warn( - `Session: CSRF fetch failed with status ${csrfResponse.status}`, + if (!response.ok) { + this.logger?.debug( + `Session: Cleanup CSRF fetch failed with status ${response.status}`, ); - return false; - } - - this.processResponse(csrfResponse); - - const success = this.csrfManager.hasCached(); - if (!success) { - this.logger?.warn( - 'Session: CSRF fetch succeeded but no token found in response', - ); - return false; + return undefined; } + // Parse Set-Cookie for the cleanup session, but do not cache the + // CSRF token or mark the session active — cleanup must not pollute + // shared state that later requests rely on. + const setCookieHeader = response.headers.get('set-cookie'); + if (setCookieHeader) this.cookieStore.parseCookies(setCookieHeader); + return this.csrfManager.extractFromHeader( + response.headers.get('x-csrf-token'), + ); + } catch { + this.logger?.debug('Session: Failed to fetch cleanup CSRF token'); + return undefined; + } + } - this.securitySessionActive = true; - this.logger?.debug('Session: CSRF token acquired'); - - // ── Step 3: Delete the security session (token stays valid) ── - // Frees the session slot — SAP allows one security session per user. - if (sessionPath) { - const deleteUrl = new URL(sessionPath, baseUrl); - if (client) deleteUrl.searchParams.append('sap-client', client); - - try { - this.logger?.debug( - `Session: Deleting security session ${sessionPath}`, - ); - await fetch(deleteUrl.toString(), { - method: 'DELETE', - headers: { - ...baseHeaders(), - 'x-sap-security-session': 'use', - 'x-csrf-token': this.csrfManager.getCached()!, - }, - }); - this.logger?.debug('Session: Security session deleted'); - } catch { - // Best-effort — session will time out anyway - this.logger?.debug( - 'Session: Failed to delete security session (will expire)', - ); - } - } + private async deleteSecuritySessionWithFallback({ + sessionPath, + baseUrl, + authHeader, + client, + language, + signal, + }: { + sessionPath: string; + baseUrl: string; + authHeader?: string; + client?: string; + language?: string; + signal?: AbortSignal; + }): Promise { + // Always fetch a fresh cleanup token; the shared cache may contain a + // stale value and cleanup must never pollute shared CSRF/session state. + const token = await this.fetchSecuritySessionCsrfToken({ + baseUrl, + authHeader, + client, + language, + signal, + }); + if (token) { + await this.deleteSecuritySession({ + sessionPath, + csrfToken: token, + baseUrl, + authHeader, + client, + signal, + }); + } + } - return true; - } catch (error) { - this.logger?.error( - `Session: CSRF initialization error: ${error instanceof Error ? error.message : String(error)}`, - ); - return false; + private handleCsrfInitializationError( + error: unknown, + { + baseUrl, + authHeader, + client, + language, + sessionPath, + signal, + }: { + baseUrl: string; + authHeader?: string; + client?: string; + language?: string; + sessionPath?: string; + signal?: AbortSignal; + }, + ): boolean { + if (sessionPath) { + // Use a detached timeout for cleanup; the caller signal may already + // be aborted and we still want to free the SAP session slot. + this.deleteSecuritySessionWithFallback({ + sessionPath, + baseUrl, + authHeader, + client, + language, + }).catch(() => undefined); + } + if (signal?.aborted) { + throw error; } + this.logger?.error( + `Session: CSRF initialization error: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; } /** diff --git a/packages/adt-client/tests/adapter-cancellation.test.ts b/packages/adt-client/tests/adapter-cancellation.test.ts new file mode 100644 index 000000000..6a8af2dea --- /dev/null +++ b/packages/adt-client/tests/adapter-cancellation.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createAdtAdapter } from '../src/adapter'; +import { runWithAdtAbortSignal } from '../src/cancellation'; + +function waitForFetch(fetch: ReturnType, calls: number) { + return vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(calls)); +} + +describe('ADT execution-scoped cancellation', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('passes the execution abort signal to the SAP request and response body', async () => { + let requestSignal: AbortSignal | undefined; + let bodyController: ReadableStreamDefaultController | undefined; + const body = new ReadableStream({ + start(controller) { + bodyController = controller; + }, + }); + const fetch = vi.fn().mockImplementation((_input, init?: RequestInit) => { + requestSignal = init?.signal ?? undefined; + requestSignal?.addEventListener('abort', () => { + bodyController?.error(requestSignal?.reason); + }); + return Promise.resolve( + new Response(body, { headers: { 'content-type': 'text/plain' } }), + ); + }); + vi.stubGlobal('fetch', fetch); + const adapter = createAdtAdapter({ + baseUrl: 'https://sap.example.test', + username: 'test', + password: 'test', + }); + const abortController = new AbortController(); + + const request = runWithAdtAbortSignal(abortController.signal, () => + adapter.request({ + method: 'GET', + url: '/sap/bc/adt/repository/informationsystem/search', + }), + ); + await waitForFetch(fetch, 1); + abortController.abort(new Error('execution deadline exceeded')); + + await expect(request).rejects.toThrow('execution deadline exceeded'); + expect(requestSignal).toBe(abortController.signal); + }); + + it('aborts CSRF initialization without dispatching later session or write requests', async () => { + let requestSignal: AbortSignal | undefined; + const fetch = vi.fn().mockImplementation((_input, init?: RequestInit) => { + requestSignal = init?.signal ?? undefined; + return new Promise((_resolve, reject) => { + requestSignal?.addEventListener( + 'abort', + () => reject(requestSignal?.reason), + { once: true }, + ); + }); + }); + vi.stubGlobal('fetch', fetch); + const adapter = createAdtAdapter({ + baseUrl: 'https://sap.example.test', + username: 'test', + password: 'test', + }); + const abortController = new AbortController(); + + const request = runWithAdtAbortSignal(abortController.signal, () => + adapter.request({ + method: 'POST', + url: '/sap/bc/adt/atc/runs', + body: '', + }), + ); + await waitForFetch(fetch, 1); + abortController.abort(new Error('execution deadline exceeded')); + + await expect(request).rejects.toThrow('execution deadline exceeded'); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(fetch).toHaveBeenCalledTimes(1); + expect(requestSignal).toBe(abortController.signal); + }); + + it('isolates concurrent execution signals', async () => { + const observed = new Map< + string, + { + signal: AbortSignal; + resolve: (response: Response) => void; + reject: (error: unknown) => void; + } + >(); + const fetch = vi.fn().mockImplementation((input, init?: RequestInit) => { + const signal = init?.signal; + if (!signal) throw new Error('missing request signal'); + const url = String(input); + return new Promise((resolve, reject) => { + observed.set(url, { signal, resolve, reject }); + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }); + }); + }); + vi.stubGlobal('fetch', fetch); + const adapter = createAdtAdapter({ + baseUrl: 'https://sap.example.test', + username: 'test', + password: 'test', + }); + const firstController = new AbortController(); + const secondController = new AbortController(); + + const first = runWithAdtAbortSignal(firstController.signal, () => + adapter.request({ method: 'GET', url: '/first' }), + ); + const second = runWithAdtAbortSignal(secondController.signal, () => + adapter.request({ method: 'GET', url: '/second' }), + ); + await waitForFetch(fetch, 2); + firstController.abort(new Error('first deadline exceeded')); + + const firstRequest = observed.get('https://sap.example.test/first'); + const secondRequest = observed.get('https://sap.example.test/second'); + expect(firstRequest?.signal.aborted).toBe(true); + expect(secondRequest?.signal.aborted).toBe(false); + secondRequest?.resolve( + new Response('second result', { + headers: { 'content-type': 'text/plain' }, + }), + ); + + await expect(first).rejects.toThrow('first deadline exceeded'); + await expect(second).resolves.toBe('second result'); + }); +}); diff --git a/packages/adt-mcp/README.md b/packages/adt-mcp/README.md index c840696bd..8bf5c9480 100644 --- a/packages/adt-mcp/README.md +++ b/packages/adt-mcp/README.md @@ -532,7 +532,7 @@ server-side worklist for the analysis. ``` 1. list_package_objects packageName=ZPACKAGE -2. get_object objectName=ZCL_FOO +2. get_object objectName=ZCL_FOO objectType=CLAS 3. get_source objectName=ZCL_FOO objectType=CLAS ``` diff --git a/packages/adt-mcp/src/index.ts b/packages/adt-mcp/src/index.ts index d8fc192dc..5a7d12174 100644 --- a/packages/adt-mcp/src/index.ts +++ b/packages/adt-mcp/src/index.ts @@ -47,10 +47,14 @@ export { } from './lib/http/server'; export { createMcpInvocationVerifier, + parseScopedAdtInvocationPolicy, + parseSafeExecutePolicy, + type ScopedAdtInvocationPolicy, type McpInvocationJsonValue, type McpInvocationVerifier, type McpInvocationVerifierOptions, type McpTrustedOperationClass, + type SafeExecutePolicy, type TrustedMcpInvocationClaims, } from './lib/http/invocation.js'; export { diff --git a/packages/adt-mcp/src/lib/http/invocation.ts b/packages/adt-mcp/src/lib/http/invocation.ts index a0c10cab4..b9da57067 100644 --- a/packages/adt-mcp/src/lib/http/invocation.ts +++ b/packages/adt-mcp/src/lib/http/invocation.ts @@ -17,8 +17,14 @@ const MAX_TOKEN_LIFETIME_SECONDS = 5 * 60; const MAX_FROZEN_SOURCES = 500; const MAX_SOURCE_REFERENCE_LENGTH = 8 * 1024; const MAX_FROZEN_SOURCE_BYTES = 2 * 1024 * 1024; +const MAX_SCOPED_OBJECT_KEYS = 100; +const MAX_SCOPED_TOOL_CALLS = 12; +const MAX_SAFE_EXECUTE_GRANT_BYTES = 16 * 1024; const destinationKeyPattern = /^[a-z][a-z0-9-]{1,62}$/u; const canonicalKeyPattern = /^[A-Z0-9_]+:.+$/u; +const scopedCanonicalObjectKeyPattern = + /^[A-Z0-9_]{2,30}:[A-Z0-9_/$-]{1,128}$/u; +const compactJwsPattern = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u; const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; @@ -26,8 +32,10 @@ const trustedAgentIds = new Set([ 'ai-review', 'system-assistant', 'autonomous-review-agent', + 'adt-execution', ]); const trustedOperationClasses = new Set(['server', 'read', 'safe_execute']); +const scopedReadTools = new Set(['get_object', 'get_object_structure']); export type McpTrustedOperationClass = 'server' | 'read' | 'safe_execute'; @@ -44,7 +52,10 @@ export interface TrustedMcpInvocationClaims { readonly tokenId: string; readonly principal: string; readonly agentId: - 'ai-review' | 'system-assistant' | 'autonomous-review-agent'; + | 'ai-review' + | 'system-assistant' + | 'autonomous-review-agent' + | 'adt-execution'; readonly classes: readonly McpTrustedOperationClass[]; readonly destinationKeys: readonly string[]; readonly correlationId: string; @@ -70,6 +81,56 @@ export interface AiReviewFrozenSourcePolicy { readonly maxSourceBytes: number; } +export type SafeExecutePolicy = + | { + readonly operationId: 'atc_run'; + readonly check: 'atc'; + readonly maxDurationMs: number; + readonly maxResultBytes: number; + readonly maxFindings: number; + readonly maxObjects: number; + readonly maxPackages: number; + readonly maxVariants: number; + } + | { + readonly operationId: 'run_unit_tests'; + readonly check: 'aunit'; + readonly effectiveWithCoverage: false; + readonly effectiveCoverageFormat: null; + readonly maxDurationMs: number; + readonly maxResultBytes: number; + readonly maxFindings: number; + readonly maxObjects: number; + readonly maxTestClasses: number; + readonly maxTestMethods: number; + } + | { + readonly operationId: 'run_unit_tests'; + readonly check: 'coverage'; + readonly effectiveWithCoverage: true; + readonly effectiveCoverageFormat: 'jacoco' | 'sonar-generic'; + readonly maxDurationMs: number; + readonly maxResultBytes: number; + readonly maxFindings: number; + readonly maxObjects: number; + readonly maxPrograms: number; + readonly maxMeasurements: number; + }; + +export interface ScopedAdtInvocationPolicy { + readonly scopeId: string; + readonly executionId: string; + readonly systemSid: string; + readonly resourceKeys: readonly string[]; + readonly toolNames: readonly string[]; + readonly operationClass: 'read' | 'safe_execute'; + readonly maxToolCalls: number; + readonly safeExecutePolicy?: SafeExecutePolicy; + /** Server-owned opaque grant material; never exposed in a tool schema. */ + readonly authorizationId?: string; + readonly authorizationToken?: string; +} + export interface McpInvocationVerifierOptions { /** ES256 public key mounted at ADT Server; it cannot issue credentials. */ publicKey: CryptoKey | KeyObject; @@ -99,9 +160,8 @@ export interface McpInvocationVerifier { * access; accepting a syntactically valid policy without enforcing it would * widen the signed credential. * - * The initial system-assistant policy is fully enforced by its exact - * destination key plus the selected System marker. AI Review stays fail - * closed until the frozen-object/capability policy is implemented at dispatch. + * Every admitted policy has an exact parser. Structurally incomplete or + * unknown policies fail closed. */ function isSystemAssistantPolicySupported( claims: TrustedMcpInvocationClaims, @@ -141,6 +201,9 @@ export function isMcpInvocationDispatchPolicySupported( parseAutonomousReviewAgentPolicy(claims) !== undefined ); } + if (claims.agentId === 'adt-execution') { + return parseScopedAdtInvocationPolicy(claims) !== undefined; + } if (claims.agentId === 'ai-review') { return ( hasServerReadClasses(claims) && @@ -184,6 +247,347 @@ function requiredSourceReference(value: unknown): string | undefined { return requiredString(value, { maxLength: MAX_SOURCE_REFERENCE_LENGTH }); } +function hasExactSortedKeys( + value: Readonly>, + expected: readonly string[], +): boolean { + const actual = Object.keys(value).sort((left, right) => + left.localeCompare(right), + ); + const sortedExpected = [...expected].sort((left, right) => + left.localeCompare(right), + ); + return ( + actual.length === sortedExpected.length && + actual.every((key, index) => key === sortedExpected[index]) + ); +} + +function positiveSafeInteger(value: unknown): number | undefined { + return Number.isSafeInteger(value) && (value as number) > 0 + ? (value as number) + : undefined; +} + +function parseSafeExecuteCommon( + value: Readonly>, +): + | { + maxDurationMs: number; + maxResultBytes: number; + maxFindings: number; + maxObjects: number; + } + | undefined { + const maxDurationMs = positiveSafeInteger(value.maxDurationMs); + const maxResultBytes = positiveSafeInteger(value.maxResultBytes); + const maxFindings = positiveSafeInteger(value.maxFindings); + const maxObjects = positiveSafeInteger(value.maxObjects); + if ( + maxDurationMs === undefined || + maxResultBytes === undefined || + maxFindings === undefined || + maxObjects === undefined + ) { + return undefined; + } + return { maxDurationMs, maxResultBytes, maxFindings, maxObjects }; +} + +const safeExecuteCommonKeys = [ + 'operationId', + 'check', + 'maxDurationMs', + 'maxResultBytes', + 'maxFindings', + 'maxObjects', +] as const; + +function parseAtcSafeExecutePolicy( + cloned: Record, + common: ReturnType, +): SafeExecutePolicy | undefined { + if (!common) return undefined; + if ( + cloned.operationId !== 'atc_run' || + cloned.check !== 'atc' || + !hasExactSortedKeys(cloned, [ + ...safeExecuteCommonKeys, + 'maxPackages', + 'maxVariants', + ]) + ) { + return undefined; + } + const maxPackages = positiveSafeInteger(cloned.maxPackages); + const maxVariants = positiveSafeInteger(cloned.maxVariants); + if (maxPackages === undefined || maxVariants === undefined) { + return undefined; + } + return Object.freeze({ + operationId: 'atc_run', + check: 'atc', + ...common, + maxPackages, + maxVariants, + }); +} + +function parseAunitSafeExecutePolicy( + cloned: Record, + common: ReturnType, +): SafeExecutePolicy | undefined { + if (!common) return undefined; + if ( + cloned.operationId !== 'run_unit_tests' || + cloned.check !== 'aunit' || + cloned.effectiveWithCoverage !== false || + cloned.effectiveCoverageFormat !== null || + !hasExactSortedKeys(cloned, [ + ...safeExecuteCommonKeys, + 'effectiveWithCoverage', + 'effectiveCoverageFormat', + 'maxTestClasses', + 'maxTestMethods', + ]) + ) { + return undefined; + } + const maxTestClasses = positiveSafeInteger(cloned.maxTestClasses); + const maxTestMethods = positiveSafeInteger(cloned.maxTestMethods); + if (maxTestClasses === undefined || maxTestMethods === undefined) { + return undefined; + } + return Object.freeze({ + operationId: 'run_unit_tests', + check: 'aunit', + effectiveWithCoverage: false, + effectiveCoverageFormat: null, + ...common, + maxTestClasses, + maxTestMethods, + }); +} + +function parseCoverageSafeExecutePolicy( + cloned: Record, + common: ReturnType, +): SafeExecutePolicy | undefined { + if (!common) return undefined; + const format = cloned.effectiveCoverageFormat; + if ( + cloned.operationId !== 'run_unit_tests' || + cloned.check !== 'coverage' || + cloned.effectiveWithCoverage !== true || + (format !== 'jacoco' && format !== 'sonar-generic') || + !hasExactSortedKeys(cloned, [ + ...safeExecuteCommonKeys, + 'effectiveWithCoverage', + 'effectiveCoverageFormat', + 'maxPrograms', + 'maxMeasurements', + ]) + ) { + return undefined; + } + const maxPrograms = positiveSafeInteger(cloned.maxPrograms); + const maxMeasurements = positiveSafeInteger(cloned.maxMeasurements); + if (maxPrograms === undefined || maxMeasurements === undefined) { + return undefined; + } + return Object.freeze({ + operationId: 'run_unit_tests', + check: 'coverage', + effectiveWithCoverage: true, + effectiveCoverageFormat: format, + ...common, + maxPrograms, + maxMeasurements, + }); +} + +export function parseSafeExecutePolicy( + value: unknown, +): SafeExecutePolicy | undefined { + if (!isPlainObject(value)) return undefined; + const cloned = cloneJsonRecord(value); + if (!cloned) return undefined; + const common = parseSafeExecuteCommon(cloned); + + return ( + parseAtcSafeExecutePolicy(cloned, common) ?? + parseAunitSafeExecutePolicy(cloned, common) ?? + parseCoverageSafeExecutePolicy(cloned, common) + ); +} + +function parseSortedUniqueStrings( + value: unknown, + options: { + maxItems: number; + minItems: number; + matches: (item: string) => boolean; + }, +): readonly string[] | undefined { + if ( + !Array.isArray(value) || + value.length < options.minItems || + value.length > options.maxItems + ) { + return undefined; + } + const parsed: string[] = []; + for (const item of value) { + if ( + typeof item !== 'string' || + !options.matches(item) || + parsed.includes(item) + ) { + return undefined; + } + parsed.push(item); + } + const sorted = [...parsed].sort((left, right) => left.localeCompare(right)); + if (!parsed.every((item, index) => item === sorted[index])) return undefined; + return Object.freeze(parsed); +} + +function parseScopedObjectKeys(value: unknown): readonly string[] | undefined { + return parseSortedUniqueStrings(value, { + maxItems: MAX_SCOPED_OBJECT_KEYS, + minItems: 1, + matches: (item) => scopedCanonicalObjectKeyPattern.test(item), + }); +} + +function parseScopedToolNames( + value: unknown, + operationClass: 'read' | 'safe_execute', + safeExecutePolicy: SafeExecutePolicy | undefined, +): readonly string[] | undefined { + const toolNames = parseSortedUniqueStrings(value, { + maxItems: operationClass === 'read' ? scopedReadTools.size : 1, + minItems: 1, + matches: (item) => + operationClass === 'read' + ? scopedReadTools.has(item) + : item === 'atc_run' || item === 'run_unit_tests', + }); + if (!toolNames) return undefined; + if (operationClass === 'read') { + return safeExecutePolicy === undefined ? toolNames : undefined; + } + return safeExecutePolicy && toolNames[0] === safeExecutePolicy.operationId + ? toolNames + : undefined; +} + +function parseExecutionAuthorizationToken(value: unknown): string | undefined { + return typeof value === 'string' && + value.length <= MAX_SAFE_EXECUTE_GRANT_BYTES && + compactJwsPattern.test(value) + ? value + : undefined; +} + +export function parseScopedAdtInvocationPolicy( + claims: TrustedMcpInvocationClaims, +): ScopedAdtInvocationPolicy | undefined { + if ( + claims.agentId !== 'adt-execution' || + claims.destinationKeys.length !== 1 + ) { + return undefined; + } + if ( + claims.classes.length !== 2 || + claims.classes[0] !== 'server' || + (claims.classes[1] !== 'read' && claims.classes[1] !== 'safe_execute') + ) { + return undefined; + } + const operationClass = claims.classes[1]; + const safeExecute = operationClass === 'safe_execute'; + const expectedConstraintKeys = [ + 'kind', + 'scopeId', + 'executionId', + 'systemSid', + 'resourceKeys', + 'toolNames', + ...(safeExecute + ? ['safeExecutePolicy', 'authorizationId', 'authorizationToken'] + : []), + ]; + if (!hasExactSortedKeys(claims.constraint, expectedConstraintKeys)) { + return undefined; + } + if ( + claims.constraint.kind !== 'adt-execution-v1' || + !hasExactSortedKeys(claims.limits, ['maxToolCalls']) + ) { + return undefined; + } + + const scopeId = requiredUuid(claims.constraint.scopeId); + const executionId = requiredUuid(claims.constraint.executionId); + const systemSid = requiredSystemSid(claims.constraint.systemSid); + const resourceKeys = parseScopedObjectKeys(claims.constraint.resourceKeys); + const maxToolCalls = positiveSafeInteger(claims.limits.maxToolCalls); + if ( + !scopeId || + !executionId || + !systemSid || + !resourceKeys || + maxToolCalls === undefined || + maxToolCalls > MAX_SCOPED_TOOL_CALLS + ) { + return undefined; + } + + const safeExecutePolicy = safeExecute + ? parseSafeExecutePolicy(claims.constraint.safeExecutePolicy) + : undefined; + const toolNames = parseScopedToolNames( + claims.constraint.toolNames, + operationClass, + safeExecutePolicy, + ); + if (!toolNames) return undefined; + + if (!safeExecute) { + return Object.freeze({ + scopeId, + executionId, + systemSid, + resourceKeys, + toolNames, + operationClass, + maxToolCalls, + }); + } + + const authorizationId = requiredUuid(claims.constraint.authorizationId); + const authorizationToken = parseExecutionAuthorizationToken( + claims.constraint.authorizationToken, + ); + if (!safeExecutePolicy || !authorizationId || !authorizationToken) { + return undefined; + } + return Object.freeze({ + scopeId, + executionId, + systemSid, + resourceKeys, + toolNames, + operationClass, + maxToolCalls, + safeExecutePolicy, + authorizationId, + authorizationToken, + }); +} + export function parseFrozenSource( source: unknown, sourceKeys: Set, @@ -405,7 +809,9 @@ function cloneTrustedClasses( if (!Array.isArray(value) || value.length === 0) return undefined; const classes: McpTrustedOperationClass[] = []; for (const item of value) { - if (!isTrustedOperationClass(item)) return undefined; + if (!isTrustedOperationClass(item) || classes.includes(item)) { + return undefined; + } classes.push(item); } return Object.freeze(classes); @@ -415,7 +821,11 @@ function cloneDestinationKeys(value: unknown): readonly string[] | undefined { if (!Array.isArray(value) || value.length === 0) return undefined; const destinationKeys: string[] = []; for (const item of value) { - if (typeof item !== 'string' || !destinationKeyPattern.test(item)) { + if ( + typeof item !== 'string' || + !destinationKeyPattern.test(item) || + destinationKeys.includes(item) + ) { return undefined; } destinationKeys.push(item); @@ -618,7 +1028,14 @@ export function createMcpInvocationVerifier( audience, currentDate: currentDate(now), }); - if (verified.protectedHeader.kid !== keyId) return undefined; + const protectedHeader = verified.protectedHeader; + if ( + protectedHeader.alg !== 'ES256' || + protectedHeader.kid !== keyId || + protectedHeader.typ !== 'JWT' + ) { + return undefined; + } return claimsFromPayload(verified.payload, keyId, issuer, audience); } catch { return undefined; diff --git a/packages/adt-mcp/src/lib/http/server.ts b/packages/adt-mcp/src/lib/http/server.ts index 758d300df..2007e6bc8 100644 --- a/packages/adt-mcp/src/lib/http/server.ts +++ b/packages/adt-mcp/src/lib/http/server.ts @@ -28,6 +28,7 @@ import { isMcpDestinationKey, isMcpOperationClass, type McpFrozenSourceAccess, + type McpScopedAccess, type McpRequestAccess, } from '../tools/scope-catalogue.js'; import { @@ -39,6 +40,8 @@ import { isMcpInvocationDispatchPolicySupported, parseAiReviewFrozenSourcePolicy, parseFrozenSource, + parseScopedAdtInvocationPolicy, + parseSafeExecutePolicy, } from './invocation.js'; import type { McpInvocationVerifier, @@ -120,6 +123,15 @@ export interface HttpServerOptions { resolveFrozenSource?: NonNullable< import('../types.js').ToolContext['resolveFrozenSource'] >; + consumeExecutionAuthorization?: NonNullable< + import('../types.js').ToolContext['consumeExecutionAuthorization'] + >; + reportExecutionOutcome?: NonNullable< + import('../types.js').ToolContext['reportExecutionOutcome'] + >; + executeWithDeadline?: NonNullable< + import('../types.js').ToolContext['executeWithDeadline'] + >; }; /** Inject a logger that writes to stderr by default. */ log?: (level: 'info' | 'warn' | 'error', msg: string) => void; @@ -175,14 +187,171 @@ function snapshotRequestAccess( const frozenSource = snapshotFrozenSourceAccess(access.frozenSource); if (access.frozenSource && !frozenSource) return undefined; + const scoped = snapshotScopedAccess(access.scoped); + if (access.scoped && !scoped) return undefined; + if ( + scoped && + (classes.length !== 2 || + classes[0] !== 'server' || + classes[1] !== scoped.operationClass) + ) { + return undefined; + } return Object.freeze({ classes: Object.freeze([...classes]), destinationKeys: Object.freeze([...destinationKeys]), ...(frozenSource ? { frozenSource } : {}), + ...(scoped ? { scoped } : {}), }); } +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; +const OBJECT_KEY_REGEX = /^[A-Z0-9_]{2,30}:[A-Z0-9_/$-]{1,128}$/u; +const READ_SCOPED_TOOLS = new Set(['get_object', 'get_object_structure']); +const SYSTEM_SID_REGEX = /^[A-Za-z0-9_-]{1,16}$/u; +const AUTHORIZATION_TOKEN_REGEX = + /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u; + +function normalizeAndValidateStrings(values: unknown[]): string[] | undefined { + if (!Array.isArray(values)) return undefined; + const result: string[] = []; + for (const value of values) { + if (typeof value !== 'string' || !value) return undefined; + result.push(value); + } + return result; +} + +function isSortedUniqueStrings(values: string[]): boolean { + const sorted = [...values].sort((left, right) => left.localeCompare(right)); + return ( + new Set(values).size === values.length && + values.every((value, index) => value === sorted[index]) + ); +} + +function validateCommonScopedFields( + access: McpScopedAccess, +): { resourceKeys: string[]; toolNames: string[] } | undefined { + if (typeof access.tokenId !== 'string' || !access.tokenId) return undefined; + if (typeof access.principal !== 'string' || !access.principal) + return undefined; + if (typeof access.correlationId !== 'string' || !access.correlationId) + return undefined; + if (typeof access.scopeId !== 'string' || !UUID_REGEX.test(access.scopeId)) { + return undefined; + } + if ( + typeof access.executionId !== 'string' || + !UUID_REGEX.test(access.executionId) + ) { + return undefined; + } + if ( + typeof access.systemSid !== 'string' || + !SYSTEM_SID_REGEX.test(access.systemSid) + ) { + return undefined; + } + if ( + !Number.isSafeInteger(access.maxToolCalls) || + access.maxToolCalls < 1 || + access.maxToolCalls > 12 + ) { + return undefined; + } + + const resourceKeys = normalizeAndValidateStrings(access.resourceKeys); + const toolNames = normalizeAndValidateStrings(access.toolNames); + if ( + !resourceKeys || + resourceKeys.length > 100 || + resourceKeys.some((key) => !OBJECT_KEY_REGEX.test(key)) || + !isSortedUniqueStrings(resourceKeys) || + !toolNames || + toolNames.length === 0 || + !isSortedUniqueStrings(toolNames) + ) { + return undefined; + } + + return { resourceKeys, toolNames }; +} + +function buildScopedAccess( + access: McpScopedAccess, + resourceKeys: string[], + toolNames: string[], + overrides: Record = {}, +): McpScopedAccess { + return Object.freeze({ + ...access, + resourceKeys: Object.freeze(resourceKeys), + toolNames: Object.freeze(toolNames), + ...overrides, + }) as McpScopedAccess; +} + +function snapshotReadScope( + access: McpScopedAccess, + resourceKeys: string[], + toolNames: string[], +): McpScopedAccess | undefined { + if ( + resourceKeys.length === 0 || + toolNames.some((name) => !READ_SCOPED_TOOLS.has(name)) || + access.safeExecutePolicy !== undefined || + access.authorizationId !== undefined || + access.authorizationToken !== undefined + ) { + return undefined; + } + return buildScopedAccess(access, resourceKeys, toolNames); +} + +function snapshotSafeExecuteScope( + access: McpScopedAccess, + resourceKeys: string[], + toolNames: string[], +): McpScopedAccess | undefined { + const safeExecutePolicy = parseSafeExecutePolicy(access.safeExecutePolicy); + if ( + !safeExecutePolicy || + resourceKeys.length === 0 || + toolNames.length !== 1 || + toolNames[0] !== safeExecutePolicy.operationId || + typeof access.authorizationId !== 'string' || + !UUID_REGEX.test(access.authorizationId) || + typeof access.authorizationToken !== 'string' || + access.authorizationToken.length > 16 * 1024 || + !AUTHORIZATION_TOKEN_REGEX.test(access.authorizationToken) + ) { + return undefined; + } + return buildScopedAccess(access, resourceKeys, toolNames, { + safeExecutePolicy, + }); +} + +function snapshotScopedAccess( + access: McpScopedAccess | undefined, +): McpScopedAccess | undefined { + if (!access) return undefined; + const validated = validateCommonScopedFields(access); + if (!validated) return undefined; + const { resourceKeys, toolNames } = validated; + + if (access.operationClass === 'read') { + return snapshotReadScope(access, resourceKeys, toolNames); + } + if (access.operationClass === 'safe_execute') { + return snapshotSafeExecuteScope(access, resourceKeys, toolNames); + } + return undefined; +} + function snapshotFrozenSourceAccess( access: McpFrozenSourceAccess | undefined, ): McpFrozenSourceAccess | undefined { @@ -256,10 +425,21 @@ function invocationRequestAccess( return undefined; } const frozenSource = parseAiReviewFrozenSourcePolicy(invocation); + const scopedPolicy = parseScopedAdtInvocationPolicy(invocation); return snapshotRequestAccess({ classes: invocation.classes, destinationKeys: invocation.destinationKeys, ...(frozenSource ? { frozenSource } : {}), + ...(scopedPolicy + ? { + scoped: { + tokenId: invocation.tokenId, + principal: invocation.principal, + correlationId: invocation.correlationId, + ...scopedPolicy, + }, + } + : {}), }); } @@ -591,6 +771,24 @@ export function createHttpMcpHandler( destinationServer.resolveFrozenSource, } : {}), + ...(destinationServer.consumeExecutionAuthorization + ? { + consumeExecutionAuthorization: + destinationServer.consumeExecutionAuthorization, + } + : {}), + ...(destinationServer.reportExecutionOutcome + ? { + reportExecutionOutcome: + destinationServer.reportExecutionOutcome, + } + : {}), + ...(destinationServer.executeWithDeadline + ? { + executeWithDeadline: + destinationServer.executeWithDeadline, + } + : {}), } : {}), }); diff --git a/packages/adt-mcp/src/lib/server.ts b/packages/adt-mcp/src/lib/server.ts index 90001bcef..db32f9ad7 100644 --- a/packages/adt-mcp/src/lib/server.ts +++ b/packages/adt-mcp/src/lib/server.ts @@ -48,6 +48,12 @@ export interface McpServerOptions { requestAccess?: (extra: { sessionId?: string; }) => McpRequestAccess | undefined; + /** Deployment-owned atomic authorization hook required by scoped execution. */ + consumeExecutionAuthorization?: ToolContext['consumeExecutionAuthorization']; + /** Deployment-owned terminal outcome hook required by scoped execution. */ + reportExecutionOutcome?: ToolContext['reportExecutionOutcome']; + /** Hard-cancellable runtime required for scoped execution. */ + executeWithDeadline?: ToolContext['executeWithDeadline']; /** Private ADT broker resolver for signed frozen-source capabilities. */ resolveFrozenSource: ToolContext['resolveFrozenSource']; } @@ -70,11 +76,23 @@ export function createMcpServer(options?: McpServerOptions): McpServer { { capabilities: { tools: {} } }, ); - const registry = options?.registry; + const { + registry, + clientFactory, + resolveSystem, + destinationRegistry, + requestIdentity, + requestAccess, + consumeExecutionAuthorization, + reportExecutionOutcome, + executeWithDeadline, + resolveFrozenSource, + } = options ?? {}; + const sourceCapabilities = createSourceCapabilityRegistry(); const ctx: ToolContext = { - getClient: options?.clientFactory ?? defaultClientFactory, + getClient: clientFactory ?? defaultClientFactory, sourceCapabilities, ...(registry ? { @@ -82,29 +100,39 @@ export function createMcpServer(options?: McpServerOptions): McpServer { getSession: (mcpSessionId: string) => registry.get(mcpSessionId), } : {}), - ...(options?.resolveSystem ? { resolveSystem: options.resolveSystem } : {}), - ...(options?.destinationRegistry + ...(resolveSystem ? { resolveSystem } : {}), + ...(destinationRegistry ? { - destinationRegistry: options.destinationRegistry, - requestIdentity: options.requestIdentity, - requestAccess: options.requestAccess, - ...(options.resolveFrozenSource - ? { resolveFrozenSource: options.resolveFrozenSource } + destinationRegistry, + requestIdentity, + requestAccess, + ...(consumeExecutionAuthorization + ? { consumeExecutionAuthorization } : {}), + ...(reportExecutionOutcome ? { reportExecutionOutcome } : {}), + ...(executeWithDeadline ? { executeWithDeadline } : {}), + ...(resolveFrozenSource ? { resolveFrozenSource } : {}), } : {}), }; - const destinationMode = options?.destinationRegistry; registerTools( - destinationMode - ? destinationModeServer(server, { requestAccess: options.requestAccess }) + destinationRegistry + ? destinationModeServer(server, { + requestAccess, + consumeExecutionAuthorization, + reportExecutionOutcome, + executeWithDeadline, + }) : server, ctx, ); - if (destinationMode) { + if (destinationRegistry) { installDestinationModeToolListProjection(server, { - requestAccess: options?.requestAccess, + requestAccess, + consumeExecutionAuthorization, + reportExecutionOutcome, + executeWithDeadline, }); } diff --git a/packages/adt-mcp/src/lib/session/changeset.ts b/packages/adt-mcp/src/lib/session/changeset.ts index d5636dc68..8834c2c34 100644 --- a/packages/adt-mcp/src/lib/session/changeset.ts +++ b/packages/adt-mcp/src/lib/session/changeset.ts @@ -22,10 +22,7 @@ */ export type ChangesetStatus = - | 'open' - | 'committing' - | 'committed' - | 'rolled_back'; + 'open' | 'committing' | 'committed' | 'rolled_back'; export interface ChangesetEntry { /** Fully-qualified ADT object URI (e.g. `/sap/bc/adt/oo/classes/zcl_demo`). */ diff --git a/packages/adt-mcp/src/lib/tools/atc-run.ts b/packages/adt-mcp/src/lib/tools/atc-run.ts index a868ee167..c75348862 100644 --- a/packages/adt-mcp/src/lib/tools/atc-run.ts +++ b/packages/adt-mcp/src/lib/tools/atc-run.ts @@ -12,7 +12,13 @@ import type { AdtClient } from '@abapify/adt-client'; import type { ToolContext } from '../types'; import { sessionOrConnectionShape } from './shared-schemas'; import { resolveClient } from './session-helpers'; -import { resolveObjectUri } from './utils'; +import { + extractSafeExecutePolicy, + handleSafeExecuteError, + mcpErrorResult, + resolveObjectUri, + safeExecuteLimitResult, +} from './utils'; type UnknownRecord = Record; @@ -124,6 +130,13 @@ function canonicalFindings(response: unknown): UnknownRecord[] { }); } +class AtcObjectUnavailableError extends Error { + constructor() { + super('ATC object is unavailable'); + this.name = 'AtcObjectUnavailableError'; + } +} + async function resolveScopeUris( client: AdtClient, scope: AtcScope, @@ -143,7 +156,7 @@ async function resolveScopeUris( object.objectName, object.objectType, ); - if (!uri) throw new Error('ATC object is unavailable'); + if (!uri) throw new AtcObjectUnavailableError(); return uri; }), ); @@ -192,20 +205,24 @@ export function registerAtcRunTool(server: McpServer, ctx: ToolContext): void { objectUri: z.never().optional(), }, async (args, extra) => { + const safePolicy = extractSafeExecutePolicy( + ctx.requestAccess?.(extra ?? {}), + 'atc_run', + ); try { const { client } = await resolveClient(ctx, args, extra ?? {}); const checkVariant = await resolveVariant(client, args.variant); + const targetUris = await resolveScopeUris(client, args.scope); const created = await client.adt.atc.worklists.create({ checkVariant, }); const worklistId = worklistIdFrom(created); - const targetUris = await resolveScopeUris(client, args.scope); await client.adt.atc.runs.post( { worklistId }, { run: { - maximumVerdicts: 10_000, + maximumVerdicts: safePolicy?.maxFindings ?? 10_000, objectSets: { objectSet: [ { @@ -222,6 +239,10 @@ export function registerAtcRunTool(server: McpServer, ctx: ToolContext): void { const worklist = await client.adt.atc.worklists.get(worklistId, { includeExemptedFindings: 'false', }); + const findings = canonicalFindings(worklist); + if (safePolicy && findings.length > safePolicy.maxFindings) { + return safeExecuteLimitResult('safe_execute_limit_exceeded'); + } return { content: [ @@ -230,7 +251,7 @@ export function registerAtcRunTool(server: McpServer, ctx: ToolContext): void { text: JSON.stringify( { checkVariant, - findings: canonicalFindings(worklist), + findings, }, null, 2, @@ -239,15 +260,10 @@ export function registerAtcRunTool(server: McpServer, ctx: ToolContext): void { ], }; } catch (error) { - return { - isError: true, - content: [ - { - type: 'text' as const, - text: `ATC run failed: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - }; + if (error instanceof AtcObjectUnavailableError) { + return mcpErrorResult(error, 'ATC run'); + } + return handleSafeExecuteError(error, safePolicy, 'ATC run'); } }, ); diff --git a/packages/adt-mcp/src/lib/tools/changeset-helpers.ts b/packages/adt-mcp/src/lib/tools/changeset-helpers.ts index 8a90d0c3c..9f7c47054 100644 --- a/packages/adt-mcp/src/lib/tools/changeset-helpers.ts +++ b/packages/adt-mcp/src/lib/tools/changeset-helpers.ts @@ -30,8 +30,7 @@ export function textOk(payload: unknown): { } export type RequireResult = - | { ok: true; value: T } - | { ok: false; error: TextErrorResult }; + { ok: true; value: T } | { ok: false; error: TextErrorResult }; /** Resolve the current HTTP MCP session registry entry, or return a 4xx-style error. */ export function requireSession( diff --git a/packages/adt-mcp/src/lib/tools/destination-mode.ts b/packages/adt-mcp/src/lib/tools/destination-mode.ts index efa4dd216..b7f07de14 100644 --- a/packages/adt-mcp/src/lib/tools/destination-mode.ts +++ b/packages/adt-mcp/src/lib/tools/destination-mode.ts @@ -12,6 +12,7 @@ import type { McpServer, RegisteredTool, } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { ToolContext } from '../types.js'; import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { actionClassesForMcpTool, @@ -23,6 +24,7 @@ import { type McpOperationClass, type McpRequestAccess, } from './scope-catalogue.js'; +import { safeExecuteLimitResult, scopeDeniedResult } from './utils.js'; const destination = z .string() @@ -59,6 +61,7 @@ const rawUriFieldsByTool = new Map([ ['find_references', ['objectUri']], ['get_callers_of', ['objectUri']], ['get_callees_of', ['objectUri']], + ['get_source_version', ['uri']], ['grep_objects', ['objectUris']], ]); @@ -81,6 +84,9 @@ export interface DestinationModeOptions { requestAccess?: (extra: { sessionId?: string; }) => McpRequestAccess | undefined; + consumeExecutionAuthorization?: ToolContext['consumeExecutionAuthorization']; + reportExecutionOutcome?: ToolContext['reportExecutionOutcome']; + executeWithDeadline?: ToolContext['executeWithDeadline']; } type DestinationToolListEntry = { @@ -94,13 +100,17 @@ const destinationToolInventories = new WeakMap< Map >(); -function scopeDeniedResult() { - return { - isError: true as const, - content: [{ type: 'text' as const, text: 'mcp_scope_denied' }], - }; +function isToolErrorResult(result: unknown): boolean { + return ( + !!result && + typeof result === 'object' && + !Array.isArray(result) && + (result as { isError?: unknown }).isError === true + ); } +type DispatchCounter = { admitted: number }; + function toolListEntry( name: string, tool: RegisteredTool, @@ -203,8 +213,7 @@ function transformToolInput( ): { transformedInputSchema: unknown; strictInputSchema: - | ReturnType - | undefined; + ReturnType | undefined; useRegisterTool: boolean; } { const requiresStrictCanonicalSchema = rawUriFieldsByTool.has(name); @@ -225,48 +234,209 @@ function transformToolInput( }; } +type HandlerArgs = { + toolArguments: Record; + extra: { sessionId?: string }; +}; + +function parseHandlerArgs(handlerArgs: unknown[]): HandlerArgs { + const toolArguments = + handlerArgs[0] && typeof handlerArgs[0] === 'object' + ? (handlerArgs[0] as Record) + : {}; + const extra = + handlerArgs[1] && typeof handlerArgs[1] === 'object' + ? (handlerArgs[1] as { sessionId?: string }) + : {}; + return { toolArguments, extra }; +} + +function isScopeAllowed( + access: McpRequestAccess | undefined, + name: string, + toolArguments: Record, +): boolean { + return ( + isMcpToolAllowed(access, name, toolArguments) && + isMcpToolResourceAllowed(access, name, toolArguments) && + isMcpDestinationAllowed(access, toolArguments.destination) + ); +} + +function reserveCounter( + scoped: NonNullable, + counters: Map, +): boolean { + const existing = counters.get(scoped.executionId); + const counter = existing ?? { admitted: 0 }; + counter.admitted++; + if (counter.admitted > scoped.maxToolCalls) { + counter.admitted--; + if (counter.admitted <= 0) { + counters.delete(scoped.executionId); + } else { + counters.set(scoped.executionId, counter); // nosemgrep + } + return false; + } + counters.set(scoped.executionId, counter); // nosemgrep + return true; +} + +function releaseCounter( + scoped: NonNullable, + counters: Map, +): void { + const counter = counters.get(scoped.executionId); + if (!counter) return; + counter.admitted--; + if (counter.admitted <= 0) { + counters.delete(scoped.executionId); + } else { + counters.set(scoped.executionId, counter); // nosemgrep + } +} + +async function runSafeExecution(context: { + handler: Handler; + handlerArgs: unknown[]; + scoped: NonNullable; + toolArguments: Record; + options: DestinationModeOptions; + counters: Map; +}): Promise { + const { handler, handlerArgs, scoped, toolArguments, options, counters } = + context; + const policy = scoped.safeExecutePolicy; + const { authorizationId, authorizationToken } = scoped; + const destinationKey = toolArguments.destination; + const { + consumeExecutionAuthorization, + reportExecutionOutcome, + executeWithDeadline, + } = options; + if ( + !policy || + !authorizationId || + !authorizationToken || + typeof destinationKey !== 'string' || + !consumeExecutionAuthorization || + !reportExecutionOutcome || + !executeWithDeadline + ) { + return scopeDeniedResult(); + } + + if (!reserveCounter(scoped, counters)) return scopeDeniedResult(); + + try { + const consumed = await consumeExecutionAuthorization({ + authorizationId, + authorizationToken, + principal: scoped.principal, + scopeId: scoped.scopeId, + executionId: scoped.executionId, + systemSid: scoped.systemSid, + resourceKeys: scoped.resourceKeys, + destination: destinationKey, + operationId: policy.operationId, + policy, + }); + if (!consumed) { + releaseCounter(scoped, counters); + return scopeDeniedResult(); + } + } catch { + releaseCounter(scoped, counters); + return scopeDeniedResult(); + } + + let outcome: 'succeeded' | 'failed' | 'outcome_unknown'; + let result: unknown; + try { + result = await executeWithDeadline({ + maxDurationMs: policy.maxDurationMs, + operation: async () => await handler(...handlerArgs), + }); + if ( + new TextEncoder().encode(JSON.stringify(result)).byteLength > + policy.maxResultBytes + ) { + result = safeExecuteLimitResult('safe_execute_limit_exceeded'); + } + outcome = isToolErrorResult(result) ? 'failed' : 'succeeded'; + } catch { + outcome = 'outcome_unknown'; + result = safeExecuteLimitResult('outcome_unknown'); + } + + try { + const recorded = await reportExecutionOutcome({ + authorizationId, + authorizationToken, + outcome, + }); + if (!recorded) result = safeExecuteLimitResult('outcome_unknown'); + } catch { + result = safeExecuteLimitResult('outcome_unknown'); + } + return result; +} + function wrapToolHandler( handler: Handler, name: string, options: DestinationModeOptions, + counters: Map, ): Handler { return async (...handlerArgs: unknown[]) => { - const toolArguments = - handlerArgs[0] && typeof handlerArgs[0] === 'object' - ? (handlerArgs[0] as Record) - : {}; - const extra = - handlerArgs[1] && typeof handlerArgs[1] === 'object' - ? (handlerArgs[1] as { sessionId?: string }) - : {}; + const { toolArguments, extra } = parseHandlerArgs(handlerArgs); let access: McpRequestAccess | undefined; try { access = options.requestAccess?.(extra); } catch { return scopeDeniedResult(); } - if ( - !isMcpToolAllowed(access, name, toolArguments) || - !isMcpToolResourceAllowed(access, name, toolArguments) || - !isMcpDestinationAllowed(access, toolArguments.destination) - ) { + if (!isScopeAllowed(access, name, toolArguments)) { return scopeDeniedResult(); } + const scoped = access?.scoped; + if (scoped) { + if (scoped.operationClass === 'safe_execute') { + return await runSafeExecution({ + handler, + handlerArgs, + scoped, + toolArguments, + options, + counters, + }); + } + if (!reserveCounter(scoped, counters)) return scopeDeniedResult(); + } return await handler(...handlerArgs); }; } -function registerDestinationTool( - target: McpServer, - name: string, - description: string | undefined, - transformedInputSchema: unknown, +function registerDestinationTool(context: { + target: McpServer; + name: string; + description: string | undefined; + transformedInputSchema: unknown; strictInputSchema: - | ReturnType - | undefined, - wrappedHandler: Handler, - useRegisterTool: boolean, -): unknown { + ReturnType | undefined; + wrappedHandler: Handler; + useRegisterTool: boolean; +}): unknown { + const { + target, + name, + description, + transformedInputSchema, + strictInputSchema, + wrappedHandler, + useRegisterTool, + } = context; if (useRegisterTool) { return target.registerTool( name, @@ -317,6 +487,7 @@ export function destinationModeServer( options: DestinationModeOptions = {}, ): McpServer { const inventory = new Map(); + const counters = new Map(); destinationToolInventories.set(server, inventory); return new Proxy(server, { get(target, property, receiver) { @@ -334,8 +505,9 @@ export function destinationModeServer( handler as Handler, name, options, + counters, ); - const registeredTool = registerDestinationTool( + const registeredTool = registerDestinationTool({ target, name, description, @@ -343,7 +515,7 @@ export function destinationModeServer( strictInputSchema, wrappedHandler, useRegisterTool, - ); + }); if (!registeredTool) return registeredTool; const entry = toolListEntry(name, registeredTool); actionSchemaProjection(name, entry.inputSchema); @@ -376,7 +548,17 @@ export function installDestinationModeToolListProjection( } return { tools: Array.from(inventory.values()) - .filter((tool) => isMcpToolListed(access, tool.name)) + .filter((tool) => { + if (!isMcpToolListed(access, tool.name)) return false; + if (access?.scoped?.operationClass === 'safe_execute') { + return ( + typeof options.consumeExecutionAuthorization === 'function' && + typeof options.reportExecutionOutcome === 'function' && + typeof options.executeWithDeadline === 'function' + ); + } + return true; + }) .map((tool) => toolListEntryForAccess(tool, access)), }; }); diff --git a/packages/adt-mcp/src/lib/tools/get-frozen-source.ts b/packages/adt-mcp/src/lib/tools/get-frozen-source.ts index 765805805..6fcfad9a4 100644 --- a/packages/adt-mcp/src/lib/tools/get-frozen-source.ts +++ b/packages/adt-mcp/src/lib/tools/get-frozen-source.ts @@ -12,13 +12,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { AdtResponseTooLargeError } from '@abapify/adt-client'; import type { ToolContext } from '../types.js'; import { resolveClient } from './session-helpers.js'; - -function denied() { - return { - isError: true as const, - content: [{ type: 'text' as const, text: 'mcp_scope_denied' }], - }; -} +import { scopeDeniedResult } from './utils.js'; export function registerGetFrozenSourceTool( server: McpServer, @@ -54,7 +48,7 @@ export function registerGetFrozenSourceTool( !ctx.resolveFrozenSource || typeof destination !== 'string' ) { - return denied(); + return scopeDeniedResult(); } try { @@ -71,7 +65,7 @@ export function registerGetFrozenSourceTool( // eslint-disable-next-line no-control-regex /[\s\\\u0000-\u0008\u000e-\u001f\u007f]/u.test(resolved.sourceUri) ) { - return denied(); + return scopeDeniedResult(); } const { client } = await resolveClient(ctx, args, extra ?? {}); let text: string; diff --git a/packages/adt-mcp/src/lib/tools/get-object-structure.ts b/packages/adt-mcp/src/lib/tools/get-object-structure.ts index fb7acf052..1027019fc 100644 --- a/packages/adt-mcp/src/lib/tools/get-object-structure.ts +++ b/packages/adt-mcp/src/lib/tools/get-object-structure.ts @@ -74,7 +74,6 @@ export function registerGetObjectStructureTool( objectName: z.string().describe('ABAP object name'), objectType: z .string() - .optional() .describe('Object type (e.g. CLAS, PROG, INTF, FUGR)'), version: z .enum(['active', 'inactive']) diff --git a/packages/adt-mcp/src/lib/tools/get-object.ts b/packages/adt-mcp/src/lib/tools/get-object.ts index 52de9f97a..1f7584123 100644 --- a/packages/adt-mcp/src/lib/tools/get-object.ts +++ b/packages/adt-mcp/src/lib/tools/get-object.ts @@ -21,6 +21,9 @@ export function registerGetObjectTool( { ...sessionOrConnectionShape, objectName: z.string().describe('ABAP object name to inspect'), + objectType: z + .string() + .describe('Object type (e.g. CLAS, PROG, INTF, FUGR)'), }, async (args, extra) => { try { @@ -31,16 +34,26 @@ export function registerGetObjectTool( await client.adt.repository.informationsystem.search.quickSearch({ query: args.objectName, maxResults: 10, + ...(args.objectType + ? { objectType: args.objectType.toUpperCase() } + : {}), }); const objects = extractObjectReferences(searchResult); - // Find exact match - const exactMatch = objects.find( - (obj) => + // Find exact match, optionally filtered by object type + const exactMatch = objects.find((obj) => { + const nameMatch = String(obj.name ?? '').toUpperCase() === - args.objectName.toUpperCase(), - ); + args.objectName.toUpperCase(); + if (!nameMatch) return false; + if (!args.objectType) return true; + return ( + String(obj.type ?? '') + .toUpperCase() + .split('/')[0] === args.objectType.toUpperCase().split('/')[0] + ); + }); if (!exactMatch) { const similar = objects diff --git a/packages/adt-mcp/src/lib/tools/run-unit-tests.ts b/packages/adt-mcp/src/lib/tools/run-unit-tests.ts index eb7fda091..db2a1422f 100644 --- a/packages/adt-mcp/src/lib/tools/run-unit-tests.ts +++ b/packages/adt-mcp/src/lib/tools/run-unit-tests.ts @@ -14,7 +14,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { ToolContext } from '../types'; import { sessionOrConnectionShape } from './shared-schemas'; import { resolveClient } from './session-helpers'; -import { resolveObjectUri } from './utils'; +import { + extractSafeExecutePolicy, + handleSafeExecuteError, + resolveObjectUri, + safeExecuteLimitResult, +} from './utils'; import type { InferTypedSchema } from '@abapify/adt-schemas'; import { aunitResult } from '@abapify/adt-schemas'; import { extractCoverageMeasurementId } from '@abapify/adt-contracts'; @@ -22,8 +27,14 @@ import { toJacocoXml, toSonarGenericCoverageXml, } from '@abapify/adt-aunit/formatters/jacoco'; +import { + normalizeUnitTestOptions, + type NormalizedUnitTestOptions, +} from './scope-catalogue.js'; +import type { SafeExecutePolicy } from '../http/invocation.js'; type AunitResultData = InferTypedSchema; +type AdtClient = ReturnType; type AunitProgram = NonNullable< AunitResultData['runResult']['program'] >[number]; @@ -143,6 +154,170 @@ function normalizeResult(response: AunitResultData): { return { totalTests, passCount, failCount, errorCount, programs }; } +function alertCount(rawAlerts: unknown): number { + if (!rawAlerts) return 0; + return Array.isArray(rawAlerts) ? rawAlerts.length : 1; +} + +function resultCounts(programs: AunitProgram[]): { + programs: number; + testClasses: number; + testMethods: number; + findings: number; +} { + let testClasses = 0; + let testMethods = 0; + let findings = 0; + for (const program of programs) { + const rawClasses = program.testClasses?.testClass ?? []; + const classes = Array.isArray(rawClasses) ? rawClasses : [rawClasses]; + findings += alertCount(program.alerts?.alert); + testClasses += classes.length; + for (const testClass of classes) { + const rawMethods = testClass?.testMethods?.testMethod ?? []; + const methods = Array.isArray(rawMethods) ? rawMethods : [rawMethods]; + findings += alertCount(testClass.alerts?.alert); + testMethods += methods.length; + for (const method of methods) { + findings += alertCount(method?.alerts?.alert); + } + } + } + return { + programs: programs.length, + testClasses, + testMethods, + findings, + }; +} + +function coverageMeasurementCount(value: unknown): number { + if (!value || typeof value !== 'object' || Array.isArray(value)) return 0; + const record = value as Record; + const nodes = record.nodes; + if (!nodes || typeof nodes !== 'object' || Array.isArray(nodes)) return 0; + const rawChildren = (nodes as Record).node; + const children = Array.isArray(rawChildren) + ? rawChildren + : rawChildren + ? [rawChildren] + : []; + return children.reduce( + (total, child) => total + 1 + coverageMeasurementCount(child), + 0, + ); +} + +type SafeExecuteLimitResult = ReturnType; +type CoveragePayload = { format: string; xml: string; warning?: string }; + +function checkSafeExecuteLimits( + counts: ReturnType, + safePolicy: SafeExecutePolicy | undefined, +): SafeExecuteLimitResult | undefined { + if (!safePolicy) return undefined; + if (counts.findings > safePolicy.maxFindings) + return safeExecuteLimitResult('safe_execute_limit_exceeded'); + if ( + safePolicy.check === 'aunit' && + (counts.testClasses > safePolicy.maxTestClasses || + counts.testMethods > safePolicy.maxTestMethods) + ) { + return safeExecuteLimitResult('safe_execute_limit_exceeded'); + } + if ( + safePolicy.check === 'coverage' && + counts.programs > safePolicy.maxPrograms + ) { + return safeExecuteLimitResult('safe_execute_limit_exceeded'); + } + return undefined; +} + +type CoverageFetchResult = + | { kind: 'payload'; value: CoveragePayload } + | { kind: 'limit'; value: SafeExecuteLimitResult }; + +async function fetchCoveragePayload( + client: AdtClient, + response: AunitResultData, + normalizedOptions: NormalizedUnitTestOptions, + safePolicy: SafeExecutePolicy | undefined, +): Promise { + const measurementId = extractCoverageMeasurementId(response); + const runtime = ( + client as unknown as { + adt: { + runtime?: { + traces: { + coverage: { + measurements: { post: (id: string) => Promise }; + statements: { get: (id: string) => Promise }; + }; + }; + }; + }; + } + ).adt.runtime; + + const format = normalizedOptions.effectiveCoverageFormat ?? 'jacoco'; + + if (!measurementId) { + return { + kind: 'payload', + value: { + format, + xml: '', + warning: 'Coverage requested but SAP returned no measurement link.', + }, + }; + } + if (!runtime) { + return { + kind: 'payload', + value: { + format, + xml: '', + warning: 'runtime/traces contract not available on this client.', + }, + }; + } + + const cov = runtime.traces.coverage; + try { + const measurements = (await cov.measurements.post( + measurementId, + )) as Parameters[0]['measurements']; + if ( + safePolicy?.check === 'coverage' && + coverageMeasurementCount(measurements.result) > safePolicy.maxMeasurements + ) { + return { + kind: 'limit', + value: safeExecuteLimitResult('safe_execute_limit_exceeded'), + }; + } + const statements = (await cov.statements.get(measurementId)) as Parameters< + typeof toJacocoXml + >[0]['statements']; + const xml = + format === 'sonar-generic' + ? toSonarGenericCoverageXml({ measurements, statements }) + : toJacocoXml({ measurements, statements }); + return { kind: 'payload', value: { format, xml } }; + } catch (err) { + if (safePolicy) throw err; + return { + kind: 'payload', + value: { + format, + xml: '', + warning: `Coverage fetch failed: ${err instanceof Error ? err.message : String(err)}`, + }, + }; + } +} + export function registerRunUnitTestsTool( server: McpServer, ctx: ToolContext, @@ -164,7 +339,6 @@ export function registerRunUnitTestsTool( withCoverage: z .boolean() .optional() - .default(false) .describe('Whether to collect code coverage data'), coverage: z .boolean() @@ -179,7 +353,23 @@ export function registerRunUnitTestsTool( .describe('Coverage report format when coverage is enabled'), }, async (args, extra) => { + const safePolicy = extractSafeExecutePolicy( + ctx.requestAccess?.(extra ?? {}), + 'run_unit_tests', + ); try { + const normalizedOptions = normalizeUnitTestOptions(args); + if (!normalizedOptions) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: 'Run unit tests failed: coverage options disagree', + }, + ], + }; + } const { client } = await resolveClient(ctx, args, extra ?? {}); const objectUri = await resolveObjectUri( @@ -199,8 +389,7 @@ export function registerRunUnitTestsTool( }; } - const wantsCoverage = - (args.coverage ?? false) || (args.withCoverage ?? false); + const wantsCoverage = normalizedOptions.effectiveWithCoverage; const body = buildRunConfiguration([objectUri], wantsCoverage); @@ -211,65 +400,20 @@ export function registerRunUnitTestsTool( body as Parameters[0], ); const result = normalizeResult(response as AunitResultData); + const counts = resultCounts(result.programs); + const limitResult = checkSafeExecuteLimits(counts, safePolicy); + if (limitResult) return limitResult; - // Optional: follow the coverage link and serialise a JaCoCo / Sonar report. - let coveragePayload: - | { format: string; xml: string; warning?: string } - | undefined; - + let coveragePayload: CoveragePayload | undefined; if (wantsCoverage) { - const measurementId = extractCoverageMeasurementId(response); - const runtime = ( - client as unknown as { - adt: { - runtime?: { - traces: { - coverage: { - measurements: { post: (id: string) => Promise }; - statements: { get: (id: string) => Promise }; - }; - }; - }; - }; - } - ).adt.runtime; - - if (!measurementId) { - coveragePayload = { - format: args.coverageFormat ?? 'jacoco', - xml: '', - warning: - 'Coverage requested but SAP returned no measurement link.', - }; - } else if (!runtime) { - coveragePayload = { - format: args.coverageFormat ?? 'jacoco', - xml: '', - warning: 'runtime/traces contract not available on this client.', - }; - } else { - const cov = runtime.traces.coverage; - try { - const measurements = (await cov.measurements.post( - measurementId, - )) as Parameters[0]['measurements']; - const statements = (await cov.statements.get( - measurementId, - )) as Parameters[0]['statements']; - const fmt = args.coverageFormat ?? 'jacoco'; - const xml = - fmt === 'sonar-generic' - ? toSonarGenericCoverageXml({ measurements, statements }) - : toJacocoXml({ measurements, statements }); - coveragePayload = { format: fmt, xml }; - } catch (err) { - coveragePayload = { - format: args.coverageFormat ?? 'jacoco', - xml: '', - warning: `Coverage fetch failed: ${err instanceof Error ? err.message : String(err)}`, - }; - } - } + const coverageResult = await fetchCoveragePayload( + client, + response as AunitResultData, + normalizedOptions, + safePolicy, + ); + if (coverageResult.kind === 'limit') return coverageResult.value; + coveragePayload = coverageResult.value; } const payload = coveragePayload @@ -285,15 +429,7 @@ export function registerRunUnitTestsTool( ], }; } catch (error) { - return { - isError: true, - content: [ - { - type: 'text' as const, - text: `Run unit tests failed: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - }; + return handleSafeExecuteError(error, safePolicy, 'Run unit tests'); } }, ); diff --git a/packages/adt-mcp/src/lib/tools/scope-catalogue.ts b/packages/adt-mcp/src/lib/tools/scope-catalogue.ts index 5dc3b2df4..b2e19ef7e 100644 --- a/packages/adt-mcp/src/lib/tools/scope-catalogue.ts +++ b/packages/adt-mcp/src/lib/tools/scope-catalogue.ts @@ -6,6 +6,8 @@ * catalogue is the single source of truth for the operation a tool performs. */ +import type { SafeExecutePolicy } from '../http/invocation.js'; + export type McpOperationClass = 'server' | 'read' | 'safe_execute' | 'write'; const destinationKeyPattern = /^[a-z][a-z0-9-]{1,62}$/u; @@ -32,6 +34,24 @@ export interface McpRequestAccess { destinationKeys: readonly string[]; /** A typed signed policy that removes ambient AI Review read authority. */ frozenSource?: McpFrozenSourceAccess; + /** Exact execution-scoped Scoped catalogue and dispatch policy. */ + scoped?: McpScopedAccess; +} + +export interface McpScopedAccess { + readonly tokenId: string; + readonly principal: string; + readonly correlationId: string; + readonly scopeId: string; + readonly executionId: string; + readonly systemSid: string; + readonly resourceKeys: readonly string[]; + readonly toolNames: readonly string[]; + readonly operationClass: 'read' | 'safe_execute'; + readonly maxToolCalls: number; + readonly safeExecutePolicy?: SafeExecutePolicy; + readonly authorizationId?: string; + readonly authorizationToken?: string; } export interface McpFrozenSourceAccess { @@ -131,7 +151,6 @@ export const MCP_TOOL_SCOPE_CATALOGUE: Readonly> = 'lookup_user', 'pretty_print', 'run_query', - 'run_unit_tests', 'sap_connect', 'sap_disconnect', 'search_objects', @@ -145,7 +164,7 @@ export const MCP_TOOL_SCOPE_CATALOGUE: Readonly> = ]), // ATC creates a server-side worklist even though it does not mutate ABAP // repository objects. It therefore needs an explicit execution grant. - ...safeExecute(['atc_run']), + ...safeExecute(['atc_run', 'run_unit_tests']), ...write([ 'activate_object', 'activate_package', @@ -247,11 +266,210 @@ export function isMcpToolAllowed( arguments_: Record = {}, ): boolean { const classes = access?.classes; - return Boolean( + const classAllowed = Boolean( Array.isArray(classes) && classes.every(isMcpOperationClass) && classes.includes(operationClassForMcpTool(name, arguments_)), ); + if (!classAllowed) return false; + const scoped = access?.scoped; + return ( + !scoped || + (scoped.operationClass === operationClassForMcpTool(name, arguments_) && + scoped.toolNames.includes(name)) + ); +} + +function canonicalObjectKey( + objectType: unknown, + objectName: unknown, +): string | undefined { + if (typeof objectType !== 'string' || typeof objectName !== 'string') { + return undefined; + } + const type = objectType.trim().toUpperCase().split('/')[0]; + const key = `${type}:${objectName.trim().toUpperCase()}`; + return /^[A-Z0-9_]{2,30}:[A-Z0-9_/$-]{1,128}$/u.test(key) ? key : undefined; +} + +function sortedUnique( + values: readonly string[], +): readonly string[] | undefined { + if (new Set(values).size !== values.length) return undefined; + return [...values].sort((left, right) => left.localeCompare(right)); +} + +function exactStringArrays( + left: readonly string[], + right: readonly string[], +): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +export interface NormalizedUnitTestOptions { + readonly effectiveWithCoverage: boolean; + readonly effectiveCoverageFormat: 'jacoco' | 'sonar-generic' | null; +} + +/** + * Canonicalises the accepted AUnit coverage spellings before policy + * comparison. The legacy alias is accepted only for compatibility and can + * never disagree with `withCoverage`. + */ +export function normalizeUnitTestOptions( + arguments_: Record, +): NormalizedUnitTestOptions | undefined { + const withCoverage = arguments_.withCoverage; + const coverage = arguments_.coverage; + const coverageFormat = arguments_.coverageFormat; + if ( + (withCoverage !== undefined && typeof withCoverage !== 'boolean') || + (coverage !== undefined && typeof coverage !== 'boolean') || + (coverageFormat !== undefined && + coverageFormat !== 'jacoco' && + coverageFormat !== 'sonar-generic') + ) { + return undefined; + } + if ( + withCoverage !== undefined && + coverage !== undefined && + withCoverage !== coverage + ) { + return undefined; + } + const effectiveWithCoverage = + (coverage as boolean | undefined) ?? + (withCoverage as boolean | undefined) ?? + false; + return { + effectiveWithCoverage, + effectiveCoverageFormat: effectiveWithCoverage + ? ((coverageFormat as 'jacoco' | 'sonar-generic' | undefined) ?? 'jacoco') + : null, + }; +} + +function isScopedReadResourceAllowed( + scoped: McpScopedAccess, + name: string, + arguments_: Record, +): boolean { + if (name !== 'get_object' && name !== 'get_object_structure') return true; + if (scoped.resourceKeys.length === 0) return false; + const key = canonicalObjectKey(arguments_.objectType, arguments_.objectName); + return Boolean(key && scoped.resourceKeys.includes(key)); +} + +function validateAtcScope( + policy: NonNullable | undefined, + scope: unknown, +): + | { + scopeRecord: Record; + maxObjects: number; + maxPackages: number; + maxVariants: number; + } + | undefined { + if ( + !policy || + policy.operationId !== 'atc_run' || + policy.check !== 'atc' || + !scope || + typeof scope !== 'object' || + Array.isArray(scope) + ) { + return undefined; + } + const scopeRecord = scope as Record; + if (policy.maxVariants < 1) return undefined; + if (scopeRecord.kind === 'package') { + // A package selector would expand only after SAP I/O, too late to compare + // every object with the signed scope. The credential issuer must materialise the expansion + // into this tool's canonical `objects` scope before issuing the grant. + return undefined; + } + if (scopeRecord.kind !== 'objects' || !Array.isArray(scopeRecord.objects)) { + // Transport expansion has no corresponding signed count bound. + return undefined; + } + return { + scopeRecord, + maxObjects: policy.maxObjects, + maxPackages: policy.maxPackages, + maxVariants: policy.maxVariants, + }; +} + +function collectAtcObjectKeys( + objects: unknown[], +): { keys: string[]; packageCount: number } | undefined { + const keys: string[] = []; + let packageCount = 0; + for (const object of objects) { + if (!object || typeof object !== 'object' || Array.isArray(object)) { + return undefined; + } + const record = object as Record; + const key = canonicalObjectKey(record.objectType, record.objectName); + if (!key) return undefined; + if (key.startsWith('DEVC:')) packageCount++; + keys.push(key); + } + return { keys, packageCount }; +} + +function isScopedAtcResourceAllowed( + scoped: McpScopedAccess, + arguments_: Record, +): boolean { + const validated = validateAtcScope( + scoped.safeExecutePolicy, + arguments_.scope, + ); + if (!validated) return false; + + const collected = collectAtcObjectKeys(validated.scopeRecord.objects); + if (!collected) return false; + + const sortedKeys = sortedUnique(collected.keys); + return Boolean( + sortedKeys && + sortedKeys.length <= validated.maxObjects && + collected.packageCount <= validated.maxPackages && + exactStringArrays(scoped.resourceKeys, sortedKeys), + ); +} + +function isScopedUnitTestResourceAllowed( + scoped: McpScopedAccess, + arguments_: Record, +): boolean { + const policy = scoped.safeExecutePolicy; + const key = canonicalObjectKey(arguments_.objectType, arguments_.objectName); + const normalized = normalizeUnitTestOptions(arguments_); + if ( + !policy || + policy.operationId !== 'run_unit_tests' || + !key || + !normalized || + policy.maxObjects < 1 || + !exactStringArrays(scoped.resourceKeys, [key]) + ) { + return false; + } + return policy.check === 'aunit' + ? normalized.effectiveWithCoverage === false && + normalized.effectiveCoverageFormat === null && + policy.effectiveWithCoverage === false && + policy.effectiveCoverageFormat === null + : normalized.effectiveWithCoverage === true && + normalized.effectiveCoverageFormat === policy.effectiveCoverageFormat && + policy.effectiveWithCoverage === true; } /** @@ -264,6 +482,19 @@ export function isMcpToolResourceAllowed( name: string, arguments_: Record = {}, ): boolean { + const scoped = access?.scoped; + if (scoped) { + if (scoped.operationClass === 'read') { + return isScopedReadResourceAllowed(scoped, name, arguments_); + } + if (name === 'atc_run') { + return isScopedAtcResourceAllowed(scoped, arguments_); + } + if (name === 'run_unit_tests') { + return isScopedUnitTestResourceAllowed(scoped, arguments_); + } + return false; + } const frozenSource = access?.frozenSource; if (!frozenSource) return name !== 'get_frozen_source'; if (name !== 'get_frozen_source') return false; @@ -317,6 +548,16 @@ export function isMcpToolListed( return false; } if (access.frozenSource) return name === 'get_frozen_source'; + if (access.scoped) { + const operationClass = + 'actionClasses' in entry ? undefined : entry.operationClass; + if (operationClass !== access.scoped.operationClass) return false; + if (!access.scoped.toolNames.includes(name)) return false; + if (operationClass === 'read' && access.scoped.resourceKeys.length === 0) { + return false; + } + return true; + } if (name === 'get_frozen_source') return false; const classes = 'actionClasses' in entry diff --git a/packages/adt-mcp/src/lib/tools/utils.ts b/packages/adt-mcp/src/lib/tools/utils.ts index d21367e97..8dd6a6fdd 100644 --- a/packages/adt-mcp/src/lib/tools/utils.ts +++ b/packages/adt-mcp/src/lib/tools/utils.ts @@ -2,6 +2,9 @@ * Shared utilities for MCP tool implementations. */ +import type { McpRequestAccess } from './scope-catalogue.js'; +import type { SafeExecutePolicy } from '../http/invocation.js'; + export interface SearchObject { name?: string; type?: string; @@ -190,6 +193,23 @@ export function getErrorStatus(error: unknown): number | undefined { : undefined; } +/** + * A completed SAP HTTP error response is a deterministic failed execution. + * Network, body-stream, abort, and internal parsing errors deliberately do not + * match, because dispatch may have happened and their outcome is uncertain. + */ +export function isKnownAdtHttpFailure(error: unknown): boolean { + const status = getErrorStatus(error); + return ( + error instanceof Error && + error.name === 'AdtError' && + typeof status === 'number' && + Number.isInteger(status) && + status >= 400 && + status <= 599 + ); +} + /** * Build a standard MCP error result with BTP 404 awareness. */ @@ -208,3 +228,55 @@ export function mcpErrorResult( content: [{ type: 'text' as const, text: message }], }; } + +export type McpErrorResult = ReturnType; + +/** + * Extract the safe-execute policy for a tool when the caller has been granted + * a scoped safe-execute grant for this operationId. + */ +export function extractSafeExecutePolicy( + access: McpRequestAccess | undefined, + operationId: string, +): SafeExecutePolicy | undefined { + return access?.scoped?.operationClass === 'safe_execute' && + access.scoped.safeExecutePolicy?.operationId === operationId + ? access.scoped.safeExecutePolicy + : undefined; +} + +/** + * For safe-execute tool handlers, rethrow non-deterministic errors when a safe + * policy is active so the outer execution recorder can report outcome_unknown; + * return a standard MCP error for known SAP HTTP failures. + */ +export function handleSafeExecuteError( + error: unknown, + safePolicy: SafeExecutePolicy | undefined, + toolLabel: string, +): McpErrorResult { + if (safePolicy && !isKnownAdtHttpFailure(error)) throw error; + return mcpErrorResult(error, toolLabel); +} + +/** + * Standard MCP result returned when the active scope does not allow a tool. + */ +export function scopeDeniedResult() { + return { + isError: true as const, + content: [{ type: 'text' as const, text: 'mcp_scope_denied' }], + }; +} + +/** + * Standard MCP result returned when a safe-execute guard limit is hit. + */ +export function safeExecuteLimitResult( + text: 'safe_execute_limit_exceeded' | 'outcome_unknown', +) { + return { + isError: true as const, + content: [{ type: 'text' as const, text }], + }; +} diff --git a/packages/adt-mcp/src/lib/types.ts b/packages/adt-mcp/src/lib/types.ts index 6b32c9c5b..687ad3c7e 100644 --- a/packages/adt-mcp/src/lib/types.ts +++ b/packages/adt-mcp/src/lib/types.ts @@ -10,6 +10,7 @@ import type { RequestIdentity, } from './session/destination-registry.js'; import type { McpRequestAccess } from './tools/scope-catalogue.js'; +import type { SafeExecutePolicy } from './http/invocation.js'; import type { createSourceCapabilityRegistry } from './source-capabilities.js'; /** @@ -53,6 +54,42 @@ export interface ToolContext { requestAccess?: (extra: { sessionId?: string; }) => McpRequestAccess | undefined; + /** + * Atomically consumes the policy broker's opaque scoped check grant. The implementation + * owns ADT Server service authentication and must return true only after + * the policy broker responds with the successful consume result. + */ + consumeExecutionAuthorization?: (input: { + authorizationId: string; + authorizationToken: string; + principal: string; + scopeId: string; + executionId: string; + systemSid: string; + resourceKeys: readonly string[]; + destination: string; + operationId: 'atc_run' | 'run_unit_tests'; + policy: SafeExecutePolicy; + }) => Promise; + /** + * Records the single terminal state of a consumed broker grant. It is called + * exactly once after the SAP operation settles and must never trigger a + * retry of that operation. + */ + reportExecutionOutcome?: (input: { + authorizationId: string; + authorizationToken: string; + outcome: 'succeeded' | 'failed' | 'outcome_unknown'; + }) => Promise; + /** + * Executes a consumed safe check under a runtime that can actually + * terminate its SAP transport at `maxDurationMs`. Returning early while the + * supplied operation continues is forbidden. + */ + executeWithDeadline?: (input: { + maxDurationMs: number; + operation: () => Promise; + }) => Promise; /** * Redeems an opaque ADT source capability after destination-mode policy has * selected it. The resolver may return only a trusted server-relative URI. diff --git a/packages/adt-mcp/tests/adt-execution-policy.test.ts b/packages/adt-mcp/tests/adt-execution-policy.test.ts new file mode 100644 index 000000000..3473427ae --- /dev/null +++ b/packages/adt-mcp/tests/adt-execution-policy.test.ts @@ -0,0 +1,222 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + isMcpInvocationDispatchPolicySupported, + parseScopedAdtInvocationPolicy, + type TrustedMcpInvocationClaims, +} from '../src/lib/http/invocation.js'; + +const scopeId = '11111111-1111-4111-8111-111111111111'; +const executionId = '22222222-2222-4222-8222-222222222222'; +const authorizationId = '33333333-3333-4333-8333-333333333333'; +const authorizationToken = [ + 'mock_header', + 'mock_payload', + 'mock_signature', +].join('.'); + +function claims( + overrides: Partial = {}, +): TrustedMcpInvocationClaims { + return { + tokenId: 'jti-scoped-001', + principal: 'engineer@example.invalid', + agentId: 'adt-execution', + classes: ['server', 'read'], + destinationKeys: ['tst-adt'], + correlationId: 'scoped:execution:001', + constraint: { + kind: 'adt-execution-v1', + scopeId, + executionId, + systemSid: 'TST', + resourceKeys: ['CLAS:ZCL_RELEASE_GATE', 'PROG:Z_RELEASE_REPORT'], + toolNames: ['get_object', 'get_object_structure'], + }, + limits: { maxToolCalls: 3 }, + ...overrides, + }; +} + +const atcPolicy = { + operationId: 'atc_run', + check: 'atc', + maxDurationMs: 30_000, + maxResultBytes: 262_144, + maxFindings: 500, + maxObjects: 20, + maxPackages: 5, + maxVariants: 1, +} as const; + +const aunitPolicy = { + operationId: 'run_unit_tests', + check: 'aunit', + effectiveWithCoverage: false, + effectiveCoverageFormat: null, + maxDurationMs: 45_000, + maxResultBytes: 262_144, + maxFindings: 500, + maxObjects: 20, + maxTestClasses: 100, + maxTestMethods: 1_000, +} as const; + +const coveragePolicy = { + operationId: 'run_unit_tests', + check: 'coverage', + effectiveWithCoverage: true, + effectiveCoverageFormat: 'sonar-generic', + maxDurationMs: 60_000, + maxResultBytes: 524_288, + maxFindings: 500, + maxObjects: 20, + maxPrograms: 20, + maxMeasurements: 10_000, +} as const; + +describe('Scoped ADT invocation policy', () => { + it('parses the exact read policy', () => { + const parsed = parseScopedAdtInvocationPolicy(claims()); + + assert.deepStrictEqual(parsed, { + scopeId, + executionId, + systemSid: 'TST', + resourceKeys: ['CLAS:ZCL_RELEASE_GATE', 'PROG:Z_RELEASE_REPORT'], + toolNames: ['get_object', 'get_object_structure'], + operationClass: 'read', + maxToolCalls: 3, + }); + assert.strictEqual(isMcpInvocationDispatchPolicySupported(claims()), true); + }); + + for (const policy of [atcPolicy, aunitPolicy, coveragePolicy]) { + it(`parses the exact ${policy.check} safe-execute policy`, () => { + const safeClaims = claims({ + classes: ['server', 'safe_execute'], + constraint: { + kind: 'adt-execution-v1', + scopeId, + executionId, + systemSid: 'TST', + resourceKeys: ['CLAS:ZCL_RELEASE_GATE'], + toolNames: [policy.operationId], + safeExecutePolicy: policy, + authorizationId: authorizationId, + authorizationToken: authorizationToken, + }, + limits: { maxToolCalls: 1 }, + }); + + assert.deepStrictEqual(parseScopedAdtInvocationPolicy(safeClaims), { + scopeId, + executionId, + systemSid: 'TST', + resourceKeys: ['CLAS:ZCL_RELEASE_GATE'], + toolNames: [policy.operationId], + operationClass: 'safe_execute', + safeExecutePolicy: policy, + authorizationId: authorizationId, + authorizationToken: authorizationToken, + maxToolCalls: 1, + }); + assert.strictEqual( + isMcpInvocationDispatchPolicySupported(safeClaims), + true, + ); + }); + } + + it('does not reinterpret other agent policies as scoped execution', () => { + for (const agentId of [ + 'system-assistant', + 'autonomous-review-agent', + ] as const) { + assert.strictEqual( + parseScopedAdtInvocationPolicy( + claims({ agentId } as Partial), + ), + undefined, + ); + } + }); + + it('rejects destination-wide reads without an exact resource binding', () => { + for (const toolName of ['cts_get_transport', 'cts_transport_objects']) { + const constraint = { + ...claims().constraint, + toolNames: [toolName], + }; + assert.strictEqual( + parseScopedAdtInvocationPolicy(claims({ constraint })), + undefined, + ); + } + }); + + it('rejects unsorted or duplicate exact scopes', () => { + for (const constraint of [ + { + ...claims().constraint, + resourceKeys: ['PROG:Z_RELEASE_REPORT', 'CLAS:ZCL_RELEASE_GATE'], + }, + { + ...claims().constraint, + resourceKeys: ['CLAS:ZCL_RELEASE_GATE', 'CLAS:ZCL_RELEASE_GATE'], + }, + { + ...claims().constraint, + toolNames: ['get_object_structure', 'get_object'], + }, + ]) { + assert.strictEqual( + parseScopedAdtInvocationPolicy(claims({ constraint })), + undefined, + ); + } + }); + + it('rejects class, tool and policy drift', () => { + const safeConstraint = { + kind: 'adt-execution-v1', + scopeId, + executionId, + systemSid: 'TST', + resourceKeys: ['CLAS:ZCL_RELEASE_GATE'], + toolNames: ['atc_run'], + safeExecutePolicy: atcPolicy, + authorizationId: authorizationId, + authorizationToken: authorizationToken, + } as const; + for (const candidate of [ + claims({ + classes: ['server', 'read'], + constraint: safeConstraint, + }), + claims({ + classes: ['server', 'safe_execute'], + constraint: { + ...safeConstraint, + toolNames: ['run_unit_tests'], + }, + }), + claims({ + classes: ['server', 'safe_execute'], + constraint: { + ...safeConstraint, + safeExecutePolicy: { ...atcPolicy, ignored: true }, + }, + }), + claims({ + classes: ['server', 'safe_execute'], + constraint: { + ...safeConstraint, + safeExecutePolicy: undefined, + }, + }), + ]) { + assert.strictEqual(parseScopedAdtInvocationPolicy(candidate), undefined); + } + }); +}); diff --git a/packages/adt-mcp/tests/destination-registry.test.ts b/packages/adt-mcp/tests/destination-registry.test.ts index 1ee38009c..10b0bf2b0 100644 --- a/packages/adt-mcp/tests/destination-registry.test.ts +++ b/packages/adt-mcp/tests/destination-registry.test.ts @@ -155,8 +155,7 @@ test('releaseAll waits for a pending creation and cleans its context and lease', | undefined; let resolveCreationStarted: (() => void) | undefined; let resolveContext: - | ((value: { client: never; close(): Promise }) => void) - | undefined; + ((value: { client: never; close(): Promise }) => void) | undefined; const leaseReady = new Promise<{ destination: string; expiresAt: number; diff --git a/packages/adt-mcp/tests/http-destination-scope.test.ts b/packages/adt-mcp/tests/http-destination-scope.test.ts index c3ff4e3c5..29f6077e9 100644 --- a/packages/adt-mcp/tests/http-destination-scope.test.ts +++ b/packages/adt-mcp/tests/http-destination-scope.test.ts @@ -54,6 +54,7 @@ test('HTTP destination mode projects only read-scoped tools without weakening hi assert.ok(names.has('system_info')); assert.ok(names.has('gcts_config')); assert.ok(!names.has('atc_run')); + assert.ok(!names.has('run_unit_tests')); assert.ok(!names.has('lock_object')); assert.ok(!names.has('activate_object')); assert.deepStrictEqual(gctsActionSchema?.enum, ['get', 'list']); diff --git a/packages/adt-mcp/tests/integration.test.ts b/packages/adt-mcp/tests/integration.test.ts index 7f067718e..2ad5a9a1e 100644 --- a/packages/adt-mcp/tests/integration.test.ts +++ b/packages/adt-mcp/tests/integration.test.ts @@ -165,6 +165,7 @@ describe('adt-mcp integration tests', () => { const { json } = await callTool('get_object', { ...connArgs(), objectName: 'ZCL_EXAMPLE', + objectType: 'CLAS', }); const data = json as { found: boolean; object?: { name: string } }; assert.strictEqual(data.found, true); @@ -177,6 +178,7 @@ describe('adt-mcp integration tests', () => { const { json } = await callTool('get_object', { ...connArgs(), objectName: 'DOES_NOT_EXIST', + objectType: 'CLAS', }); const data = json as { found: boolean; message?: string }; assert.strictEqual(data.found, false); diff --git a/packages/adt-mcp/tests/safe-execute-enforcement.test.ts b/packages/adt-mcp/tests/safe-execute-enforcement.test.ts new file mode 100644 index 000000000..ca10dda6c --- /dev/null +++ b/packages/adt-mcp/tests/safe-execute-enforcement.test.ts @@ -0,0 +1,556 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +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'; +import { registerRunUnitTestsTool } from '../src/lib/tools/run-unit-tests.js'; +import { + isMcpToolListed, + normalizeUnitTestOptions, + type McpRequestAccess, +} from '../src/lib/tools/scope-catalogue.js'; +import { isKnownAdtHttpFailure } from '../src/lib/tools/utils.js'; + +type ToolResult = { + isError?: boolean; + content: Array<{ type: 'text'; text: string }>; +}; +type ToolHandler = ( + args: Record, + extra: { sessionId?: string }, +) => Promise; + +class CapturingServer { + readonly handlers = new Map(); + + tool(...args: unknown[]): void { + const name = args[0]; + const handler = args.at(-1); + assert.strictEqual(typeof name, 'string'); + assert.strictEqual(typeof handler, 'function'); + this.handlers.set(name, handler as ToolHandler); + } +} + +const atcPolicy = { + operationId: 'atc_run', + check: 'atc', + maxDurationMs: 30_000, + maxResultBytes: 1_024, + maxFindings: 10, + maxObjects: 2, + maxPackages: 1, + maxVariants: 1, +} as const; + +function safeAccess(): McpRequestAccess { + return { + classes: ['server', 'safe_execute'], + destinationKeys: ['tst-adt'], + scoped: { + tokenId: 'invocation-jti', + principal: 'engineer@example.invalid', + correlationId: 'scoped:execution:001', + scopeId: '11111111-1111-4111-8111-111111111111', + executionId: '22222222-2222-4222-8222-222222222222', + systemSid: 'TST', + resourceKeys: ['CLAS:ZCL_RELEASE_GATE'], + toolNames: ['atc_run'], + operationClass: 'safe_execute', + maxToolCalls: 1, + safeExecutePolicy: atcPolicy, + authorizationId: '33333333-3333-4333-8333-333333333333', + authorizationToken: 'header.payload.signature', + }, + }; +} + +function safeAunitAccess(): McpRequestAccess { + const access = safeAccess(); + return { + ...access, + scoped: { + ...access.scoped!, + resourceKeys: ['CLAS:ZCL_RELEASE_GATE'], + toolNames: ['run_unit_tests'], + safeExecutePolicy: { + operationId: 'run_unit_tests', + check: 'aunit', + effectiveWithCoverage: false, + effectiveCoverageFormat: null, + maxDurationMs: 30_000, + maxResultBytes: 1_024, + maxFindings: 10, + maxObjects: 1, + maxTestClasses: 10, + maxTestMethods: 100, + }, + }, + }; +} + +const exactAtcArgs = { + destination: 'tst-adt', + scope: { + kind: 'objects', + objects: [{ objectType: 'CLAS', objectName: 'ZCL_RELEASE_GATE' }], + }, + variant: 'DEFAULT', +}; + +describe('Scoped safe-execute catalogue and dispatch', () => { + it('classifies only completed SAP HTTP error responses as deterministic failures', () => { + const sapResponse = Object.assign(new Error('HTTP 400: Bad Request'), { + name: 'AdtError', + status: 400, + }); + const transportFailure = new TypeError('fetch failed'); + const abortFailure = Object.assign(new Error('deadline exceeded'), { + name: 'AbortError', + }); + + assert.strictEqual(isKnownAdtHttpFailure(sapResponse), true); + assert.strictEqual(isKnownAdtHttpFailure(transportFailure), false); + assert.strictEqual(isKnownAdtHttpFailure(abortFailure), false); + }); + + it('normalises known ATC HTTP failures but rethrows uncertain transport failures', async () => { + const target = new CapturingServer(); + let failure: Error = Object.assign(new Error('HTTP 400: Bad Request'), { + name: 'AdtError', + status: 400, + }); + registerAtcRunTool(target as unknown as McpServer, { + getClient: () => + ({ + adt: { + atc: { + worklists: { + create: async () => { + throw failure; + }, + }, + }, + }, + }) as never, + requestAccess: safeAccess, + }); + const handler = target.handlers.get('atc_run')!; + const args = { + baseUrl: 'https://sap.example.test', + scope: exactAtcArgs.scope, + variant: 'DEFAULT', + }; + + await assert.doesNotReject(async () => { + const result = await handler(args, {}); + assert.strictEqual(result.isError, true); + }); + + failure = new TypeError('fetch failed'); + await assert.rejects(handler(args, {}), /fetch failed/u); + }); + + it('normalises known AUnit HTTP failures but rethrows uncertain transport failures', async () => { + const target = new CapturingServer(); + let failure: Error = Object.assign( + new Error('HTTP 500: Internal Server Error'), + { + name: 'AdtError', + status: 500, + }, + ); + registerRunUnitTestsTool(target as unknown as McpServer, { + getClient: () => + ({ + adt: { + aunit: { + testruns: { + post: async () => { + throw failure; + }, + }, + }, + }, + }) as never, + requestAccess: safeAunitAccess, + }); + const handler = target.handlers.get('run_unit_tests')!; + const args = { + baseUrl: 'https://sap.example.test', + objectName: 'ZCL_RELEASE_GATE', + objectType: 'CLAS', + withCoverage: false, + }; + + await assert.doesNotReject(async () => { + const result = await handler(args, {}); + assert.strictEqual(result.isError, true); + }); + + failure = new TypeError('fetch failed'); + await assert.rejects(handler(args, {}), /fetch failed/u); + }); + + it('normalises coverage flags and rejects disagreement', () => { + assert.deepStrictEqual(normalizeUnitTestOptions({}), { + effectiveWithCoverage: false, + effectiveCoverageFormat: null, + }); + assert.deepStrictEqual( + normalizeUnitTestOptions({ + coverage: true, + withCoverage: true, + coverageFormat: 'sonar-generic', + }), + { + effectiveWithCoverage: true, + effectiveCoverageFormat: 'sonar-generic', + }, + ); + assert.strictEqual( + normalizeUnitTestOptions({ + coverage: true, + withCoverage: false, + }), + undefined, + ); + }); + + it('hides both checks from read and exposes exactly the signed safe tool', () => { + const read: McpRequestAccess = { + classes: ['server', 'read'], + destinationKeys: ['tst-adt'], + 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'], + operationClass: 'read', + maxToolCalls: 3, + }, + }; + + assert.strictEqual(isMcpToolListed(read, 'atc_run'), false); + assert.strictEqual(isMcpToolListed(read, 'run_unit_tests'), false); + assert.strictEqual(isMcpToolListed(safeAccess(), 'atc_run'), true); + assert.strictEqual(isMcpToolListed(safeAccess(), 'run_unit_tests'), false); + }); + + it('denies policy/resource mismatch before grant consumption and handler I/O', async () => { + const target = new CapturingServer(); + let consumes = 0; + let handlerCalls = 0; + const server = destinationModeServer(target as unknown as McpServer, { + requestAccess: safeAccess, + consumeExecutionAuthorization: async () => { + consumes++; + return true; + }, + executeWithDeadline: async ({ operation }) => await operation(), + reportExecutionOutcome: async () => true, + }); + server.tool('atc_run', {}, async () => { + handlerCalls++; + return { content: [{ type: 'text' as const, text: 'unexpected' }] }; + }); + + const result = await target.handlers.get('atc_run')!( + { + ...exactAtcArgs, + scope: { + kind: 'objects', + objects: [{ objectType: 'CLAS', objectName: 'ZCL_OTHER' }], + }, + }, + { sessionId: 'session-1' }, + ); + + assert.strictEqual(result.isError, true); + assert.strictEqual(result.content[0]?.text, 'mcp_scope_denied'); + assert.strictEqual(consumes, 0); + assert.strictEqual(handlerCalls, 0); + }); + + it('denies an unmaterialised package expansion before grant consumption', async () => { + const target = new CapturingServer(); + let consumes = 0; + const server = destinationModeServer(target as unknown as McpServer, { + requestAccess: safeAccess, + consumeExecutionAuthorization: async () => { + consumes++; + return true; + }, + executeWithDeadline: async ({ operation }) => await operation(), + reportExecutionOutcome: async () => true, + }); + server.tool('atc_run', {}, async () => ({ + content: [{ type: 'text' as const, text: 'unexpected' }], + })); + + const result = await target.handlers.get('atc_run')!( + { + destination: 'tst-adt', + scope: { kind: 'package', packageName: 'ZRELEASE' }, + variant: 'DEFAULT', + }, + { sessionId: 'session-1' }, + ); + + assert.strictEqual(result.isError, true); + assert.strictEqual(consumes, 0); + }); + + it('consumes the opaque grant before I/O and denies replay', async () => { + const target = new CapturingServer(); + const events: string[] = []; + let unconsumed = true; + const access = safeAccess(); + access.scoped = { ...access.scoped!, maxToolCalls: 2 }; + const server = destinationModeServer(target as unknown as McpServer, { + requestAccess: () => access, + consumeExecutionAuthorization: async (input) => { + events.push('consume'); + assert.strictEqual( + input.authorizationId, + safeAccess().scoped?.authorizationId, + ); + assert.strictEqual( + input.authorizationToken, + 'header.payload.signature', + ); + if (!unconsumed) return false; + unconsumed = false; + return true; + }, + executeWithDeadline: async ({ operation }) => await operation(), + reportExecutionOutcome: async ({ outcome }) => { + events.push(`outcome:${outcome}`); + return true; + }, + }); + server.tool('atc_run', {}, async () => { + events.push('handler'); + return { content: [{ type: 'text' as const, text: 'permitted' }] }; + }); + + const first = await target.handlers.get('atc_run')!(exactAtcArgs, { + sessionId: 'session-1', + }); + const replay = await target.handlers.get('atc_run')!(exactAtcArgs, { + sessionId: 'session-1', + }); + + assert.strictEqual(first.isError, undefined); + assert.deepStrictEqual(events, [ + 'consume', + 'handler', + 'outcome:succeeded', + 'consume', + ]); + assert.strictEqual(replay.isError, true); + assert.strictEqual(replay.content[0]?.text, 'mcp_scope_denied'); + }); + + it('enforces maxToolCalls across the exact read catalogue', async () => { + const target = new CapturingServer(); + let handlerCalls = 0; + const access: McpRequestAccess = { + classes: ['server', 'read'], + destinationKeys: ['tst-adt'], + 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'], + operationClass: 'read', + maxToolCalls: 1, + }, + }; + const server = destinationModeServer(target as unknown as McpServer, { + requestAccess: () => access, + }); + server.tool('get_object', {}, async () => { + handlerCalls++; + return { content: [{ type: 'text' as const, text: 'permitted' }] }; + }); + const args = { + destination: 'tst-adt', + objectType: 'CLAS', + objectName: 'ZCL_RELEASE_GATE', + }; + + const first = await target.handlers.get('get_object')!(args, { + sessionId: 'session-1', + }); + const second = await target.handlers.get('get_object')!(args, { + sessionId: 'session-1', + }); + + assert.strictEqual(first.isError, undefined); + assert.strictEqual(second.isError, true); + assert.strictEqual(handlerCalls, 1); + }); + + it('stays fail-closed before consume when the runtime or outcome recorder is absent', async () => { + const target = new CapturingServer(); + let consumes = 0; + const server = destinationModeServer(target as unknown as McpServer, { + requestAccess: safeAccess, + consumeExecutionAuthorization: async () => { + consumes++; + return true; + }, + executeWithDeadline: async ({ operation }) => await operation(), + }); + server.tool('atc_run', {}, async () => ({ + content: [{ type: 'text' as const, text: 'unexpected' }], + })); + + const result = await target.handlers.get('atc_run')!(exactAtcArgs, { + sessionId: 'session-1', + }); + + assert.strictEqual(result.isError, true); + assert.strictEqual(consumes, 0); + }); + + it('enforces the signed result byte limit after a consumed execution', async () => { + const target = new CapturingServer(); + const access = safeAccess(); + access.scoped = { + ...access.scoped!, + safeExecutePolicy: { ...atcPolicy, maxResultBytes: 8 }, + }; + const server = destinationModeServer(target as unknown as McpServer, { + requestAccess: () => access, + consumeExecutionAuthorization: async () => true, + executeWithDeadline: async ({ operation }) => await operation(), + reportExecutionOutcome: async ({ outcome }) => { + assert.strictEqual(outcome, 'failed'); + return true; + }, + }); + server.tool('atc_run', {}, async () => ({ + content: [{ type: 'text' as const, text: 'larger-than-eight-bytes' }], + })); + + const result = await target.handlers.get('atc_run')!(exactAtcArgs, { + sessionId: 'session-1', + }); + + assert.strictEqual(result.isError, true); + assert.strictEqual(result.content[0]?.text, 'safe_execute_limit_exceeded'); + }); + + it('records deterministic tool failures only after the operation settles', async () => { + const target = new CapturingServer(); + const events: string[] = []; + const server = destinationModeServer(target as unknown as McpServer, { + requestAccess: safeAccess, + consumeExecutionAuthorization: async () => { + events.push('consume'); + return true; + }, + executeWithDeadline: async ({ operation }) => await operation(), + reportExecutionOutcome: async (input) => { + events.push(`outcome:${input.outcome}`); + assert.strictEqual( + input.authorizationId, + '33333333-3333-4333-8333-333333333333', + ); + assert.strictEqual( + input.authorizationToken, + 'header.payload.signature', + ); + return true; + }, + }); + server.tool('atc_run', {}, async () => { + events.push('handler-settled'); + return { + isError: true, + content: [{ type: 'text' as const, text: 'deterministic failure' }], + }; + }); + + const result = await target.handlers.get('atc_run')!(exactAtcArgs, { + sessionId: 'session-1', + }); + + assert.strictEqual(result.isError, true); + assert.deepStrictEqual(events, [ + 'consume', + 'handler-settled', + 'outcome:failed', + ]); + }); + + it('records outcome_unknown once after an uncertain operation settles without retrying SAP', async () => { + const target = new CapturingServer(); + const events: string[] = []; + let handlerCalls = 0; + const server = destinationModeServer(target as unknown as McpServer, { + requestAccess: safeAccess, + consumeExecutionAuthorization: async () => true, + executeWithDeadline: async ({ operation }) => { + await operation(); + throw new Error('transport outcome is uncertain'); + }, + reportExecutionOutcome: async ({ outcome }) => { + events.push(outcome); + return true; + }, + }); + server.tool('atc_run', {}, async () => { + handlerCalls++; + events.push('handler-settled'); + return { content: [{ type: 'text' as const, text: 'late result' }] }; + }); + + const result = await target.handlers.get('atc_run')!(exactAtcArgs, { + sessionId: 'session-1', + }); + + assert.strictEqual(result.isError, true); + assert.strictEqual(result.content[0]?.text, 'outcome_unknown'); + assert.strictEqual(handlerCalls, 1); + assert.deepStrictEqual(events, ['handler-settled', 'outcome_unknown']); + }); + + it('returns outcome_unknown when the single terminal report fails without rerunning SAP', async () => { + const target = new CapturingServer(); + let handlerCalls = 0; + let reports = 0; + const server = destinationModeServer(target as unknown as McpServer, { + requestAccess: safeAccess, + consumeExecutionAuthorization: async () => true, + executeWithDeadline: async ({ operation }) => await operation(), + reportExecutionOutcome: async () => { + reports++; + return false; + }, + }); + server.tool('atc_run', {}, async () => { + handlerCalls++; + return { content: [{ type: 'text' as const, text: 'SAP completed' }] }; + }); + + const result = await target.handlers.get('atc_run')!(exactAtcArgs, { + sessionId: 'session-1', + }); + + assert.strictEqual(result.isError, true); + assert.strictEqual(result.content[0]?.text, 'outcome_unknown'); + assert.strictEqual(handlerCalls, 1); + assert.strictEqual(reports, 1); + }); +}); diff --git a/packages/adt-mcp/tests/scope-enforcement.test.ts b/packages/adt-mcp/tests/scope-enforcement.test.ts index c4068d3f8..e51dc3db8 100644 --- a/packages/adt-mcp/tests/scope-enforcement.test.ts +++ b/packages/adt-mcp/tests/scope-enforcement.test.ts @@ -121,6 +121,10 @@ test('ATC analysis requires an explicit safe-execution class', async () => { assert.notStrictEqual(permitted.isError, true); assert.strictEqual(permitted.content[0]?.text, 'permitted'); assert.strictEqual(calls, 1); + assert.strictEqual( + MCP_TOOL_SCOPE_CATALOGUE.run_unit_tests?.operationClass, + 'safe_execute', + ); }); test('a read-scoped caller cannot dispatch a permitted read tool on an unauthorised destination', async () => { @@ -319,7 +323,10 @@ test('destination mode hides and rejects the legacy raw ATC URI target', async ( const destinations = destinationRegistry(() => undefined); const server = createMcpServer({ destinationRegistry: destinations, - requestAccess: () => ({ classes: ['read'], destinationKeys: ['dev'] }), + requestAccess: () => ({ + classes: ['safe_execute'], + destinationKeys: ['dev'], + }), }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -375,7 +382,6 @@ test('destination mode hides raw URI fields from all canonical read tools', asyn try { const tools = await client.listTools(); const forbiddenFields: Readonly> = { - atc_run: 'objectUri', get_callers_of: 'objectUri', get_callees_of: 'objectUri', find_references: 'objectUri', diff --git a/packages/adt-server/src/bin/adt-server.ts b/packages/adt-server/src/bin/adt-server.ts index 6e519f3a6..64867e684 100644 --- a/packages/adt-server/src/bin/adt-server.ts +++ b/packages/adt-server/src/bin/adt-server.ts @@ -1,5 +1,8 @@ import { createHttpBrokerOperations } from '../broker.js'; -import { createAdtServerMcpOptions } from '../mcp-runtime.js'; +import { + createAdtServerMcpOptions, + executeWithDeadlineAndAbort, +} from '../mcp-runtime.js'; import { loadRestRuntimeSecurity } from '../rest-runtime.js'; import { startAdtServer } from '../server.js'; @@ -21,6 +24,7 @@ async function main(): Promise { createAdtServerMcpOptions({ env: process.env, brokerOptions, + executeWithDeadline: executeWithDeadlineAndAbort, }), loadRestRuntimeSecurity({ tokenFile: restTokenFile, diff --git a/packages/adt-server/src/index.ts b/packages/adt-server/src/index.ts index 4fd5de1e0..299ca6e87 100644 --- a/packages/adt-server/src/index.ts +++ b/packages/adt-server/src/index.ts @@ -10,6 +10,7 @@ export { export { openApiDocument, openApiYaml } from './openapi.js'; export { createAdtServerMcpOptions, + executeWithDeadlineAndAbort, type AdtServerMcpRuntimeOptions, } from './mcp-runtime.js'; export { diff --git a/packages/adt-server/src/mcp-runtime.ts b/packages/adt-server/src/mcp-runtime.ts index ce494b607..873d37e18 100644 --- a/packages/adt-server/src/mcp-runtime.ts +++ b/packages/adt-server/src/mcp-runtime.ts @@ -3,7 +3,9 @@ import { readFile } from 'node:fs/promises'; import { createDestinationContextRegistry, createMcpInvocationVerifier, + type ToolContext, } from '@abapify/adt-mcp'; +import { runWithAdtAbortSignal } from '@abapify/adt-client'; import { createHttpDestinationContexts, type HttpBrokerOptions, @@ -17,6 +19,83 @@ type RuntimeEnvironment = Readonly>; export interface AdtServerMcpRuntimeOptions { env: RuntimeEnvironment; brokerOptions: HttpBrokerOptions; + /** Deployment-owned one-shot authorization consumer. */ + consumeExecutionAuthorization?: NonNullable< + ToolContext['consumeExecutionAuthorization'] + >; + /** Deployment-owned terminal outcome recorder. */ + reportExecutionOutcome?: NonNullable; + /** + * Optional deployment/test override for the execution-scoped abort runtime. + */ + executeWithDeadline?: NonNullable; +} + +const MAX_TIMER_DELAY_MS = 2_147_483_647; + +function scheduleDeadline( + deadline: number, + onDeadline: () => void, +): () => void { + let timer: ReturnType | undefined; + const schedule = () => { + const remaining = deadline - performance.now(); + if (remaining <= 0) { + onDeadline(); + return; + } + timer = setTimeout(schedule, Math.min(remaining, MAX_TIMER_DELAY_MS)); + }; + schedule(); + return () => { + if (timer !== undefined) clearTimeout(timer); + }; +} + +/** + * Aborts all SAP HTTP work in this async execution context at the signed + * deadline, then waits for the tool operation to settle before returning. + */ +export async function executeWithDeadlineAndAbort(input: { + maxDurationMs: number; + operation: () => Promise; +}): Promise { + if (!Number.isSafeInteger(input.maxDurationMs) || input.maxDurationMs <= 0) { + throw new RangeError('maxDurationMs must be a positive safe integer'); + } + + const abortController = new AbortController(); + const deadline = performance.now() + input.maxDurationMs; + let deadlineExceeded = false; + const markDeadlineExceeded = () => { + deadlineExceeded = true; + if (!abortController.signal.aborted) { + abortController.abort(new Error('safe_execute deadline exceeded')); + } + }; + const clearDeadline = scheduleDeadline(deadline, markDeadlineExceeded); + const deadlineHasPassed = () => + deadlineExceeded || performance.now() >= deadline; + + try { + const result = await runWithAdtAbortSignal( + abortController.signal, + input.operation, + ); + if (deadlineHasPassed()) { + markDeadlineExceeded(); + throw new Error('safe_execute deadline exceeded'); + } + return result; + } catch (error) { + if (deadlineHasPassed()) { + markDeadlineExceeded(); + throw new Error('safe_execute deadline exceeded', { cause: error }); + } + throw error; + } finally { + clearDeadline(); + } } function configuredValue( @@ -63,6 +142,18 @@ function allowedHostsFromEnv(env: RuntimeEnvironment): string[] | undefined { return [...new Set(hosts)]; } +function safeExecuteEnabled(env: RuntimeEnvironment): boolean { + const configured = configuredValue( + env, + 'ADT_SERVER_MCP_SAFE_EXECUTE_ENABLED', + ); + if (configured === undefined || configured === 'false') return false; + if (configured === 'true') return true; + throw new Error( + 'ADT_SERVER_MCP_SAFE_EXECUTE_ENABLED must be exactly true or false', + ); +} + async function loadP256PublicKey(file: string) { const pem = await readFile(file, 'utf8'); if (/-----BEGIN(?: EC)? PRIVATE KEY-----/u.test(pem)) { @@ -95,8 +186,33 @@ async function loadP256PublicKey(file: string) { export async function createAdtServerMcpOptions( options: AdtServerMcpRuntimeOptions, ): Promise { + const enableSafeExecute = safeExecuteEnabled(options.env); const invocation = invocationConfiguration(options.env); - if (!invocation) return undefined; + if (!invocation) { + if (enableSafeExecute) { + throw new Error( + 'ADT Server MCP safe_execute requires MCP invocation configuration', + ); + } + return undefined; + } + const executeWithDeadline = + options.executeWithDeadline ?? executeWithDeadlineAndAbort; + const safeExecuteOptions = (() => { + if (!enableSafeExecute) return {}; + const consumeExecutionAuthorization = options.consumeExecutionAuthorization; + const reportExecutionOutcome = options.reportExecutionOutcome; + if (!consumeExecutionAuthorization || !reportExecutionOutcome) { + throw new Error( + 'ADT Server MCP safe_execute requires deployment-owned authorization and outcome hooks', + ); + } + return { + consumeExecutionAuthorization, + reportExecutionOutcome, + executeWithDeadline, + }; + })(); const publicKey = await loadP256PublicKey(invocation.publicKeyFile); const { leaseProvider, contextFactory, resolveFrozenSource } = @@ -113,6 +229,7 @@ export async function createAdtServerMcpOptions( contextFactory, }), resolveFrozenSource, + ...safeExecuteOptions, allowedHosts: allowedHostsFromEnv(options.env), }; } diff --git a/packages/adt-server/src/server.ts b/packages/adt-server/src/server.ts index 008a997af..8bf51c164 100644 --- a/packages/adt-server/src/server.ts +++ b/packages/adt-server/src/server.ts @@ -30,8 +30,7 @@ export interface DestinationSummary { } export type FrozenSourceResolution = - | { sourceUri: string } - | { sourceCapability: string }; + { sourceUri: string } | { sourceCapability: string }; export type ResolveFrozenSource = (input: { destination: string; @@ -139,6 +138,18 @@ export interface AdtServerMcpOptions { * carried only in an AI Review invocation policy. */ resolveFrozenSource: ResolveFrozenSource; + /** Deployment-owned atomic authorization hook. */ + consumeExecutionAuthorization?: NonNullable< + import('@abapify/adt-mcp').ToolContext['consumeExecutionAuthorization'] + >; + /** Deployment-owned terminal outcome hook. */ + reportExecutionOutcome?: NonNullable< + import('@abapify/adt-mcp').ToolContext['reportExecutionOutcome'] + >; + /** Hard-cancellable SAP runtime; safe execution is denied when absent. */ + executeWithDeadline?: NonNullable< + import('@abapify/adt-mcp').ToolContext['executeWithDeadline'] + >; allowedHosts?: string[]; } @@ -213,8 +224,7 @@ function createServerMcpHandler( options: AdtServerOptions, host: string, sourceCapabilities: - | ReturnType - | undefined, + ReturnType | undefined, ) { if (!options.mcp) return undefined; const mcpOptions = options.mcp; @@ -253,6 +263,20 @@ function createServerMcpHandler( input, ); }, + ...(mcpOptions.consumeExecutionAuthorization + ? { + consumeExecutionAuthorization: + mcpOptions.consumeExecutionAuthorization, + } + : {}), + ...(mcpOptions.reportExecutionOutcome + ? { + reportExecutionOutcome: mcpOptions.reportExecutionOutcome, + } + : {}), + ...(mcpOptions.executeWithDeadline + ? { executeWithDeadline: mcpOptions.executeWithDeadline } + : {}), }, }); } diff --git a/packages/adt-server/tests/mcp-runtime.test.ts b/packages/adt-server/tests/mcp-runtime.test.ts index ee346d168..9e30d9a13 100644 --- a/packages/adt-server/tests/mcp-runtime.test.ts +++ b/packages/adt-server/tests/mcp-runtime.test.ts @@ -5,7 +5,11 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { SignJWT } from 'jose'; -import { createAdtServerMcpOptions } from '../src/mcp-runtime.js'; +import { + createAdtServerMcpOptions, + executeWithDeadlineAndAbort, +} from '../src/mcp-runtime.js'; +import { createAdtAdapter } from '@abapify/adt-client'; const brokerOptions = { baseUrl: 'http://adt-api.internal', @@ -63,12 +67,19 @@ test('accepts a dedicated P-256 public key without accepting a private key path' v: 1, kid: 'adt-mcp-test', principal: 'runtime-test-user', - agentId: 'system-assistant', + agentId: 'adt-execution', classes: ['server', 'read'], destinationKeys: ['dev'], correlationId: 'runtime-test-correlation', - constraint: { systemSid: 'DEV' }, - limits: {}, + constraint: { + kind: 'adt-execution-v1', + scopeId: '11111111-1111-4111-8111-111111111111', + executionId: '22222222-2222-4222-8222-222222222222', + systemSid: 'DEV', + resourceKeys: ['CLAS:ZCL_RUNTIME_TEST'], + toolNames: ['get_object'], + }, + limits: { maxToolCalls: 1 }, }) .setProtectedHeader({ alg: 'ES256', kid: 'adt-mcp-test', typ: 'JWT' }) .setIssuer('adt-api') @@ -88,6 +99,178 @@ test('accepts a dedicated P-256 public key without accepting a private key path' } }); +test('safe_execute uses the hard-cancellable SAP runtime by default', 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 { + 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', + }, + brokerOptions, + consumeExecutionAuthorization: async () => true, + reportExecutionOutcome: async () => true, + }); + + assert.strictEqual( + options?.executeWithDeadline, + executeWithDeadlineAndAbort, + ); + assert.strictEqual(typeof options?.reportExecutionOutcome, 'function'); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('safe_execute aborts SAP I/O and awaits settlement before reporting timeout', async () => { + const originalFetch = globalThis.fetch; + let observedSignal: AbortSignal | undefined; + let operationSettled = false; + let fetchCalls = 0; + globalThis.fetch = async (_input, init) => { + fetchCalls++; + observedSignal = init?.signal ?? undefined; + return await new Promise((_resolve, reject) => { + observedSignal?.addEventListener( + 'abort', + () => { + queueMicrotask(() => { + operationSettled = true; + reject(observedSignal?.reason); + }); + }, + { once: true }, + ); + }); + }; + const adapter = createAdtAdapter({ + baseUrl: 'https://sap.example.test', + username: 'test', + password: 'test', + }); + + try { + await assert.rejects( + executeWithDeadlineAndAbort({ + maxDurationMs: 10, + operation: async () => { + try { + return await adapter.request({ + method: 'GET', + url: '/sap/bc/adt/atc/runs', + }); + } catch { + // Tool handlers normalize transport failures into MCP results. + return { isError: true }; + } + }, + }), + /deadline exceeded/i, + ); + assert.strictEqual(observedSignal?.aborted, true); + assert.strictEqual(operationSettled, true); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.strictEqual(fetchCalls, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('safe_execute rejects a late settlement even when the event loop delays the timer', async () => { + await assert.rejects( + executeWithDeadlineAndAbort({ + maxDurationMs: 1, + operation: async () => { + const settleAfter = performance.now() + 10; + while (performance.now() < settleAfter) { + // Simulate synchronous result processing that delays timer delivery. + } + return { completed: true }; + }, + }), + /deadline exceeded/i, + ); +}); + +test('safe_execute rejects missing deployment-owned authorization hooks', 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', + }, + brokerOptions, + }), + /deployment-owned authorization and outcome hooks/i, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('safe_execute preserves injected authorization hooks unchanged', 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 consumeExecutionAuthorization = async () => true; + const reportExecutionOutcome = async () => true; + + 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', + }, + brokerOptions, + consumeExecutionAuthorization, + reportExecutionOutcome, + }); + + assert.strictEqual( + options?.consumeExecutionAuthorization, + consumeExecutionAuthorization, + ); + assert.strictEqual(options?.reportExecutionOutcome, reportExecutionOutcome); + } 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'); diff --git a/packages/adt-server/tests/server.test.ts b/packages/adt-server/tests/server.test.ts index c599199cf..b36891528 100644 --- a/packages/adt-server/tests/server.test.ts +++ b/packages/adt-server/tests/server.test.ts @@ -104,12 +104,19 @@ test('mounts signed MCP only at /mcp while preserving REST endpoints', async () v: 1, kid: 'mount-test-key', principal: 'mount-test-user', - agentId: 'system-assistant', + agentId: 'adt-execution', classes: ['server', 'read'], destinationKeys: ['dev'], correlationId: 'mount-test-correlation', - constraint: { systemSid: 'DEV' }, - limits: {}, + constraint: { + kind: 'adt-execution-v1', + scopeId: '11111111-1111-4111-8111-111111111111', + executionId: '22222222-2222-4222-8222-222222222222', + systemSid: 'DEV', + resourceKeys: ['CLAS:ZCL_MOUNT_TEST'], + toolNames: ['get_object'], + }, + limits: { maxToolCalls: 1 }, }) .setProtectedHeader({ alg: 'ES256', kid: 'mount-test-key', typ: 'JWT' }) .setIssuer('adt-api') @@ -147,10 +154,9 @@ test('mounts signed MCP only at /mcp while preserving REST endpoints', async () try { await client.connect(transport); - assert.ok( - (await client.listTools()).tools.some( - (tool) => tool.name === 'system_info', - ), + assert.deepStrictEqual( + (await client.listTools()).tools.map((tool) => tool.name), + ['get_object'], ); const ready = await fetch(`${server.url}/readyz`);