Lesson 06 · Effect AgentCore

Gateway: explainer search as an MCP tool

So far searchExplainers has run in-process — lesson 03's tool loop calls it right inside the container. AgentCore Gateway lets that tool run as its own service the agent reaches over MCP. In this lesson you move the keyword search into an Effect Lambda, expose it through a Gateway as an MCP tool, and wire it to the Runtime by handing the container the Gateway's URL — where a small SigV4-signed MCP client calls it. The agent's respond loop does not change one line — the searchExplainers Tool keeps the same name and schema; only its handler changes.

01From in-process tool to a Gateway tool

Recap lesson 03: the model asks for a tool, the loop runs runSearchExplainers, and the search executes inside the agent container over a bundled corpus. That is the simplest thing that works — and for a single agent it is genuinely fine.

You externalise the tool when the shape of the system grows past one container. Three reasons recur:

AgentCore Gateway is the front door that presents a Lambda to the model as an MCP tool — a tool target the model calls by name, exactly like an in-process one, except the call now hops out of the container.

search core: given/search/index.ts · in-process tool: src/tools.ts

02The search Lambda

The Lambda wraps the same given keyword-search core you already use in-process. Nothing about the ranking changes — you are only moving where it runs. Alchemy's AWS.Lambda.Function bundles a TypeScript entry module, provisions an execution role, and uploads the zip for you:

import * as AWS from "alchemy/AWS";
import { Duration } from "effect";

const searchFn = yield* AWS.Lambda.Function<AWS.Lambda.Function>()("search", {
  main: "./given/search/lambda.ts",   // bundles given/search + corpus
  handler: "handler",                 // the exported async handler
  isExternal: true,                   // plain handler, not an Effect entrypoint
  runtime: "nodejs24.x",
  timeout: Duration.seconds(10),
});

The props are faithful to FunctionProps: main is the entry module (required), handler is the exported symbol to call, isExternal: true bundles that handler directly instead of wrapping it in an Effect entrypoint (so the file needs no default export), runtime is "nodejs22.x" or "nodejs24.x", and timeout is a Duration rounded up to whole seconds. env and url are optional and we don't need them here.

Inside the handler, the contract is identical to the local tool. The Lambda loads the corpus — from S3 or DynamoDB in production, the bundled corpus in dev — and runs the same search(corpus, query) core. The Doc[] input and Hit[] output types are unchanged; only the source of the corpus moves:

// given/search/lambda.ts — effectful handler, plain entry point
import { Effect } from "effect";
import { search, type Hit } from "./index.ts";
import { corpus } from "./corpus.ts";          // dev: bundled; prod: S3/DynamoDB

// The logic is an Effect (mirrors src/tools.ts's runSearchExplainers)…
export const searchExplainers = (query: string): Effect.Effect<ReadonlyArray<Hit>> =>
  Effect.sync(() => search(corpus, query));

// …run at the boundary. AgentCore invokes a Gateway Lambda DIRECTLY (not over HTTP),
// so this is a plain handler bundled via `isExternal: true`, not an Effect `{ fetch }`.
export const handler = (event: { query: string }): Promise<ReadonlyArray<Hit>> =>
  Effect.runPromise(searchExplainers(event.query));

// Also default-export it: Alchemy's Function `diff` re-bundles to hash the code without
// `isExternal`, and that path needs a default export to resolve. AWS still runs `handler`.
export default handler;

That search function is the one from given/search/index.ts: a deterministic term-frequency score with a small title boost, returning ranked Hit objects (id, title, score, snippet). The in-process runSearchExplainers in src/tools.ts calls exactly the same core — so the Lambda and the local tool are two front-ends over one search.

given/search/index.ts · src/tools.ts · props from alchemy/AWS/Lambda/Function.ts

03Exposing it as a Gateway MCP tool

A Lambda on its own is not yet a tool the model can call — it needs a front door that speaks MCP. The course ships an AgentCore.Gateway resource alongside the Runtime resource (Alchemy has no AgentCore support, so the course authors both). You consume it the same way you consume Runtime from alchemy.run.ts:

