I kept seeing a pattern: a team has a customer account service that works fine. Then an AI agent becomes a requirement, and the team builds something else. An MCP server with its own tool definitions, input schemas, and handlers that call the same functions the application already calls.

Six months later, there are already two descriptions of the same capability. Somebody adds a field to the customer record, and only one of them gets updated.

The usual framing of this problem is "how do we add MCP to our backend?", as I think that framing is backwards, and it is the reason the second layer gets built. The better question is: what is the callable surface of this backend, and who is allowed to call it? Once you can answer that question, then an application consumer and an AI consumer are two answers to the same question rather than two separate projects.

In this blog post, you will learn about that design shift. It uses Graftcode and a customer account service as the worked example, but the focus is on its architecture. MCP shows up as one consumer of a callable backend surface.

What You Will Learn

  • What Receiver and Caller actually represent, and why they matter
  • Why a module's public method surface makes a contract
  • How a client package is generated from your backend code
  • Where Graftcode’s gateway sits between its modules and consumers
  • How the same methods serve an application and an AI agent with no second layer
  • Why a small surface matters when AI is a consumer
  • How Graftcode sits alongside a public REST API rather than replacing it

Prerequisites

This is mostly an architecture article, but the examples are runnable:

  1. Node.js 22 or higher installed on your local machine
  2. Graftcode Gateway installed on your local machine
  3. An MCP-capable client if you want to try the agent side, for example, Cursor, Claude Desktop, or some other agentic IDE

Install Graft Gateway via these commands depending on your local machine:

On Linux/MacOS:

curl -fsSL <https://grft.dev/get> | sh 

On Windows:

iwr <https://grft.dev/get> | iex 

Alternative: using wget:

wget -qO- grft.dev/get | sh 

Using Docker:

docker pull graftcode/gateway docker run -p 8080:80 -p 8081:81 -v $(pwd):/app graftcode/gateway /app/package.json 

You can also download the gg binary for your platform from the Gateway releases page on GitHub, then add it to your PATH.

Verify it:

gg --help 

The Problem Is Not MCP

To begin with, you’d need to start by being direct about what exactly is going wrong. You have a customer account service. An application calls it; now you want an AI agent to call it too. The path looks like this by default:

Customer Account Service Structure

Both branches are translations. But neither of them has business logic. And the MCP branch is a strange kind of duplication, because a tool definition is a description of your method: its name, its parameters, their types, what it does. You already wrote that as the method signature. Writing it again by hand creates a copy that can drift.

The failure is not that MCP is hard. The failure is that the backend never had a defined callable module, so every new kind of consumer requires you to create a new one.

Two translation layers versus one callable surface

Graftcode’s Structural Roles

Graftcode's model has two roles: the Caller and Receiver. They sound simple, and their implications are not.

The Receiver is the service being called. It is a plain module that supports public methods from the callable surface. You write the implementation, and Callers never receive your source code.

The Caller is the service that does the calling. It decides when to make a call and which arguments to use. It installs a generated package, configures how to reach the Receiver, and handles latency, failures, and authorization for its deployment.

Both sides are code you own. Graftcode does not generate your domain logic, your database access, or your deployment. What it removes is the layer between the two services: the HTTP clients, route handlers, and custom SDKs.

These are directions, not permanent identities. The docs explain that one codebase can act as a Receiver when it exposes a module, and as a Caller when it installs another module. A modular monolith can host several Receivers in one process while one component calls another in memory.

This is why application consumers and agents stop being different architectural problems. They are both Callers, but the Receiver does not know the difference and should not.

Why the public method surface is the contract

Here is the main idea behind Graft: When using REST or gRPC, the contract has separate URLs and operations. They schema a generated or hand-written client. In Graftcode, the supported public method surface is the contract, and the installed Graft is the client. There is no second artifact.

That artifact is designed manually and maintained alongside your logic. This keeps them in sync with your ongoing work, and when they drift, the spec is usually the one that is wrong. In Graftcode, the supported public method surface is the contract, and the installed Graft is the client.

There are trade-offs, and they go both ways. You give up the ability to design a contract manually for your implementation, which is sometimes exactly what you want for a public API. In return, you eliminate an entire category of mismatches.

The important thing is to be deliberate. As the Graftcode docs explain: public code is not automatically exposed code, and the callable surface should be a well-structured design decision rather than something that happens just because a method is public.

In practice, that means:

  • Expose only what you want consumers to call
  • Keep database clients, HTTP objects, framework models, and helpers non-public
  • Keep parameters and returns on the baseline: strings, plain models, identifiers, and dates as strings
  • Verify in Graftcode Vision rather than reasoning from your code

