Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker-entrypoint.adt-server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ fi
# neither mounted nor accepted by the runtime configuration.
case "${ADT_SERVER_MCP_ENABLED:-false}" in
false)
unset ADT_SERVER_MCP_PUBLIC_KEY_FILE ADT_SERVER_MCP_KEY_ID ADT_SERVER_MCP_ISSUER ADT_SERVER_MCP_ALLOWED_HOSTS
unset ADT_SERVER_MCP_PUBLIC_KEY_FILE ADT_SERVER_MCP_KEY_ID ADT_SERVER_MCP_ISSUER ADT_SERVER_MCP_ALLOWED_HOSTS ADT_SERVER_MCP_SAFE_EXECUTE_ENABLED ADT_SERVER_MCP_AUTHORIZATION_CONSUMER_URL ADT_SERVER_MCP_OUTCOME_REPORTER_URL
;;
true)
if [ -z "${ADT_SERVER_MCP_PUBLIC_KEY_FILE:-}" ] || [ ! -s "$ADT_SERVER_MCP_PUBLIC_KEY_FILE" ]; then
Expand Down
78 changes: 76 additions & 2 deletions packages/adt-server/src/mcp-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,74 @@
return [...new Set(hosts)];
}

type ConsumeExecutionAuthorization = NonNullable<
ToolContext['consumeExecutionAuthorization']
>;
type ReportExecutionOutcome = NonNullable<
ToolContext['reportExecutionOutcome']
>;

function createHttpAuthorizationConsumer(
url: string,
fetchImpl: typeof globalThis.fetch = globalThis.fetch,
): ConsumeExecutionAuthorization {
const endpoint = new URL(url).toString();
return async (input) => {
const response = await fetchImpl(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${input.authorizationToken}`,
},
body: JSON.stringify(input),
});
return response.ok;
};
}

function createHttpOutcomeReporter(

Check warning on line 170 in packages/adt-server/src/mcp-runtime.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Update this function so that its implementation is not identical to the one on line 152.

See more on https://sonarcloud.io/project/issues?id=abapify_adt-cli&issues=AZ-U9cEkD9e_hzU_eqKn&open=AZ-U9cEkD9e_hzU_eqKn&pullRequest=146
url: string,
fetchImpl: typeof globalThis.fetch = globalThis.fetch,
): ReportExecutionOutcome {
const endpoint = new URL(url).toString();
return async (input) => {
const response = await fetchImpl(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${input.authorizationToken}`,
},
body: JSON.stringify(input),
});
return response.ok;
};
}

function safeExecuteHooksFromEnv(
env: RuntimeEnvironment,
fetchImpl: typeof globalThis.fetch = globalThis.fetch,
): {
consumeExecutionAuthorization?: ConsumeExecutionAuthorization;
reportExecutionOutcome?: ReportExecutionOutcome;
} {
const consumerUrl = configuredValue(
env,
'ADT_SERVER_MCP_AUTHORIZATION_CONSUMER_URL',
);
const reporterUrl = configuredValue(
env,
'ADT_SERVER_MCP_OUTCOME_REPORTER_URL',
);
return {
consumeExecutionAuthorization: consumerUrl
? createHttpAuthorizationConsumer(consumerUrl, fetchImpl)
: undefined,
reportExecutionOutcome: reporterUrl
? createHttpOutcomeReporter(reporterUrl, fetchImpl)
: undefined,
};
}

