MCP Servers
12

One Node.js App, Two Doors: REST and MCP From a Single Domain

June 4, 2026

A team has an existing Node.js backend. It serves a React frontend. It talks to a Postgres database. It has six months of business logic, three years of bug fixes, and a deployment pipeline that finally stopped paging the on-call engineer at 3am.

The team decides to add MCP support so internal agents can call the system's capabilities through Claude Desktop or Cursor.

The first instinct is almost always: start a new project. A separate acme-mcp repo, a separate Node.js process, its own deployment pipeline. The MCP server will call the existing backend's API to do anything real, and the existing backend stays untouched.

This is the wrong default for almost every serious case, and unwinding why is most of the work in this post. The right shape is one Node.js project, one domain, two primary adapters: a REST adapter your frontend already uses, and an MCP adapter your agents now use. Both wired into the same use cases, the same validation, the same audit logs, the same security model.

The cost of doing it the other way — two projects, two copies of the domain logic, two deployments, two security postures — is paid for the lifetime of the system. The cost of doing it as a dual adapter is one architectural decision made once.

This post pairs with the bounded-context post — that one argues for one server per bounded context; this one argues for one server per app, with MCP as one of its faces. Together they describe the structure that scales to enterprise without collapsing into a graveyard of half-duplicated services.

The instinct to make it a separate project

It is worth being honest about where this instinct comes from. Three reasons converge.

MCP tutorials all show standalone servers. Every quickstart, every reference, every "build your first MCP server" article shows a fresh project with npm init, the MCP SDK as a dependency, and a single server.ts that wires three tools to handlers. The implicit lesson is "MCP is a kind of project". It isn't. MCP is a protocol, and a protocol can be added to anything that already speaks HTTP.

Microservice momentum. A decade of being told "small services, well-bounded, single responsibility" trains the instinct that a new capability deserves a new service. For MCP that instinct misfires, because MCP is not a capability — it is another face on the capabilities you already have. The capability is the use case. The face is the adapter. Two faces, one body.

Risk aversion. "If the MCP code is in our main backend, a bug in the MCP code could take down the whole product". This is a legitimate concern and worth addressing on its merits: yes, you do isolate by process boundary if the operational risk justifies it. But the question is operational, not architectural. The domain should not be written twice regardless of how many processes you decide to run.

The unifying mistake: treating MCP as a thing that ships separately, when MCP is a protocol that ships as part of an existing application. The closest precedent in mainstream backend work is the GraphQL endpoint added next to a REST API. Nobody starts a new project to add GraphQL; they add a /graphql route, configure the resolver layer, and point it at the same services the REST endpoints use. MCP is structurally the same move.

What the wrong shape costs

Concrete consequences of running MCP as a separate project, in roughly the order they bite:

The domain gets duplicated. Almost immediately. The MCP server's cancelOrder tool needs to call something to actually cancel an order. If the backend exposes the right API, the MCP server can be thin — but most backends do not expose every operation an agent might want, and adding new API endpoints in the backend "just for the MCP server to consume" means the backend is now growing API surface area for a consumer that is not the frontend. That growth has no end. Within months, the backend's /admin/internal/* routes are a tangle of endpoints designed for an agent that is in a different repo.

The validation drifts. Zod schemas on the REST side. Different Zod schemas (or worse, JSON Schema written by hand) on the MCP side. They start identical and diverge with the first bug fix that is applied to one and forgotten on the other. The agent's view of "what is a valid order ID" stops matching the user's view. Whichever side the bug fix landed on is the one that no longer reflects the truth.

The security model fragments. Two projects mean two auth setups. Two sets of credentials. Two audit pipelines. Two places to add the next compliance requirement when legal asks. In an enterprise procurement conversation, this is the moment the conversation gets longer. "How do you ensure consistency of access control across these two systems?" is a question with no clean answer if the two systems are genuinely separate.

The deployment cost doubles. Two Dockerfiles, two CI pipelines, two staging environments, two on-call rotations. None of this is fatal individually. All of it is overhead that exists because someone reached for the new-project instinct on day one.

The duplication tax is the worst of these. Splitting a system across two repos is reversible early and almost impossible to reverse late. Six months in, both sides have drift, both sides have integrations, both sides have customers, and the question "should we have done this as one project?" is no longer actionable. The right time to ask is before the first commit lands in the second repo.

A quick hexagonal recap

The architecture that makes the dual-adapter pattern obvious has a name — hexagonal architecture or ports and adapters. The shape, in one paragraph:

The center of the application is the domain — pure business logic, no I/O, no frameworks. Around the domain is the application layer — use cases that orchestrate the domain. The application layer defines ports — interfaces that describe what the application needs from the outside world and what the outside world can ask the application to do. Outside the application sit adapters — concrete implementations of those ports. Primary adapters drive the application (an HTTP endpoint receives a request, calls a use case). Secondary adapters are driven by the application (a database repository implementation, a Stripe SDK wrapper).

The point of the pattern: the domain depends on no adapter, no framework, no specific database. Adapters depend on the domain. The dependency rule points inward. The domain is reusable across any adapter you can implement against its ports.

For MCP specifically, this rule has a clean consequence. The MCP transport is just another primary adapter. An HTTP framework is a primary adapter. A CLI is a primary adapter. All three can drive the same use cases without the use cases knowing which one is calling them. Add MCP, and the domain does not change. Remove REST, and the domain does not change. The adapters are interchangeable; the body of work is in the center.

If hexagonal feels abstract, the bounded-context post has the worked example with the bookstore template's six contexts. Reading that first will make the rest of this post denser; reading this first makes the bounded-context post sharper. Either order works.

The dual-adapter shape, in folders

The concrete structure for a Node.js app that exposes both REST and MCP. Adapted from the Node.js Clean Architecture template with MCP added as a second adapter:

javascript
src/
├── domain/                          # Pure business logic
│   ├── orders/
│   │   ├── order.entity.ts
│   │   ├── order-status.vo.ts
│   │   └── order.repository.ts      # Port (interface)
│   └── ...
│
├── application/                     # Use cases (orchestration)
│   ├── orders/
│   │   ├── cancel-order.uc.ts
│   │   ├── get-order.uc.ts
│   │   └── search-orders.uc.ts
│   └── ...
│
├── presentation/                    # Primary adapters
│   ├── rest/
│   │   ├── routes.ts
│   │   ├── controllers/
│   │   │   └── orders.controller.ts
│   │   └── validators/
│   │       └── orders.zod.ts
│   │
│   ├── mcp/
│   │   ├── server.ts                # MCP transport setup
│   │   ├── tools/
│   │   │   └── orders.tools.ts
│   │   └── schemas/
│   │       └── orders.zod.ts        # See "shared validators" below
│   │
│   └── shared/
│       ├── auth.middleware.ts
│       └── audit.interceptor.ts
│
├── infrastructure/                  # Secondary adapters
│   ├── persistence/
│   │   └── orders.kysely.repo.ts    # Repository implementation
│   ├── stripe/
│   │   └── stripe.payments.ts
│   └── ...
│
└── main.ts                          # Composition root

Three things to notice.

`/presentation` exists as a sibling of `/infrastructure`. Both are adapter layers, but they sit on different sides of the hexagon. Presentation is driving — requests come in, use cases run. Infrastructure is driven — the application calls out. Splitting them surfaces the asymmetry in code, where most production systems benefit from it being visible. The single /infrastructure folder that holds both is a viable shape; the split is cleaner once the project has more than one primary adapter, which is exactly the case we are in.

`/presentation/rest` and `/presentation/mcp` are siblings. Same level. Same status. Neither one is "the real one." This is the conceptual move that the dual-adapter pattern depends on; if your folder structure quietly treats REST as primary and MCP as secondary, the team will quietly under-invest in the MCP side and the duplication problem starts.

The validation lives twice in this layout. Once in rest/validators/orders.zod.ts, once in mcp/schemas/orders.zod.ts. This is intentional but not ideal. We will address it in the next section.

Where the validators actually live

The most common question, in our experience, when teams first lay out this structure: if the validation is the same on both sides, do I write it once or twice?

The honest answer: neither. Both alternatives are wrong.

Writing it twice sets you up for drift. The REST side adds a constraint; the MCP side does not. A month later, an agent succeeds in calling an endpoint that the REST API would have rejected, and the bug is "a permission that should not have applied".

Writing it once at the presentation-shared level sounds right but is subtly wrong. The REST and MCP adapters speak about the input slightly differently. REST has request bodies, path params, query params; MCP has tool arguments with a flat structure. Shared validators end up shaped like "the union of what REST and MCP both happen to receive", and that union has fields nobody is actually using and missing fields someone needs.

The pattern that works: the validator lives in the application layer, as part of the use case's input contract. Each use case exposes a Zod schema for its input. Both the REST controller and the MCP tool handler import that schema and run it before calling the use case.

typescript
// application/orders/cancel-order.uc.ts
import { z } from "zod";

export const cancelOrderInput = z.object({
  orderId: z.string().describe("UUID of the order to cancel"),
  reason: z.string().min(1).max(500).describe("Why the order is being cancelled"),
});

export type CancelOrderInput = z.infer<typeof cancelOrderInput>;

export class CancelOrderUseCase {
  constructor(private readonly orders: OrderRepository) {}

  async execute(input: CancelOrderInput): Promise<CancelOrderResult> {
    // input is already validated by the caller; the use case
    // can trust its shape.
    const order = await this.orders.findById(input.orderId);
    // ...
  }
}

Now both adapters consume the same schema:

typescript
// presentation/rest/controllers/orders.controller.ts
import { cancelOrderInput } from "../../../application/orders/cancel-order.uc";

async function cancelOrderRoute(req, res) {
  const parsed = cancelOrderInput.parse(req.body);
  const result = await cancelOrderUseCase.execute(parsed);
  res.json(result);
}
typescript
// presentation/mcp/tools/orders.tools.ts
import { cancelOrderInput } from "../../../application/orders/cancel-order.uc";

server.registerTool(
  "cancelOrder",
  {
    title: "Cancel an order",
    description: "Cancels an existing order. Use only after confirming with the user.",
    inputSchema: cancelOrderInput.shape,
  },
  async (args) => {
    const result = await cancelOrderUseCase.execute(args);
    return { content: [{ type: "text", text: JSON.stringify(result) }] };
  },
);

The validator is part of the use case's contract. The adapters are translators between their respective wire formats and the contract. This is exactly what hexagonal asks of the layers.

One subtle benefit: the .describe() calls on the Zod schema — required by the MCP side anyway (the LLM reads these) — start serving the REST side too. The REST API's OpenAPI spec can be generated from the same Zod definitions. Both consumers get descriptions written once, consistent everywhere, and updated in one place when the meaning of a field changes.

The composition root

The file that wires everything together. main.ts is where every adapter is instantiated, every dependency is injected, every transport is started.

typescript
// main.ts
import { Kysely } from "kysely";
import { KyselyOrderRepository } from "./infrastructure/persistence/orders.kysely.repo";
import { CancelOrderUseCase } from "./application/orders/cancel-order.uc";
import { startRestServer } from "./presentation/rest/server";
import { mountMcpServer } from "./presentation/mcp/server";
import express from "express";

async function main() {
  // Infrastructure
  const db = new Kysely<DB>({ /* ... */ });
  const orderRepository = new KyselyOrderRepository(db);

  // Application
  const cancelOrderUseCase = new CancelOrderUseCase(orderRepository);
  // ... other use cases

  // Presentation
  const useCases = { cancelOrderUseCase, /* ... */ };
  const app = express();

  startRestServer({ app, useCases });
  await mountMcpServer({ app, useCases });

  app.listen(3000);
}

