Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- Opt-in AgentCore Gateway path for the built-in MCP (`enable_mcp_gateway`, off by default). The four agent CLIs keep the same `aidlc` server and tool names; a runtime proxy forwards calls to an `AWS_IAM` Gateway whose Lambda target verifies a KMS-signed, control-plane grant and the current execution and stage state on every call before running the existing tool handlers. Requires VPC networking. The runtime role keeps its existing permissions in this release.

## [2.2.0] - 2026-09-29

This release adds AWS CodeCommit support, custom Cognito login domains, code-file traceability, and direct unit branch and pull-request links in reviews, alongside data-store protection, structured logging, workflow reliability fixes, and usability improvements.
Expand Down
4 changes: 4 additions & 0 deletions docs/concepts/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ flowchart TB

**AgentCore runtime.** A Bedrock AgentCore runtime running an ARM64 container image, with a dedicated microVM and filesystem per session. Each stage spawns the headless CLI selected for that intent (Kiro, Claude Code, OpenCode, or Codex), whose only interface to the application is a stdio **MCP server**: business reads and writes go to Neptune as typed, provenance-stamped artifacts; questions, outputs, and metrics go to DynamoDB. The workspace holds the project's real git checkout, but all git operations — branch, commit, push, merge — are engine-owned and deterministic; the agent never runs git and never holds source-control credentials. After each stage, deterministic sensors verify the output before the run advances.

**Built-in MCP through AgentCore Gateway (opt-in).** With `enable_mcp_gateway = true` (requires `network_mode = "VPC"`), the runtime no longer starts the MCP server in the agent's microVM. It starts a stdio proxy that registers the same tools and forwards each call, SigV4-signed, to an AgentCore Gateway (`AWS_IAM` inbound) whose Lambda target (`mcp-tools`) runs the same graph and process handlers. The orchestrator and discussions Lambdas sign a short ES256 grant with a KMS key that only they may use; it names the space, intent, execution, stage lane and leg, mode and release-policy digest of the invocation. The runtime relays the grant without exposing it to command handlers or the agent CLI. The Lambda verifies it on every call, rejects session context outside it, re-reads the execution and stage row (a finished, cancelled or superseded leg is denied immediately) and enforces the role's tool catalog before any handler runs. Every allow, deny and failure is logged with its scope. Without a grant the runtime refuses to start the MCP rather than falling back to the local server. This changes where the built-in tools run and how they are authorized; it does not yet remove the runtime role's own data permissions, which other runtime commands still use. Executions started before the flag have no grants and would fail the next time they invoke the runtime, so `deploy-terraform.sh` refuses a plan that switches the runtime to the Gateway while any execution is started, running or parked, and lists them. Stop starting intents until the deployment has succeeded (retries included), let running executions park or finish, cancel the parked and queued ones, apply, then rewind each to its current stage (its first stage if it had not reached one), which relaunches it with grants (`AIDLC_ALLOW_ACTIVE_EXECUTIONS=1` applies anyway). The check is in the deploy script; a direct `terraform apply` skips it.

**Gateway trust boundary.** The Gateway accepts calls signed with the runtime role (`AWS_IAM`), and the tools Lambda accepts only those that also carry a valid grant. These are two checks, not two independent barriers: code in the runtime's microVM can read both the role's credentials and the grant. What the grant adds is that the runtime cannot widen its own scope: it cannot mint a grant for another space, execution or stage, and a retired or superseded grant is refused on the next call. The limits are explicit: all agents of a stage share one grant; the role inside the stage (author, reviewer, reader), the persona and the checkpoint and question flags are asserted by the runtime in the unsigned session header; nothing binds a grant to the caller that presents it; and retirement refuses subsequent calls, it does not cancel one already admitted. The native `CUSTOM_JWT` authorizer would replace only the Gateway's inbound check: the tools Lambda does not receive the token's claims, and the live checks behind it stay.