That last point matters more in Node.js and TypeScript than you might expect. Exposure is based on the package's runtime exports. Since there are many valid ways to export things, the surface shown in Graftcode Vision is only the source of truth.

A customer account service

Here is a Receiver designed with that discipline. Three methods, nothing else exported.

const customers = { "cust-1001": { id: "cust-1001", name: "Sarah Johnson", email: "sarah@example.com", phone: "+44 20 7946 0101", tier: "business", since: "2024-03-11", }, "cust-1002": { id: "cust-1002", name: "James Wilson", email: "james@example.com", phone: "+44 20 7946 0102", tier: "starter", since: "2025-07-02", }, }; const orders = { "cust-1001": [ { id: "ord-9001", placed: "2026-09-28", status: "delivered", totalCents: 18400 }, { id: "ord-9014", placed: "2026-10-05", status: "in_transit", totalCents: 7350 }, ], "cust-1002": [ { id: "ord-9007", placed: "2026-10-01", status: "processing", totalCents: 2600 }, ], }; function isValidEmail(value) { return typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); } class CustomerAccountService { static getCustomer(customerId) { const customer = customers[customerId]; if (!customer) { return JSON.stringify({ found: false, customerId }); } return JSON.stringify({ found: true, ...customer }); } static getOrders(customerId, limit) { const all = orders[customerId] ?? []; const take = limit && limit > 0 ? limit : 10; return JSON.stringify({ customerId, count: all.length, orders: all.slice(0, take) }); } static updateContactDetails(customerId, email, phone) { const customer = customers[customerId]; if (!customer) { return JSON.stringify({ ok: false, reason: "CUSTOMER_NOT_FOUND" }); } if (!isValidEmail(email)) { return JSON.stringify({ ok: false, reason: "INVALID_EMAIL" }); } const previous = { email: customer.email, phone: customer.phone }; customer.email = email; customer.phone = phone; return JSON.stringify({ ok: true, customerId, previous, current: { email: customer.email, phone: customer.phone }, }); } } module.exports = { CustomerAccountService }; 

Nothing in this file knows about MCP, AI agents, HTTP, or Graftcode. There are no decorators, tool schemas, or handlers. That's the goal: the Receiver shouldn't need to know what kind of consumer is using it.

Here are two details that are especially important.

isValidEmail is not exported: It is an internal module that will not appear on the contract, which means no consumer can call it directly or depend on its shape.

updateContactDetails validates its own input. The email check lives in the Receiver, not in a caller. We will come back to this, because it turns out to be the most important line in the file once an AI agent is involved.

Where Gateway sits

Gateway is the process between your hosted module and its consumers. Point it at the project:

gg ./package.json --port 8100 --httpPort 8101 --types CustomerAccountService 

Gateway then:

  1. Loads the module with the right runtime
  2. Discovers the public callable surface
  3. Applies your -types filter
  4. Publishes the callable surface so Graftcode can generate typed packages for the supported ecosystems.
  5. Serves Graftcode Vision and an MCP endpoint over HTTP
  6. When the code changes, it updates Graft and informs consumers about the update

The ports are worth keeping in mind because they map directly onto the two consumers:

Port Serves Who uses it
--port (default 80) WebSocket service calls Application Callers through their Graft
--httpPort (default 81) Graftcode Vision and /mcp Humans reviewing the surface, and AI clients

One hosted module. Two doors. Same methods behind both.

When Gateway starts, it prints install commands for each ecosystem:

[Info] Type enabled: CustomerAccountService [Info] Hosted module customer-account-service installation commands: FOR NPM PROJECT: npm install --registry <https://grft.dev/><your-registry-id>__free @graft/npm-customer-account-service@1.0.0 

Without a Project Key, registry coordinates can change when Gateway restarts, and changing the exposed surface can change the generated package name. The docs cover this in the Known limitations docs. Use a free project key from Graftcode's Portal or Graftcode Vision when you need stable coordinates and a stable MCP URL.

Open Vision at http://localhost:8101/GV and you see the three methods with their parameters and return types, a button to call each one, and the install commands. Treat it as a review gate. This is where you confirm the surface is what you meant to publish, before anyone installs anything.

Graftcode Vision

Consumer one: an application through a Graft

A support dashboard installs the generated package:

npm install --registry <https://grft.dev/><your-registry-id>__free @graft/npm-customer-account-service@1.0.0 

And calls the methods:

import { GraftConfig, CustomerAccountService, } from "@graft/npm-customer-account-service"; GraftConfig.host = "ws://localhost:8100/ws"; GraftConfig.stateless = true; const customer = await CustomerAccountService.getCustomer("cust-1001"); const orders = await CustomerAccountService.getOrders("cust-1001", 5); 

Running those gives you this in your terminal:

getCustomer: {"found":true,"id":"cust-1001","name":"Sarah Johnson", "email":"sarah@example.com","phone":"+44 20 7946 0101", "tier":"business","since":"2024-03-11"} getOrders: {"customerId":"cust-1001","count":2,"orders":[ {"id":"ord-9001","placed":"2026-09-28","status":"delivered","totalCents":18400}, {"id":"ord-9014","placed":"2026-10-05","status":"in_transit","totalCents":7350}]} 

Two configuration lines and then normal method calls. The generated package carries the types, so the editor autocompletes getOrders and knows it takes a customerId and a limit.

Set configuration before the first call; the runtime context is cached after initialization, and there is no supported reset.

Consumer two: an AI agent through MCP

Now the part that usually becomes a project. You can point an MCP client to the HTTP port. For Cursor, in .cursor/mcp.json:

{ "mcpServers": { "customer-accounts": { "url": "<http://localhost:8101/mcp>" } } } 

For Claude Desktop, I tried this, and it supports stdio servers, bridging to the HTTP endpoint:

{ "mcpServers": { "customer-accounts": { "command": "npx", "args": ["-y", "mcp-remote", "<http://localhost:8101/mcp>"] } } } 

That is the integration. There is no MCP server in the project, no tool definitions, and no handlers.

Each tool gets an input schema and output schema generated from the method signature. The readable titles come from the method names. When a tool is called, it returns the actual result from your service:

{ "result": { "content": [{ "type": "text", "text": "{\"customerId\":\"cust-1002\",\"count\":1,\"orders\":[{\"id\":\"ord-9007\",\"placed\":\"2026-10-01\",\"status\":\"processing\",\"totalCents\":2600}]}" }] } } 

Static methods are what become MCP tools. This is another reason to prefer static input to output operations on a Receiver, on top of the routing and statelessness benefits.

If an MCP client sends a bare method name and Gateway cannot resolve which type it belongs to, --mcpBaseClass lets you name the declaring type. Browser-based or edge MCP clients may also need CORS headers, which you pass with --corsConfig.

The part that justifies the whole design

Here is the detail I did not expect to be the most convincing thing in this exercise.

Ask the agent to set a customer's email to something malformed. It calls updateContactDetails with a bad value, and gets back:

{"ok":false,"reason":"INVALID_EMAIL"} 

The same thing the application Caller gets:

updateContactDetails: {"ok":false,"reason":"INVALID_EMAIL"} 

That check was written once in the Receiver, before anyone thought about AI. It protects both consumers in the same way, and neither can bypass it.

Compare this with a two-layer architecture. Validation written in an MCP tool handler protects only MCP callers. Validation written in a REST controller protects only HTTP callers. In both cases, the rule lives in the translation layer. That means it only protects callers that use that specific entry point.

This is the real reason to put the contract on the method surface. You do write less code, but that's not the main point. Your rules end up in the one place every consumer has to pass through.

An AI agent may send input that no human would. That's the most useful thing to remember about it. If your business rules live in the Receiver, handling that input becomes much less of a problem

Why a small surface matters more with AI

Every method you expose to an agent gives it a capability. It's not just an option it can read about; it's an action it can choose to take.

Filtering is the cheapest control:

gg ./package.json \ --types CustomerAccountService \ --methods getCustomer,getOrders 

That publishes two read methods and leaves updateContactDetails off the surface entirely. I verified the effect: the MCP tool list drops to exactly the filtered methods.

This brings a useful structural practice. Think about which methods each type of consumer should have access to:

  • Read methods are usually safe to expose broadly
  • Write methods deserve a deliberate decision per consumer type
  • Anything destructive or irreversible probably should not be a tool at all
  • Methods that take free text and act on it need their guards in the Receiver

There's also an important warning in the docs: the callable surface is an exposure boundary, not an authorization boundary. Filtering keeps unintended methods off the contract. It does not authenticate or authorize anyone.

Two Gateways with different filters is a reasonable pattern, one narrow surface for agents and a broader one for trusted services, but it is a scoping tool and not a security control.

A related habit that pays off: name methods for what they do, in the words the domain uses. getOrders and updateContactDetails are already good MCP tool names. An agent chooses tools partly from names and descriptions, so method naming has a consumer you did not previously have.