main();

A few specific things in that file worth flagging.

Both adapters mount on the same Express app. Same Node process, same HTTP server, same port. The MCP transport is just another set of routes alongside the REST routes. The agent's HTTP request and the dashboard's HTTP request travel through the same listener.

The use cases are passed by reference. The REST server and the MCP server hold the same use case instance. When the agent calls cancelOrder through MCP, it calls the same object that the frontend calls through REST. Every audit log, every rate limit, every domain event fires identically.

The composition root is the only place that knows about every layer. This is the hexagonal discipline. The use case does not know about Kysely. The repository does not know about Express. The controller does not know about the SDK. Only main.ts knows about all of them, and only it is allowed to.

If your main.ts grows past a hundred lines, that is a smell that the application has earned a dependency-injection container. awilix is the Node-world standard; for a project that ships with two adapters and a handful of use cases, hand-wired DI is fine. Reach for the container when the wiring stops fitting on one screen.

Mounting MCP next to REST on the same HTTP server

The transport question, since this often confuses people: how does the MCP server share a port with the REST server?

With Streamable HTTP — the current MCP transport as of the 2025 spec revisions — the MCP server is an HTTP handler. Mount it at a path on your existing Express or Fastify app:

typescript
// presentation/mcp/server.ts
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

export async function mountMcpServer({ app, useCases }) {
  const mcpServer = createMcpServer(useCases);
  const transport = new StreamableHTTPServerTransport({ /* ... */ });

  app.use("/mcp", authMiddleware, async (req, res) => {
    await transport.handleRequest(req, res, req.body);
  });

  await mcpServer.connect(transport);
}

Behind nginx, the path is a normal subpath. The MCP requests are HTTP requests; they need only the small set of nginx tweaks that any streaming HTTP endpoint needs (proxy_buffering off, chunked_transfer_encoding off, longer timeouts). The transport post covers what the transport choice means architecturally; this paragraph is the practical answer for how it lives in your existing infrastructure.

Stdio transport, for completeness, does not fit the dual-adapter pattern naturally. Stdio expects the host to spawn the server as a child process; in a dual-adapter setup, your server is already running and listening on a port. If you want both transports in the same project, the MCP entry point can be conditional — if (process.argv.includes("--stdio")) runs the stdio transport, otherwise the HTTP transport mounts on the existing app. The duplication is small. The flexibility is real for clients whose internal devs want stdio but whose production agents need HTTP.