**Human gates.** When an agent calls the `ask_question` MCP tool — or the orchestrator opens an engine gate (walking-skeleton review, batch review, halt-and-ask) — the run parks on a durable callback. The question renders in the UI; the user's answer flows through the `intents` Lambda, which completes the callback and resumes the run exactly where it parked. No polling, no queue.

**Pull requests.** For PR-per-unit delivery, the orchestrator persists one durable callback when a unit is ready for review. A scheduled `intents` maintenance pass checks GitHub or GitLab while the durable execution remains suspended, waking it only when the PR merges or closes, a reviewed branch moves, or authenticated feedback is queued. The sparse process-table maintenance index also lets the same pass detect terminal durable executions before their local expiry. After every unit and shared stage succeeds, the orchestrator opens the final intent-to-base pull request through the same provider layer.
Expand Down
10 changes: 7 additions & 3 deletions lambda/agentcore/http-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import { commandDefinition } from './command-registry.js';
import { installProcessGroupShutdown } from './cli/spawn.js';
import { createCredentialSession } from './credential-session.js';
import { accountCredentialInvocation } from './invocation-accounting.js';
import { runWithGatewayGrant } from './mcp/gateway-context.js';

const logger = new Logger({ persistentKeys: { component: 'agentcore' } });

Expand Down Expand Up @@ -134,10 +135,13 @@ export const dispatchInvocation = async ({
: {};
const handlerPayload = { ...payload };
delete handlerPayload.agentCredentialGrant;
delete handlerPayload.gatewayGrant;
credentialSession = context.credentialSession ?? null;
const result = credentialSession
? await credentialSession.run(() => handler(handlerPayload, context))
: await handler(handlerPayload, context);
const runHandler = () =>
credentialSession
? credentialSession.run(() => handler(handlerPayload, context))
: handler(handlerPayload, context);
const result = await runWithGatewayGrant(payload.gatewayGrant ?? null, runHandler);
// Command-level failures are part of the application protocol. Keep them on
// HTTP 200 so Bedrock AgentCore returns the JSON body to the orchestrator
// instead of turning the response into an SDK transport exception. Log them
Expand Down
9 changes: 9 additions & 0 deletions lambda/agentcore/mcp/gateway-context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { AsyncLocalStorage } from 'node:async_hooks';

const context = new AsyncLocalStorage();

// The control-plane grant travels with the invocation (including detached
// background jobs started inside it) but never through command payloads, so a
// handler cannot log, persist or forward it by accident.
export const runWithGatewayGrant = (grant, fn) => (grant ? context.run(grant, fn) : fn());
export const currentGatewayGrant = () => context.getStore() ?? null;
168 changes: 168 additions & 0 deletions lambda/agentcore/mcp/gateway-proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// Runtime-owned stdio MCP server that forwards the built-in `aidlc` tools to
// AgentCore Gateway. The runtime bridge spawns it in place of the local server
// when the Gateway is enabled, so the four CLIs keep the same `aidlc` server
// name, tool names and stdio wiring.
//
// It holds no data-plane permissions of its own: every call is SigV4-signed
// for bedrock-agentcore:InvokeGateway and carries the control-plane grant plus
// the runtime's session context. The Lambda behind the Gateway is the
// authority for every call.
//
// ENV (runtime-provided, never agent-provided):
// AIDLC_MCP_GATEWAY_URL https://<gateway>.gateway.bedrock-agentcore.<region>.amazonaws.com/mcp
// AIDLC_MCP_GATEWAY_GRANT control-plane grant for this invocation
// AIDLC_MCP_GATEWAY_TARGET Gateway target name (default `aidlc`)
// plus the V2_* scope variables the local server reads.
import { createHash, createHmac } from 'node:crypto';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { z } from 'zod';
import { SignatureV4 } from '@smithy/signature-v4';
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
import { registerTools } from './server.js';
import { contextFromEnv } from './startup-context.js';
import { GRANT_HEADER, SESSION_HEADER, encodeGatewaySession } from './gateway-session.js';

const SEPARATOR = '___';
const TOOL_CALL_TIMEOUT_MS = 14 * 60 * 1000;

class NodeSha256 {
constructor(secret) {
this.hash = secret ? createHmac('sha256', secret) : createHash('sha256');
}
update(data) {
this.hash.update(typeof data === 'string' ? Buffer.from(data) : data);
}
async digest() {
return new Uint8Array(this.hash.digest());
}
}

const headerEntries = (headers) => {
if (!headers) return [];
if (typeof headers.forEach === 'function' && !Array.isArray(headers)) {
const entries = [];
headers.forEach((value, name) => entries.push([name, value]));
return entries;
}
return Array.isArray(headers) ? headers : Object.entries(headers);
};

// SigV4 needs the exact bytes sent, so only string bodies (the MCP SDK's JSON
// POSTs) and empty bodies (GET/DELETE) are supported.
export const createSignedFetch = ({
region,
credentials = fromNodeProviderChain(),
service = 'bedrock-agentcore',
fetchImpl = fetch,
}) => {
const signer = new SignatureV4({ service, region, credentials, sha256: NodeSha256 });
return async (input, init = {}) => {
const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
if (init.body !== undefined && init.body !== null && typeof init.body !== 'string') {
throw new Error('Gateway proxy can only sign string request bodies');
}
const headers = Object.fromEntries(
headerEntries(init.headers).map(([name, value]) => [name.toLowerCase(), value]),
);
headers.host = url.host;
const signed = await signer.sign({
method: init.method ?? 'GET',
protocol: url.protocol,
hostname: url.hostname,
port: url.port ? Number(url.port) : undefined,
path: url.pathname,
query: Object.fromEntries(url.searchParams),
headers,
body: init.body ?? undefined,
});
const { host: _host, ...sendHeaders } = signed.headers;
return fetchImpl(url, { ...init, headers: sendHeaders });
};
};

// Gateway serializes a Lambda target's JSON result into a text content part.
// The Lambda returns the local server's MCP envelope, so unwrap it back.
export const unwrapGatewayResult = (result) => {
const text = result?.content?.length === 1 ? result.content[0]?.text : null;
if (typeof text !== 'string') return result;
try {
const inner = JSON.parse(text);
if (inner && Array.isArray(inner.content)) {
return inner.isError ? { content: inner.content, isError: true } : { content: inner.content };
}
} catch {
// Not an envelope: pass the Gateway's result through unchanged.
}
return result;
};

export const gatewayForwardingHandlers = ({ client, target }) =>
new Proxy(
{},
{
get: (_handlers, name) => async (args) =>
unwrapGatewayResult(
await client.callTool(
{ name: `${target}${SEPARATOR}${String(name)}`, arguments: args ?? {} },
undefined,
{ timeout: TOOL_CALL_TIMEOUT_MS, resetTimeoutOnProgress: true },
),
),
},
);

// The catalog (names, descriptions, zod schemas, role/policy selection) is the
// local server's own registration, so agents see byte-identical tools whichever
// path serves them. Only execution goes through the Gateway.
export const createGatewayProxyServer = ({ client, target, role, scope, env = process.env }) => {
const server = new McpServer({ name: 'aidlc-v2-mcp', version: '1.0.0' });
const registered = registerTools({
server,
handlers: gatewayForwardingHandlers({ client, target }),
role,
stageId: scope.stageId,
policy: scope.policy,
checkpointOwner: scope.checkpointOwner,
canAsk: scope.canAsk,
z,
env,
});
return { server, registered };
};

export const startGatewayProxy = async ({ env = process.env } = {}) => {
const gatewayUrl = env.AIDLC_MCP_GATEWAY_URL;
const grant = env.AIDLC_MCP_GATEWAY_GRANT;
if (!gatewayUrl || !grant) throw new Error('Gateway MCP requires a gateway URL and grant');
const { scope, role } = contextFromEnv(env);
const target = env.AIDLC_MCP_GATEWAY_TARGET || 'aidlc';

const transport = new StreamableHTTPClientTransport(new URL(gatewayUrl), {
fetch: createSignedFetch({ region: env.AWS_REGION }),
requestInit: {
headers: {
[GRANT_HEADER]: grant,
[SESSION_HEADER]: encodeGatewaySession({ ...scope, role }),
},
},
});
const client = new Client({ name: 'aidlc-gateway-proxy', version: '1.0.0' });
await client.connect(transport);

const { server, registered } = createGatewayProxyServer({ client, target, role, scope, env });
await server.connect(new StdioServerTransport());
console.error(
`[agentcore-mcp] gateway proxy connected (role=${role}, tools=${registered.length})`,
);
return server;
};

if (import.meta.url === `file://${process.argv[1]}`) {
startGatewayProxy().catch((e) => {
console.error('[agentcore-mcp] gateway proxy fatal:', e?.message);
process.exit(1);
});
}
1 change: 1 addition & 0 deletions lambda/agentcore/mcp/gateway-session.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '../../shared/mcp/gateway-session.js';
83 changes: 1 addition & 82 deletions lambda/agentcore/mcp/graph-manager.js
Original file line number Diff line number Diff line change
@@ -1,82 +1 @@
// Owns the MCP server's Neptune connection lifecycle. The MCP child can stay
// alive while an agent thinks for several minutes; Neptune SigV4 WebSocket
// headers are signed at connection creation, so reconnect before they go stale
// and retry once if the server rejects an expired signature anyway.

const DEFAULT_MAX_AGE_MS = 4 * 60 * 1000;

const errorText = (err, seen = new Set()) => {
if (!err || seen.has(err)) return '';
seen.add(err);
const parts = [err.name, err.message, err.code, err.statusCode, err.status, err.stack];
if (err.$metadata?.httpStatusCode) parts.push(String(err.$metadata.httpStatusCode));
if (err.cause) parts.push(errorText(err.cause, seen));
return parts.filter(Boolean).join('\n');
};

export const isRetryableGraphAuthError = (err) => {
if (err?.name === 'GraphWriteError') return false;
const text = errorText(err);
return (
/\b403\b|forbidden|unexpected server response/i.test(text) &&
/signature|sigv4|x-amz-date|expired|request time|current time|too large|forbidden/i.test(text)
);
};

export const createGraphManager = ({
openGraph,
createWriter,
closeGraphSource,
scope,
now = () => Date.now(),
maxAgeMs = DEFAULT_MAX_AGE_MS,
isRetryable = isRetryableGraphAuthError,
} = {}) => {
if (!openGraph) throw new Error('createGraphManager requires openGraph');
if (!createWriter) throw new Error('createGraphManager requires createWriter');
if (!closeGraphSource) throw new Error('createGraphManager requires closeGraphSource');

let g = null;
let writer = null;
let openedAt = 0;
let opening = null;

const closeCurrent = async () => {
const current = g;
g = null;
writer = null;
openedAt = 0;
if (current) await closeGraphSource(current);
};

const ensureWriter = async ({ force = false } = {}) => {
if (force || (g && maxAgeMs > 0 && now() - openedAt >= maxAgeMs)) {
await closeCurrent();
}
if (writer) return writer;
if (!opening) {
opening = (async () => {
const nextG = await openGraph();
g = nextG;
openedAt = now();
writer = createWriter({ g: nextG, scope });
return writer;
})().finally(() => {
opening = null;
});
}
return opening;
};

const withWriter = async (fn) => {
try {
return await fn(await ensureWriter());
} catch (err) {
if (!isRetryable(err)) throw err;
await closeCurrent();
return fn(await ensureWriter({ force: true }));
}
};

return { withWriter, close: closeCurrent };
};
export * from '../../shared/mcp/graph-manager.js';
Loading
Loading