Skip to content

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.

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.

Create the service folder and add the packages:

pnpm add @evolve-framework/mcp-core @evolve-framework/core @opentelemetry/api fastify graphql
pnpm 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 stdio

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.

Generation runs in two steps.

  1. Persisted documents. The client preset writes persisted-documents.json: each operation’s hash with its query text. The createMcpToolTransform transform keeps only the operations marked @mcpTool, with the fragments they use.
  2. 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> = { … };

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:

  • GraphQLClient posts to the gateway. It sends a tool’s operation by its persisted-document id when COMPONENT_VERSION is set, in the same name~version~hash form 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.

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.

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

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.

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
  1. A client without a token gets a 401 that points to the service’s metadata.
  2. It opens /oauth/authorize, and the service sends the customer to your login page with an mcp_request ticket.
  3. 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.
  4. The customer approves the client on a consent page that always shows.
  5. 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.

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 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.

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.

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.

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.

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.

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/inspector

Connect 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.ts

Run the service as a container like your other services, with observability loaded through NODE_OPTIONS as in Observability. Then:

  1. Register its operations. In production the gateway accepts only persisted documents, so register src/generated/persisted-documents.json with 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}}
  2. Set COMPONENT_VERSION to the version you registered. The client builds each document id from COMPONENT_NAME, COMPONENT_VERSION, and the hash, so both must match the Hive app’s name and version. Without COMPONENT_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 with SERVICE_VERSION.

  3. Point it at the gateway. Set GATEWAY_ENDPOINT, and JWT_ISSUER and JWT_AUDIENCE to the values your gateway signs tokens with.

  4. 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 replayStore in oauth, and refresh tokens rotate only with one. Pass rateLimit: { redis } to createMcpModule to count across instances.

  5. Set trustProxy behind a proxy. Without it on createApplication, request.ip is 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.

  6. Set the OAuth secrets. Set MCP_OAUTH_SECRETS, MCP_INTERACTION_SECRET, MCP_PUBLIC_URL, and MCP_LOGIN_URL on the service, and MCP_URL and MCP_INTERACTION_SECRET on 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.