Auth across two adapters

A specific concern that comes up in every enterprise procurement conversation: how do we ensure the agent and the user have consistent access control?

The answer is that the access control lives in the application layer, not the adapter. Both adapters pass authenticated identity to the use case; the use case decides what is allowed.

typescript
// presentation/shared/auth.middleware.ts
export async function authMiddleware(req, res, next) {
  const token = extractBearer(req.headers.authorization);
  const subject = await verifyJwt(token);
  req.subject = subject;  // user identity attached to request
  next();
}

// application/orders/cancel-order.uc.ts
async execute(input: CancelOrderInput, ctx: { subject: Subject }) {
  if (!ctx.subject.permissions.has("orders.cancel")) {
    throw new ForbiddenError("orders.cancel required");
  }
  // ...
}

The middleware runs at the HTTP layer, covering both /v1/... REST routes and the /mcp route. The verified subject is passed into every use case call. The use case enforces what the subject is allowed to do.

This pattern means a user with orders.cancel permission can cancel an order through the dashboard or through their AI agent, and a user without that permission cannot, regardless of which adapter they reach for. The agent inherits the user's permissions; it cannot do more than the user could do directly. From an enterprise security perspective, this is the property that lets you say honestly "the agent is constrained by the same access control as the human user". It is also a property that only exists because the access control is in the application layer, not duplicated across adapters.

The OAuth 2.1 post covers the auth substrate when the caller is an external agent; the security post covers tool-design security on top of this. Together they describe the security model the dual adapter inherits.

When two projects actually is right

For balance: three cases where the dual-adapter pattern is not the best call.

Different deploy lifecycles. If the MCP server's release cadence is genuinely different from the rest of the application — daily MCP tool tweaks vs. weekly REST releases — the deploy coupling becomes a tax. Split is justified when the operational rhythms diverge and forcing them together creates real friction.

Different operational risk profiles. If the MCP server is experimental and the REST API is in front of paying customers, you may prefer the MCP code in a process you can crash without affecting the SLA. This is the legitimate version of the "blast radius" concern. The fix is not necessarily a separate project; it can be the same project shipped as two binaries, each with its own entry point but the same domain code.

Different teams own them. If the MCP server is built by a different team than the backend, and the teams have different commit access, different release processes, and different priorities, the coordination cost of a single repo may exceed the duplication cost of two. Conway's law is real; the architecture follows the org chart, and forcing it to do otherwise produces architectural drag for organizational reasons.

In each of these cases, the domain still does not duplicate. The repository pattern, the use cases, the entities — these live in a shared package, depended on by both the REST project and the MCP project. The duplication that is unacceptable is at the business logic layer. The split that is sometimes acceptable is at the adapter layer.

What this means for the buyer conversation

The framing that resonates with technical buyers when proposing this shape:

"We are not building you a separate MCP server. We are adding an MCP interface to your existing application. Your business rules will not be written twice. Your security model will not fragment. Your audit logs will reflect every action the agent takes through the same pipeline that captures every action your users take. The MCP server is a face on the body of work you already paid us to build."

That sentence does two things at once. It positions MCP as additive to the existing investment, not as a parallel investment. And it commits to a property — no duplication of domain logic — that the buyer's compliance team will recognize as the right answer.

Most teams that hire out an MCP integration are expecting the worst case: two projects, two security models, twice the surface area to audit. The dual-adapter shape is the better answer technically and the easier answer to sell.

Where this fits in the series

The pillar is the prerequisite. The bounded-context post is the closest neighbor — it argues for one server per bounded context, which composes naturally with this post's "one app per bounded context, two adapters per app". Together they describe the structure for an enterprise estate: each context is its own application with REST + MCP adapters, all sharing IdP, all sharing observability, all independently versioned.

For the validation pattern specifically, the aggregates post goes one level deeper into what each tool actually wraps. For the wiring file when it grows past hand-managed size, the composition-root post is the natural next read.

For the auth model, the OAuth 2.1 post is the foundation, and the security post is the discipline that sits on top.

The dual adapter is the architecture the next generation of MCP servers should ship in. Not because MCP is special, but because MCP is just another way for code to be called, and any code worth calling has already earned the right not to be written twice. Frontend, backend, mobile, agent — they are all consumers of the same body of work. The architectures that recognize this scale; the architectures that don't get rebuilt every two years when the next consumer joins the party.

Related Topics

mcp dual adaptermcp hexagonal architecturemcp clean architecture nodejsmcp rest api same projectenterprise mcp server
Flutter & Node.js

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.

Clean Architecture on every tier
iOS + Android, source code included
From $4,900 — no monthly lock-in