This sits alongside REST, not instead of it

A reasonable objection: we already have a public REST API, and we are not deleting it. You should not. The Graftcode docs are clear that the two solve different problems and can coexist when each boundary has a clear owner.

Keep REST when:

  • External clients need a public HTTP contract
  • Partners integrate through webhooks or fixed URLs
  • Callers cannot install a generated package

Add Graftcode for internal or controlled callers that can:

  • Service-to-service calls, including across languages
  • Share a module without hand-written HTTP clients
  • Moving between in-process and remote execution by configuration

The layout that works is the same one that makes a monolith maintainable:

  1. Put callable business logic in a plain module, not in controller types
  2. Keep REST controllers as thin adapters that call that same module
  3. Host the module with Gateway for Graft Callers and MCP consumers
  4. Keep database and framework types off the Graft contract

The module is the single source of truth. REST is an adapter for clients that need HTTP. The Graft is a generated client for consumers that can install packages. MCP is a view of the same surface for AI consumers. All three point at one implementation, and your invariants sit in that implementation where every path has to cross them.

Gateway can also host an existing HTTP workload with --runApp while exposing a Receiver surface from the same deployment, so this does not have to be a big-bang migration.

Try it locally

Two terminals. One Receiver, two kinds of consumers.

Terminal 1, host the service:

cd graftcode-customer-account/service gg ./package.json --port 8100 --httpPort 8101 

Pass the package root, not a path to a file inside a subdirectory, or the analyzer fails with package.json not found after copying.

Terminal 2, the application consumer:

cd graftcode-customer-account/support-app node probe.mjs 
getCustomer: {"found":true,"id":"cust-1001","name":"Sarah Johnson","email":"sarah@example.com", "phone":"+44 20 7946 0101","tier":"business","since":"2024-03-11"} getOrders: {"customerId":"cust-1001","count":2,"orders":[...]} updateContactDetails: {"ok":false,"reason":"INVALID_EMAIL"} 

Then the agent consumer, over the same surface and the friendliest path, is to point an MCP client at http://localhost:8101/mcp using the config shown earlier. If you would rather watch the raw protocol, note that this is a session-based MCP endpoint, so you cannot just POST tools/list at it.

  1. Initialize and capture the session ID from the response headers:
SID=$(curl -s -D - -o /dev/null -X POST <http://localhost:8101/mcp> \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' \ | tr -d '\r' | awk -F': ' '/[Mm]cp-[Ss]ession-[Ii]d/{print $2}') 
  1. Confirm Initialization:
curl -s -X POST <http://localhost:8101/mcp> \ -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -H "Mcp-Session-Id: $SID" \ -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' 
  1. Now list the tools:
curl -s -X POST <http://localhost:8101/mcp> \ -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -H "Mcp-Session-Id: $SID" \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' 

Skip the handshake, and you get -32001 Missing or invalid Mcp-Session-Id, which is could be misread as "MCP is not enabled". It is enabled. You are just not holding a session.

What comes back is derived from the method, titles included:

{ "name": "getCustomer", "title": "Get Customer", "description": "Static method CustomerAccountService.getCustomer", "inputSchema": { "type": "object", "properties": { "customerId": { "type": ["string", "integer", "number"] } }, "required": ["customerId"] } } 

The part worth doing yourself: ask the agent to set a customer's email to something malformed, and watch it get back the same INVALID_EMAIL that probe.mjs got. Nobody wrote that guard for MCP.

What this design does not give you

It is important to be clear about the limits, because the structure can seem more powerful than it actually is.

Authentication and authorization are still your responsibility, especially for MCP: A Project Key identifies a publication, but it does not control who can make calls at runtime. The MCP endpoint in this article has no authentication. Anyone who can access port 8101 can call the tools. Before exposing these tools to an agent, decide who can access the endpoint and how calls will be authorized. Test both successful calls and rejected ones.

Letting agents act on your behalf is a product decision, not a transport decision. If you expose updateContactDetails as a tool, an agent can change customer data during a conversation. You need to decide whether a human should confirm these changes and where that confirmation should happen. Graftcode makes the method available, but it does not make that decision for you.

Context and prompt injection are not handled. An agent might read untrusted content and then call your tools. This creates a real risk. Keeping the public surface small and putting the rules in the Receiver helps limit the damage, but it does not remove the risk.

You are responsible for failures, retries, and monitoring. There is no built-in retry, backoff, failover, or idempotency. Retrying a call can repeat its effects, which is especially important when an agent uses a tool to change data. The Gateway provides...