Expose your API to AI assistants
An AI assistant, such as Claude, ChatGPT, or the chat in your own storefront,
calls tools over the Model Context Protocol
(MCP). In Evolve, an MCP server is a service in your project that you build with
@evolve-framework/mcp-core. You write
GraphQL operations, mark the ones the assistant may use, and codegen turns each
into a tool. When the assistant calls a tool, the service runs that operation
against your GraphQL gateway.
This guide builds one from nothing: a service in backend/assistant with three
tools that search the catalog, read the cart, and add to it. For a complete
service, with customers signing in and every part of the shop as tools, see
backend/mcp-customer in the B2B reference site. The MCP server is in Preview.
Why it sits on the GraphQL API
Section titled “Why it sits on the GraphQL API”The MCP service is one more client of your gateway, next to the storefront. It sends the same store context headers and the same session tokens, and the gateway sends both on to the subgraphs. So the assistant gets:
- Your prices, stock, and promotions. Everything your subgraphs add on top of the commerce engine applies, because the request goes through them.
- The shopper’s own session. When the assistant acts for a shopper, a cart it fills is the cart they see in the storefront.
- Only what you allow. A tool runs one operation you wrote, with the fields you selected. In production the gateway accepts only registered operations, so an assistant cannot send a query of its own.
- One contract. A field you add to the API is available to the storefront and to the assistant alike, and a schema change that breaks a tool fails codegen.
Vendors ship MCP servers of their own. Those talk to the vendor’s API directly, past your subgraphs, so they do not know your prices or your session. They fit back-office work in one vendor, such as editing content in your CMS.
Install the packages
Section titled “Install the packages”Create the service folder and add the packages:
pnpm add @evolve-framework/mcp-core @evolve-framework/core @opentelemetry/api fastify graphqlpnpm add -D @graphql-codegen/cli @graphql-codegen/client-preset@evolve-framework/core, @opentelemetry/api, fastify, and graphql are peer
dependencies of mcp-core.
The service ends up with these files:
backend/assistant/├── operations.graphql the tools├── codegen.ts step 1: persisted documents├── codegen.tools.ts step 2: tool definitions└── src/ ├── config.ts ├── service.ts what the service is ├── server.ts serves it over HTTP └── stdio.ts serves it over stdioWrite the tools
Section titled “Write the tools”A tool is a GraphQL operation with the @mcpTool directive. Write them in
operations.graphql:
query SearchProducts( $searchTerm: String @mcpToolVariable(description: "What the shopper is looking for, in their words") $sort: ProductSortOrderType!) @mcpTool(description: "Search the catalog. Returns each product's name, slug, SKU, stock and price.") { productSearch(searchTerm: $searchTerm, sort: $sort, pageSize: 10) { items { name slug variant { sku availability price { gross { centAmount currency } } } } pagination { totalCount } }}
query GetCart@mcpTool(description: "Get the shopper's cart, with its id and version. Call it before you change the cart.") { cart { id version totalQuantity total { gross { centAmount currency } } }}
mutation AddToCart( $cartId: ID @mcpToolVariable(description: "The cart's id from get_cart. Leave it out when the shopper has no cart yet.") $version: String @mcpToolVariable(description: "The cart's version from get_cart") $sku: String! @mcpToolVariable(description: "The SKU from search_products") $quantity: Int!) @mcpTool(description: "Add a product to the shopper's cart.") { cartLineItemsAdd( id: $cartId version: $version lineItems: [{ sku: $sku, quantity: $quantity }] ) { cart { id version totalQuantity } cartErrors { ... on Error { message } } }}The assistant reads the descriptions to decide which tool to call and what to
pass, so write them for a reader who knows nothing about your shop. Tell it
where a value comes from, as the cartId description does.
| Directive | On | What it does |
|---|---|---|
@mcpTool(description: String!) |
A query or mutation | Makes the operation a tool. The tool’s name is the operation’s name in snake case: SearchProducts becomes search_products. |
@mcpTool(description: ..., exclude: true) |
A query or mutation | Registers the operation with the gateway without making it a tool, for code that runs it itself. |
@mcpTool(description: ..., scopes: [String!]) |
A query or mutation | The OAuth scopes a caller needs. See Scopes. |
@mcpTool(description: ..., businessUnit: REQUIRED) |
A query or mutation | How the tool relates to a company: REQUIRED or NONE. See Business units. |
@mcpToolVariable(description: String) |
A variable | Describes the argument. Without it, the tool uses the argument’s description from the schema. |
@mcpToolVariable(includeInputFields: [String!]) |
A variable of an input type | Shows the assistant only these fields of the input. Name nested fields with dots, such as "draft.address.city". |
The selection set is what the assistant gets back. A field you leave out, it never sees, so select only what helps it answer.
Codegen turns each variable’s type into JSON Schema: Int into integer,
Float into number, Boolean into boolean, and String, ID, and other
scalars into string. An enum lists its values with their descriptions, and an
input type becomes an object with its fields. A non-null variable is a required
argument, even when it has a default value, so declare a variable nullable to
make it optional.
Generate the tools
Section titled “Generate the tools”Generation runs in two steps.
- Persisted documents. The client preset writes
persisted-documents.json: each operation’s hash with its query text. ThecreateMcpToolTransformtransform keeps only the operations marked@mcpTool, with the fragments they use. - Tool definitions. The
tools plugin reads that
file and the directives in your operations, and writes
mcp-tools.generated.ts.
import type { CodegenConfig } from "@graphql-codegen/cli";import { createMcpToolTransform } from "@evolve-framework/mcp-core/codegen";
export const schemaPaths = [ "../graphql-gateway/supergraph.generated.graphql", // Declares @mcpTool and @mcpToolVariable, which the supergraph does not know. "./node_modules/@evolve-framework/mcp-core/directives.graphql",];
export const documentPaths = [ "./operations.graphql", // CreateGuestSession and RefreshToken, which mcp-core runs to manage sessions. // With OAuth, add CustomerLogout to operations.graphql with exclude: true. "./node_modules/@evolve-framework/mcp-core/src/auth/operations.graphql",];
const config: CodegenConfig = { schema: schemaPaths, generates: { "./src/generated/": { documents: documentPaths, preset: "client", // The gateway does not know the MCP directives, so they come out // before the documents are hashed and registered. documentTransforms: [createMcpToolTransform({ stripDirectives: true })], presetConfig: { fragmentMasking: false, persistedDocuments: { mode: "embedHashInDocument", hashPropertyName: "documentId", }, }, }, },};
export default config;import type { CodegenConfig } from "@graphql-codegen/cli";import { documentPaths, schemaPaths } from "./codegen.ts";
const config: CodegenConfig = { schema: schemaPaths, // The directives come from these, because the persisted documents no longer carry them. documents: documentPaths, generates: { "./src/generated/mcp-tools.generated.ts": { plugins: ["@evolve-framework/mcp-core/codegen/tools"], config: { persistedDocumentsPath: "./src/generated/persisted-documents.json", }, }, },};
export default config;Run the two in order from the service’s codegen script. The supergraph has
to exist first, so make the task wait for the gateway’s codegen, as the
storefront’s does:
"scripts": { "codegen": "graphql-codegen --config codegen.ts && graphql-codegen --config codegen.tools.ts", "dev": "node --env-file=../../.env --watch ./src/server.ts", "start": "node ./src/server.ts"}{ "extends": ["//"], "tasks": { "codegen": { "dependsOn": ["@evolve-platform/graphql-gateway#codegen"], "outputs": ["src/generated/*"] } }}Codegen validates every operation against the supergraph. When a subgraph
removes a field a tool selects, pnpm codegen fails before anything ships.
The generated file holds the tools, plus two lookups that the server code needs:
export const generatedMcpTools: GeneratedMCPTool[] = [ { name: "search_products", description: "Search the catalog. Returns each product's name, slug, SKU, stock and price.", operationType: "query", inputSchema: { type: "object", properties: { searchTerm: { type: "string", description: "What the shopper is looking for, in their words", }, sort: { type: "string", enum: ["POPULARITY", "PRICE_ASCENDING", "PRICE_DESCENDING", "NAME_ASCENDING", "NAME_DESCENDING"], description: "POPULARITY: Sort by popularity; PRICE_ASCENDING: Sort by price in ascending order; ...", }, }, required: ["sort"], }, documentId: "…", queryString: "query SearchProducts($searchTerm: String, $sort: ProductSortOrderType!) { … }", }, // get_cart, add_to_cart];
// Operation name to hash, for every persisted operation.export const persistedDocumentIds: Record<string, string> = { … };
// The query text of each operation marked exclude: true.export const excludedOperationQueries: Record<string, string> = { … };Create the server
Section titled “Create the server”Extend MCPCoreConfig, which
declares the service’s settings with defaults that fit a reference site run
locally: the gateway on http://localhost:4000/graphql, the evolve store,
nl-NL, and EUR.
import { MCPCoreConfig } from "@evolve-framework/mcp-core";
class Config extends MCPCoreConfig { override readonly COMPONENT_NAME = "assistant";}
export const config = await Config.load();
export const storeDefaults = { storeKey: config.DEFAULT_STORE_KEY, locale: config.DEFAULT_LOCALE, currency: config.DEFAULT_CURRENCY,};Then say what the service is: its configuration, its name, and where its tools are. Everything else follows from those.
import type { GeneratedTools, McpServiceOptions,} from "@evolve-framework/mcp-core";import { config } from "#src/config.ts";
// In a variable, so a checkout that has not run codegen still typechecks.const generatedTools = "#src/generated/mcp-tools.generated.ts";
export const service: McpServiceOptions = { config, name: "assistant", loadTools: () => import(generatedTools) as Promise<GeneratedTools>,};Each request gets a server built from this, with guest sessions and token refresh wired to the gateway:
GraphQLClientposts to the gateway. It sends a tool’s operation by its persisted-document id whenCOMPONENT_VERSIONis set, in the samename~version~hashform the storefront uses, and the full query text when it is not.- A tool returns
{ "success": true, "data": … }as JSON text. A GraphQL error comes back as a tool error with its message, so the assistant can correct its call. Arguments that are blank strings are left out. - A checkout that has not run codegen fails with an error that says so.
A service that needs its own server, for instance to register a tool by hand,
writes a createServer and passes it to
registerMcpRoutes
itself. createMcpServerFactory builds the standard one to start from.
Serve it over HTTP
Section titled “Serve it over HTTP”Over HTTP, one deployed service serves many assistants at once, and every request
stands on its own: nothing is kept between them. createMcpModule turns the
service into a module for
createApplication. It takes
the store defaults, the rate limit, and JWT validation from the configuration:
import { createApplication } from "@evolve-framework/core";import { createMcpModule } from "@evolve-framework/mcp-core/application";import { config } from "#src/config.ts";import { service } from "#src/service.ts";
const app = await createApplication({ name: config.COMPONENT_NAME, module: await createMcpModule(service), http: { address: { host: config.HTTP_HOST, port: config.HTTP_PORT } },});
await app.start();The configuration must be loaded before createMcpModule runs. The module adds:
| Route | What it does |
|---|---|
POST /mcp |
Every JSON-RPC message. Each request is served on its own, with no Mcp-Session-Id. |
GET /mcp, DELETE /mcp |
Answer 405. The service keeps no session to stream to or end. |
GET /healthcheck |
Liveness, never rate limited. If you add a HealthModule for readiness, give it livenessRoute: false, because this path is taken. |
With OAuth it adds the routes in
Connect a customer with OAuth too. The rate limit
comes from RATE_LIMIT_MAX and RATE_LIMIT_WINDOW_SECONDS, and counts per IP
address, or per connected client with OAuth. To serve several MCP endpoints from one process,
compose
createMcpRouteHandlers
yourself instead.
Run pnpm dev and the endpoint is on http://localhost:6000/mcp. To see its
traces locally, start it through an entry file that turns on observability
first, as in Observability.
Or serve it over stdio
Section titled “Or serve it over stdio”Over stdio, the assistant’s app starts the service as a child process on the user’s machine and talks to it through stdin and stdout. Use it to try your tools in a desktop assistant or an editor.
import { startMcpStdio } from "@evolve-framework/mcp-core/application";import { service } from "#src/service.ts";
await startMcpStdio(service);stdout carries the protocol, so a log line written there breaks it.
startMcpStdio calls
setLogDestination("stderr")
from @evolve-framework/core/observability, but only when it starts. Your
imports run before that, and loading the config can already log. Set
LOG_DESTINATION=stderr in the environment the client starts the process with,
or call setLogDestination yourself before loading the config, so every line goes
to stderr from the first one.
| HTTP | stdio | |
|---|---|---|
| Runs | As a deployed service | As a child process of the assistant’s app |
| Sessions | None between requests | One, for the life of the process |
| Store context | The session’s with OAuth, else each request’s headers, else the defaults | The defaults |
| Starts as | A customer or guest through OAuth, the tokens on the request, or a guest for one call | Guest |
| Use it for | The assistant in your product, remote clients | Trying tools locally |
Act for a shopper
Section titled “Act for a shopper”Every tool call runs with a shopper’s gateway tokens and a store context, which it sends to the gateway as headers, as the storefront does. The subgraphs see the same shopper either way.
Who a call acts for. The request decides, because the service remembers nothing:
| The request carries | The call acts for |
|---|---|
| An OAuth access token | The customer or guest who connected. The token holds their sealed gateway session. See Connect a customer with OAuth. |
Authorization: Bearer <access token>, without OAuth |
That shopper. The service checks the token’s expiry, issuer, and audience first. No refresh token comes with it, so it works until the token expires. |
X-Access-Token, with X-Refresh-Token and X-Data-Token |
That shopper, forwarded by a trusted proxy on every request. Only with PROXY_AUTH_ENABLED. The token is not checked here, the gateway checks it, so allow this only when the proxy is the only caller that can reach the service. |
| Nothing, without OAuth | A guest for this one call. mcp-core runs the createGuestSession mutation, and the session ends with the call, so a cart does not survive it. |
Store context. With OAuth, it is the store the session was created in,
because a client that connects by URL sends no store headers. Otherwise the
X-StoreContext-StoreKey, X-StoreContext-Locale, and
X-StoreContext-Currency headers set it on each request, and a missing one falls
back to DEFAULT_STORE_KEY, DEFAULT_LOCALE, or DEFAULT_CURRENCY. Any other
X-StoreContext-* header is kept and sent with the call.
Keeping it alive. A token that expires within
TOKEN_REFRESH_THRESHOLD_SECONDS (300 by default) is renewed with the
refreshToken mutation for that call.
With OAuth, the client renews the session itself through /oauth/token.
What a tool cannot keep. A tool cannot remember anything for the next call. Keep what has to last in the gateway, or in an argument the assistant repeats, as business units do.
Connect a customer with OAuth
Section titled “Connect a customer with OAuth”With OAuth, a customer adds your shop’s MCP URL to their assistant and signs in on your own storefront. The service is both the authorization server and the protected resource of the MCP authorization flow, and stores nothing: the client holds the customer’s session in an encrypted token, as a browser holds it in cookies.
Pass oauth and set the secrets:
module: await createMcpModule({ ...service, oauth: { resourceName: "Your shop" },}),| Setting | What it is |
|---|---|
MCP_OAUTH_SECRETS |
Comma-separated secrets of at least 32 bytes that seal every token. They stay with this service. The first seals and the rest still open, so a secret rotates by prepending a new one. OAuth is off while this is unset. |
MCP_INTERACTION_SECRET |
What the storefront’s login page authenticates with. Starting throws without it when OAuth is on. |
MCP_PUBLIC_URL |
The address clients connect to, which tokens are bound to. |
MCP_LOGIN_URL |
The storefront’s login page. |
sequenceDiagram
actor Customer
participant Client as MCP client
participant MCP as MCP service
participant Site as Storefront
participant Gateway as GraphQL gateway
Client->>MCP: POST /mcp
MCP-->>Client: 401 + resource_metadata
Client->>MCP: GET protected resource & server metadata
Note over Client,MCP: Client ID Metadata Document,<br/>or dynamic registration
Client->>Customer: Open /oauth/authorize (PKCE, resource, state)
Customer->>MCP: GET /oauth/authorize
MCP->>MCP: Verify client and redirect_uri, seal request
MCP-->>Customer: Redirect to login page + mcp_request
Customer->>Site: Sign in, however this shop signs people in
Site->>Gateway: Start a session of its own for the client
Gateway-->>Site: Gateway tokens
Site->>MCP: POST /oauth/interaction/seal
MCP-->>Site: Sealed session, for an HttpOnly cookie
Customer->>Site: Approve on the consent screen
Site->>MCP: POST /oauth/interaction/accept
MCP-->>Site: redirect_uri + code, state, iss
Site-->>Client: Redirect to redirect_uri
Client->>MCP: POST /oauth/token + code_verifier
MCP->>MCP: Verify PKCE, client and redirect, use the code once
MCP-->>Client: Access and refresh token
Client->>MCP: POST /mcp + Bearer access token
MCP->>MCP: Open token, check resource and scopes
MCP->>Gateway: Operation with the customer's gateway tokens
Gateway-->>MCP: Result
MCP-->>Client: Tool result
Client->>MCP: POST /oauth/token (refresh_token)
MCP->>Gateway: refreshToken
Gateway-->>MCP: New gateway tokens
MCP-->>Client: New access and refresh token
- A client without a token gets a 401 that points to the service’s metadata.
- It opens
/oauth/authorize, and the service sends the customer to your login page with anmcp_requestticket. - The customer signs in, however your shop signs people in, or continues as a guest. The storefront starts a gateway session for the client alone, so the browser’s own session stays as it is.
- The customer approves the client on a consent page that always shows.
- The client exchanges the code at
/oauth/token, and sends the access token on every call. It renews it with the refresh token.
The service adds these routes:
| Route | What it does |
|---|---|
GET /.well-known/oauth-protected-resource/mcp |
Protected resource metadata |
GET /.well-known/oauth-authorization-server |
Authorization server metadata |
GET /oauth/authorize |
Starts the login flow |
POST /oauth/token |
Exchanges a code or refresh token |
POST /oauth/register |
Dynamic client registration |
POST /oauth/revoke |
Ends the session of a refresh token |
POST /oauth/interaction/* |
For the login page only. Keep these reachable from the storefront alone. |
A request with a valid access token counts on its connection, up to
RATE_LIMIT_MAX, and keeps that count across refreshes. Anything else, including
the OAuth routes and an invalid token, counts on its IP address, up to
RATE_LIMIT_ADDRESS_MAX. The two differ because a client in the cloud, such as
Claude, sends every customer’s requests from the same addresses.
Scopes
Section titled “Scopes”A scope limits what a client may do for the customer. It grants nothing: the gateway still checks every request. Name the scopes a tool needs on its operation, and the server offers the ones its tools name:
mutation OrderPlace($id: ID!)@mcpTool(description: "Place the order.", scopes: ["order"]) { # ...}A caller without the scope gets a 403 with insufficient_scope, which tells an
MCP client to ask the customer for it. That is also how a guest who asks for their
orders ends up on the login page.
The login page
Section titled “The login page”The login page stays yours, so password sign-in, single sign-on, and whatever you
add later all work. @evolve-storefront/feature-mcp-connect implements the
storefront’s side. The site hands it its own login and guest operations:
import { CustomerLogin } from "@evolve-storefront/feature-auth/documents";import { createConnectFlow } from "@evolve-storefront/feature-mcp-connect/server";import { SessionGuestCreate } from "@evolve-storefront/session/documents";
export const connectFlow = createConnectFlow({ authorizationServer: config.MCP_URL, interactionSecret: config.MCP_INTERACTION_SECRET, endpoint: `${serverApiHostname()}/graphql`, login: CustomerLogin, guest: SessionGuestCreate, guestScopes: ["shop"],});The ticket arrives in the login page’s URL, where the page’s scripts, analytics and
error reporting would read it. Run connectTicketStep first in the site’s proxy: it
moves the ticket into an HttpOnly cookie and redirects to the same page with
?connect=1, before anything loads. The flow reads the ticket from that cookie, so
no page, prop or form carries it:
import { connectTicketStep } from "@evolve-storefront/feature-mcp-connect/proxy";
const ticket = connectTicketStep(request);if (ticket) return ticket;The redirect is absolute and carries the cookie, so the proxy returns it as it is.
On the login page, spread useConnectForm onto LoginFormView from
@evolve-storefront/feature-auth, so a sign-in starts a session for the client and
refusals read as they do on the normal form:
const binding = useConnectForm({ signIn, resolveError: useAuthErrorMessages() });
return <LoginFormView {...binding} />;Render
ConnectConsent on the page the sign-in sends the customer to. The storefront
needs MCP_URL and the same MCP_INTERACTION_SECRET, and never the sealing
secrets, so it can finish a sign-in but cannot issue a token.
A shop that signs people in another way leaves login and guest out, starts a
gateway session for the client, and passes it to handOver. Everything after that
is the same.
A consent page can ask more with selections, such as the company to buy for. The
value ends up in the session, under the selection’s name.
A customer who is already signed in on the storefront signs in again to connect a
client. The client needs a gateway session of its own, and customerLogin, which
takes the password, is the only operation that starts one for a customer. Handing
over the browser’s session instead would tie the two together: signing out of the
site would disconnect the client, and disconnecting the client would sign the
customer out of the site.
Business units
Section titled “Business units”A service has business units when its operations.graphql holds the
BusinessUnitSwitch operation, with exclude: true. Its tools then take a
businessUnitId argument, which the gateway checks on every call. Because no call
can hand the client a new token, the assistant repeats the id once the shopper picks
another company.
mutation OrderPlace($id: ID!)@mcpTool(description: "Place the order.", businessUnit: REQUIRED) { # ...}
query GetBusinessUnits@mcpTool(description: "List the companies.", businessUnit: NONE) { # ...}REQUIRED refuses a call that names no company, and without the operation it stops
the server from building. NONE leaves the argument off, for a tool that does not
depend on a company. A company the login page stored as
selection.businessUnitId comes back after each token refresh, which resets a
session to its default company.
Forward the storefront’s session
Section titled “Forward the storefront’s session”For a chat in your storefront, the shopper is already signed in, so pass that
session on instead of connecting with OAuth. The storefront’s
chat-proxy package sends the gateway’s
auth cookies, server to server, to your chat backend. The chat backend is the
code that talks to the model: your own, or the optional
chat-backend package. It sends the shopper’s
tokens in X-Access-Token, X-Refresh-Token, and X-Data-Token on every request.
Set PROXY_AUTH_ENABLED=true on the MCP service and keep it reachable from the
chat backend only.
An anonymous visitor needs a guest session from the chat backend too. Without tokens, each call is a guest of its own, and the cart is gone at the next call.
Do not sign in from the conversation
Section titled “Do not sign in from the conversation”mcp-core has no sign-in tool, and you should not add one. A tool that signs a
shopper in has the shopper type their password into the conversation, so the
password passes through the assistant and the model provider behind it. Let the
shopper sign in on your storefront, through OAuth or by forwarding that session.
Leave customerLogin out of the documents you mark with @mcpTool.
Add prompts and resources
Section titled “Add prompts and resources”Besides tools, MCP has prompts, templated messages a client offers its user,
and resources, data a client reads.
registerPrompts and
registerResources
add them in a createServer of your own. Both handlers receive the GraphQL client
and no session, so pass the store context to query yourself.
Try it with an MCP client
Section titled “Try it with an MCP client”Start the gateway and the subgraphs, then the MCP service.
The MCP Inspector lists your tools and calls them with arguments you type:
npx @modelcontextprotocol/inspectorConnect with the Streamable HTTP transport to http://localhost:6000/mcp. To
try stdio instead, let the Inspector start the service:
npx @modelcontextprotocol/inspector -e LOG_DESTINATION=stderr node backend/assistant/src/stdio.tsclaude mcp add --transport http evolve http://localhost:6000/mcpWith OAuth on, Claude Code opens your login page when you run /mcp and
authenticate, and asks again when a tool needs a scope it does not have. Without
OAuth each call is a guest of its own, so a cart does not last. To act for a
signed-in shopper then, add --header "Authorization: Bearer <access token>".
Claude Desktop starts a stdio server from its config file:
{ "mcpServers": { "evolve": { "command": "node", "args": ["/path/to/your-project/backend/assistant/src/stdio.ts"], "env": { "LOG_DESTINATION": "stderr" } } }}An initialize shows the service answering. The store headers are optional when
the service has defaults:
curl -i http://localhost:6000/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "X-StoreContext-Locale: en-GB" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}'There is no session id to carry over: send the same headers on every request.
With OAuth on, this answers 401 with the metadata address in WWW-Authenticate.
Deploy it
Section titled “Deploy it”Run the service as a container like your other services, with observability
loaded through NODE_OPTIONS as in
Observability. Then:
-
Register its operations. In production the gateway accepts only persisted documents, so register
src/generated/persisted-documents.jsonwith Hive before the new version rolls out. The storefront does this in its deploy, and the MCP service works the same way:before:- >-hive app:create --target <your Hive target>--name assistant --version {{.version}}backend/assistant/src/generated/persisted-documents.json- >-hive app:publish --target <your Hive target>--name assistant --version {{.version}} -
Set
COMPONENT_VERSIONto the version you registered. The client builds each document id fromCOMPONENT_NAME,COMPONENT_VERSION, and the hash, so both must match the Hive app’s name and version. WithoutCOMPONENT_VERSION, the client sends full queries, which a gateway that enforces persisted documents refuses. Bake it into the image at build time, as the storefront does withSERVICE_VERSION. -
Point it at the gateway. Set
GATEWAY_ENDPOINT, andJWT_ISSUERandJWT_AUDIENCEto the values your gateway signs tokens with. -
Run as many instances as you need. Requests need no affinity. With OAuth, a code can be used once per instance until you pass a shared
replayStoreinoauth, and refresh tokens rotate only with one. PassrateLimit: { redis }tocreateMcpModuleto count across instances. -
Set
trustProxybehind a proxy. Without it oncreateApplication,request.ipis the proxy’s address, so every client without a valid token shares one limit. Only set it when the proxy is the only way in, because a direct caller could then choose its own address. -
Set the OAuth secrets. Set
MCP_OAUTH_SECRETS,MCP_INTERACTION_SECRET,MCP_PUBLIC_URL, andMCP_LOGIN_URLon the service, andMCP_URLandMCP_INTERACTION_SECRETon the storefront.
A tool call traces through to the gateway and its subgraphs, under a span named
tools/call <tool>. Arguments and results stay out of spans, because they carry
customer data. The service reports the metrics mcp.tool.invocations,
mcp.tool.duration, mcp.oauth.tokens, and mcp.auth.challenges when
metrics are on. Every log line from a
request carries its clientId, and a sessionId that identifies that one request.