function safeExecuteEnabled(env: RuntimeEnvironment): boolean {
const configured = configuredValue(
env,
Expand Down Expand Up @@ -198,10 +266,16 @@
}
const executeWithDeadline =
options.executeWithDeadline ?? executeWithDeadlineAndAbort;
const envHooks = enableSafeExecute
? safeExecuteHooksFromEnv(options.env)
: undefined;
const safeExecuteOptions = (() => {
if (!enableSafeExecute) return {};
const consumeExecutionAuthorization = options.consumeExecutionAuthorization;
const reportExecutionOutcome = options.reportExecutionOutcome;
const consumeExecutionAuthorization =
options.consumeExecutionAuthorization ??
envHooks?.consumeExecutionAuthorization;
const reportExecutionOutcome =
options.reportExecutionOutcome ?? envHooks?.reportExecutionOutcome;
if (!consumeExecutionAuthorization || !reportExecutionOutcome) {
throw new Error(
'ADT Server MCP safe_execute requires deployment-owned authorization and outcome hooks',
Expand Down
181 changes: 181 additions & 0 deletions packages/adt-server/tests/mcp-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,187 @@ test('safe_execute preserves injected authorization hooks unchanged', async () =
}
});

test('safe_execute creates HTTP hooks from environment URLs', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'adt-server-mcp-'));
const publicKeyPath = path.join(directory, 'public.pem');
const { publicKey } = generateKeyPairSync('ec', {
namedCurve: 'prime256v1',
});
await writeFile(
publicKeyPath,
publicKey.export({ type: 'spki', format: 'pem' }),
'utf8',
);

const fetchCalls: { url: string; init: RequestInit }[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
fetchCalls.push({ url: input.toString(), init: init as RequestInit });
return new Response(null, { status: 204 });
};

try {
const options = await createAdtServerMcpOptions({
env: {
ADT_SERVER_MCP_PUBLIC_KEY_FILE: publicKeyPath,
ADT_SERVER_MCP_KEY_ID: 'adt-mcp-test',
ADT_SERVER_MCP_ISSUER: 'adt-api',
ADT_SERVER_MCP_SAFE_EXECUTE_ENABLED: 'true',
ADT_SERVER_MCP_AUTHORIZATION_CONSUMER_URL: 'http://auth.test/consume',
ADT_SERVER_MCP_OUTCOME_REPORTER_URL: 'http://auth.test/report',
},
brokerOptions,
});

assert.ok(options);
assert.strictEqual(
typeof options?.consumeExecutionAuthorization,
'function',
);
assert.strictEqual(typeof options?.reportExecutionOutcome, 'function');

const consumeInput = {
authorizationId: 'auth-1',
authorizationToken: 'token-abc',
principal: 'user-1',
scopeId: '11111111-1111-4111-8111-111111111111',
executionId: '22222222-2222-4222-8222-222222222222',
systemSid: 'DEV',
resourceKeys: ['CLAS:ZCL_FOO'],
destination: 'dev',
operationId: 'atc_run' as const,
policy: {
operationId: 'atc_run' as const,
check: 'atc' as const,
maxDurationMs: 10_000,
maxResultBytes: 1_000_000,
maxFindings: 100,
maxObjects: 100,
maxPackages: 10,
maxVariants: 5,
},
};
const consumeResult = await options?.consumeExecutionAuthorization?.(
consumeInput as any,
);
assert.strictEqual(consumeResult, true);
assert.strictEqual(fetchCalls.length, 1);
assert.strictEqual(fetchCalls[0].url, 'http://auth.test/consume');
assert.strictEqual(fetchCalls[0].init.method, 'POST');
assert.strictEqual(
(fetchCalls[0].init.headers as Record<string, string>).Authorization,
'Bearer token-abc',
);
const consumeBody = JSON.parse(fetchCalls[0].init.body as string);
assert.strictEqual(consumeBody.authorizationId, 'auth-1');
assert.strictEqual(consumeBody.operationId, 'atc_run');

const reportInput = {
authorizationId: 'auth-1',
authorizationToken: 'token-abc',
outcome: 'succeeded' as const,
};
const reportResult = await options?.reportExecutionOutcome?.(
reportInput as any,
);
assert.strictEqual(reportResult, true);
assert.strictEqual(fetchCalls.length, 2);
assert.strictEqual(fetchCalls[1].url, 'http://auth.test/report');
const reportBody = JSON.parse(fetchCalls[1].init.body as string);
assert.strictEqual(reportBody.outcome, 'succeeded');
} finally {
globalThis.fetch = originalFetch;
await rm(directory, { recursive: true, force: true });
}
});

test('safe_execute HTTP hooks return false on non-2xx responses', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'adt-server-mcp-'));
const publicKeyPath = path.join(directory, 'public.pem');
const { publicKey } = generateKeyPairSync('ec', {
namedCurve: 'prime256v1',
});
await writeFile(
publicKeyPath,
publicKey.export({ type: 'spki', format: 'pem' }),
'utf8',
);

const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(null, { status: 403 });

try {
const options = await createAdtServerMcpOptions({
env: {
ADT_SERVER_MCP_PUBLIC_KEY_FILE: publicKeyPath,
ADT_SERVER_MCP_KEY_ID: 'adt-mcp-test',
ADT_SERVER_MCP_ISSUER: 'adt-api',
ADT_SERVER_MCP_SAFE_EXECUTE_ENABLED: 'true',
ADT_SERVER_MCP_AUTHORIZATION_CONSUMER_URL: 'http://auth.test/consume',
ADT_SERVER_MCP_OUTCOME_REPORTER_URL: 'http://auth.test/report',
},
brokerOptions,
});

const result = await options?.consumeExecutionAuthorization?.({
authorizationId: 'auth-1',
authorizationToken: 'token-abc',
principal: 'user-1',
scopeId: '11111111-1111-4111-8111-111111111111',
executionId: '22222222-2222-4222-8222-222222222222',
systemSid: 'DEV',
resourceKeys: ['CLAS:ZCL_FOO'],
destination: 'dev',
operationId: 'atc_run',
policy: {
operationId: 'atc_run',
check: 'atc',
maxDurationMs: 10_000,
maxResultBytes: 1_000_000,
maxFindings: 100,
maxObjects: 100,
maxPackages: 10,
maxVariants: 5,
},
} as any);
assert.strictEqual(result, false);
} finally {
globalThis.fetch = originalFetch;
await rm(directory, { recursive: true, force: true });
}
});

test('safe_execute still requires hooks when only one environment URL is set', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'adt-server-mcp-'));
const publicKeyPath = path.join(directory, 'public.pem');
const { publicKey } = generateKeyPairSync('ec', {
namedCurve: 'prime256v1',
});
await writeFile(
publicKeyPath,
publicKey.export({ type: 'spki', format: 'pem' }),
'utf8',
);

try {
await assert.rejects(
createAdtServerMcpOptions({
env: {
ADT_SERVER_MCP_PUBLIC_KEY_FILE: publicKeyPath,
ADT_SERVER_MCP_KEY_ID: 'adt-mcp-test',
ADT_SERVER_MCP_ISSUER: 'adt-api',
ADT_SERVER_MCP_SAFE_EXECUTE_ENABLED: 'true',
ADT_SERVER_MCP_AUTHORIZATION_CONSUMER_URL: 'http://auth.test/consume',
},
brokerOptions,
}),
/deployment-owned authorization and outcome hooks/i,
);
} finally {
await rm(directory, { recursive: true, force: true });
}
});

test('rejects a non-P-256 public key before it can enable MCP', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'adt-server-mcp-'));
const publicKeyPath = path.join(directory, 'public.pem');
Expand Down
Loading