import { Gateway } from "./given/agentcore/index.ts";

import { searchExplainers } from "./src/tools.ts";   // the SAME Tool the agent uses

const gateway = yield* Gateway("EffectDocsTools", {
  roleArn: role.roleArn,
  // One target per Lambda; its `tools` are Effect Tool definitions.
  targets: [
    {
      name: "search",
      lambdaArn: searchFn.functionArn,
      tools: [searchExplainers],   // name + description + parameters Schema, reused
    },
  ],
});

The target's tools are the same Effect Tool values the agent uses in-process (src/tools.ts) — not a hand-written JSON Schema. The resource derives each MCP tool's name, description, and parameters from the Tool (Tool.getJsonSchema turns the parameters Schema.Struct({ query }) into the { type, properties, required } the gateway wants), so the tool the model calls in lesson 03 and the tool the gateway publishes can't drift apart. Add more Tools to expose more tools on one Lambda, or more targets for more Lambdas. Under the hood the resource calls the AgentCore control plane: createGateway to stand up the MCP front door, then one createGatewayTarget per target. The roleArn is the runtime's role (lesson 05), so the Gateway is allowed to invoke the target Lambdas.

Alpha — verify before you lean on it

AgentCore is alpha and the Gateway resource is authored by the course, not by Alchemy. The control-plane operations createGateway / createGatewayTarget live in @distilled.cloud/aws's bedrock-agentcore-control client; the sibling Runtime.ts still carries a TODO to verify the live request/response shapes against the SDK and to pin the version. Treat the exact targets field names and the control-plane call signatures as shape, not gospel — re-check them against the pinned @distilled.cloud/aws release and date your stack when you deploy.

Gateway authored alongside given/agentcore — Runtime.ts · control plane: @distilled.cloud/aws bedrock-agentcore-control — createGateway / createGatewayTarget

04Calling the Gateway from the agent

Provisioning the Gateway doesn't make the agent use it. The Runtime needs three things to call the Gateway tool at request time: IAM permission to reach it, the Gateway's address, and a client that speaks its protocol. Our Gateway is a course-authored resource (not a framework Platform with a .bind() helper), so we wire the three by hand — which is also the honest picture of what .bind() would do for you.

1 · Address. Pass the Gateway's MCP endpoint into the container as an env var:

const runtime = yield* Runtime("EffectDocsAgent", {
  containerUri: image.imageUri,
  roleArn: role.roleArn,
  environment: {
    BEDROCK_MODEL: bedrockModel,         // resolved via Config.withDefault(…) — lesson 03
    GATEWAY_URL: gateway.gatewayUrl,     // the …/mcp endpoint
  },
});

2 · Client. The Gateway is an MCP server, and effect ships McpServer but no MCP client — so the course hand-rolls a small one (src/gateway.ts). It speaks MCP Streamable HTTP (POST JSON-RPC: initializetools/listtools/call) and, because we created the Gateway with the AWS_IAM authorizer, SigV4-signs every request for service bedrock-agentcore with the container's execution-role credentials (via aws4fetch, the same signer the SDK uses). AgentCore namespaces a target's tools as <target>___<tool>, so the client resolves the real name from tools/list rather than hardcoding it.

3 · Permission. Two distinct grants on the execution role, and they are easy to conflate. lambda:InvokeFunction lets the Gateway (which assumes this role) invoke the Lambda. But the container calling the Gateway's AWS_IAM endpoint needs its own action — bedrock-agentcore:InvokeGateway — which lesson 06 adds to the role. Miss it and the signed call returns AccessDeniedException. An earlier version of this lesson caught that error and silently fell back to in-process search — which masked exactly this misconfiguration: answers kept coming, the Lambda never fired. So the gateway backing has no fallback; a failure is mapped to an AiError and surfaces loudly.

4 · The swap, as a Layer. The two backings aren't an if inside the handler — they're two Layers providing the SAME SearchTools handler service. The composition root picks one off the GATEWAY_URL Config value; the agent loop never sees the difference. That's Effect dependency injection: swap the wire, not the code.

// tools.ts — the in-process backing (lessons 02–05)
export const SearchToolsLayer = SearchTools.toLayer({
  searchExplainers: ({ query }) => runSearchExplainers({ query }),
});

// gateway.ts — the gateway backing. No fallback: a failure stays loud.
export const GatewaySearchToolsLayer = (url: string) =>
  SearchTools.toLayer({
    searchExplainers: ({ query }) => callSearchExplainers(url, query),  // + map error → AiError
  });

// agent.ts — pick one at the composition root; the agent never changes.
// Same Config + Layer.unwrap shape as ModelLive (lesson 03): GATEWAY_URL set → gateway.
const SearchLive = Layer.unwrap(
  Effect.map(Config.option(Config.string("GATEWAY_URL")), (url): typeof SearchToolsLayer =>
    Option.isSome(url) ? GatewaySearchToolsLayer(url.value) : SearchToolsLayer,
  ),
);
// provided where `serve` surfaces the per-request tool requirement (ServerLive)
Verify it with the MCP Inspector

The Gateway is AWS_IAM-authed, so the official MCP Inspector (plain Streamable HTTP, no SigV4) can't reach it directly. Run the signing proxy and point the Inspector at it:

GATEWAY_URL=<gatewayUrl output> AWS_REGION=eu-central-1 pnpm mcp:proxy   # localhost:8765
npx @modelcontextprotocol/inspector
# Inspector → Transport: Streamable HTTP, URL: http://localhost:8765/mcp

The proxy (src/mcp-proxy.ts) SigV4-signs each request with the same client the agent uses (makeAwsClient in src/gateway.ts) and streams the gateway's response back — so the Inspector's Tools tab lists search___searchExplainers and can call it, firing the Lambda.

Alpha — not deploy-verified

The MCP handshake, the SigV4 service name (bedrock-agentcore), and the tool-result shape follow AWS's Gateway quick-start doc; confirm them against your live Gateway with pnpm mcp:proxy + the MCP Inspector before relying on the in-agent path. The gateway backing has no silent fallback by design — a failed call surfaces as an AiError (logged as gateway searchExplainers failed), so a misconfigured permission shows up as a failed turn rather than answers with no Lambda invocations.

MCP client: src/gateway.ts · signing proxy: src/mcp-proxy.ts · AgentCore Gateway quick-start

05The agent didn't change

Aha

respond is byte-for-byte the same loop as lesson 02/03. It still calls LanguageModel.generateText({ toolkit: SearchTools }), the framework still resolves the searchExplainers tool call, and respond still re-prompts with the results. Whether that tool's handler runs in-process (lesson 03) or hops out to the Gateway over MCP is an injection detail behind one stable contract: the Tool the model sees and the tool-call it emits. Swap what's wired in behind the name, and the same loop is now a distributed system — no edit to the agent.

Concretely: the Tool the model receives (searchExplainers — name, description, { query: string } parameters schema) is identical. Lesson 03 provides its handler with the in-process SearchToolsLayer; lesson 06 provides GatewaySearchToolsLayer instead. The agent, respond, and the loop are byte-for-byte unchanged — only the layer wired in at the composition root moved. The seam is the tool's handler service, satisfied by dependency injection, and the agent never names which side of it is in play.

06Deploy + where this lands

alchemy deploy adds two resources to the stack you built in lesson 05 — the search Lambda and the Gateway that fronts it as an MCP tool — plus one statement on the execution role (bedrock-agentcore:InvokeGateway, so the container may call the Gateway). Passing GATEWAY_URL: gateway.gatewayUrl into the Runtime's env is what lets the deployed container reach the tool the moment it starts — flip that env var and the same agent is now calling a remote MCP tool instead of its in-process one.

/invocations → respond → generate → toolCall(searchExplainers) │ ▼ MCP over HTTP (SigV4) AgentCore Gateway → search Lambda → ranked hits

That is this lesson's milestone: deploy — the keyword search now runs as its own service, reachable over MCP, with the agent unchanged. Next, lesson 07 gives the agent Memory across turns, so a conversation can remember what came before.