+1 (726) 227-3745

Give Your MEAN App an MCP Server: Express 5, Mongoose 8 and Safe Tools for AI Agents

Every client we talk to in 2026 wants their internal AI assistant to "just read the app's data". The usual first attempt is a pile of bespoke glue: a Slack bot here, a LangChain tool there, a scraping script that logs in as an admin user. The Model Context Protocol (MCP) replaced that mess with one contract. Write an MCP server once and Claude Desktop, Cursor, VS Code Copilot, ChatGPT's connectors and your own agents can all call it.

If you already run a MEAN app, you are most of the way there. Your Mongoose models are the schema, your Express 5 app is the transport host, and your existing auth is the security boundary. This tutorial adds a production-shaped MCP server to an existing Express 5 + Mongoose 8 API running on Node 24, exposes read tools and one carefully-guarded write tool, and wires it into an MCP client.

The example app is a support-ticket system with Ticket, Customer and User collections. Substitute your own models; the structure does not change.

What MCP actually is

MCP is a JSON-RPC 2.0 protocol with three primitives worth caring about:

  • Tools — model-invoked functions. search_tickets, get_customer, add_ticket_note. This is where 90% of your work goes.
  • Resources — application-controlled read-only content addressed by URI, e.g. ticket://64f2.../thread. Good for attaching context the client picks, not the model.
  • Prompts — reusable prompt templates the user picks from a menu.

Two transports matter today. stdio for local servers the client launches as a subprocess, and Streamable HTTP for remote servers. Streamable HTTP replaced the old HTTP+SSE transport; if you find a 2024 tutorial using two endpoints (/sse plus /messages), it is out of date. We will mount Streamable HTTP inside the Express 5 app you already run.

Prerequisites

  • Node.js 24 LTS, an existing Express 5 API, Mongoose 8 against MongoDB 8.
  • npm install @modelcontextprotocol/sdk zod
  • An MCP client for testing. The inspector is the fastest: npx @modelcontextprotocol/inspector.

Step 1: define the server and its tools

Create src/mcp/server.js. The SDK's McpServer builds tool schemas from Zod, and the same Zod objects give you runtime validation — the model will send you a string where you asked for a number.

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { Ticket, Customer } from '../models.js';

const MAX_ROWS = 50;

export function buildMcpServer(ctx) {
  // ctx = { userId, orgId, scopes } resolved from the HTTP request
  const server = new McpServer({ name: 'stackpilots-support', version: '1.0.0' });

  server.registerTool(
    'search_tickets',
    {
      title: 'Search support tickets',
      description:
        'Search tickets in the current organisation by free text, status and date range. ' +
        'Returns at most 50 summaries, newest first. Use get_ticket for the full thread.',
      inputSchema: {
        query: z.string().max(200).optional().describe('Text to match in subject or body'),
        status: z.enum(['open', 'pending', 'closed']).optional(),
        since: z.string().datetime().optional().describe('ISO 8601; only tickets updated after this'),
        limit: z.number().int().min(1).max(MAX_ROWS).default(20),
      },
    },
    async ({ query, status, since, limit }) => {
      const filter = { org: ctx.orgId };
      if (status) filter.status = status;
      if (since) filter.updatedAt = { $gte: new Date(since) };
      if (query) filter.$text = { $search: query };

      const rows = await Ticket.find(filter)
        .sort({ updatedAt: -1 })
        .limit(Math.min(limit, MAX_ROWS))
        .select('_id subject status priority updatedAt customer')
        .populate('customer', 'name')
        .lean();

      return {
        content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
        structuredContent: { tickets: rows },
      };
    },
  );

  return server;
}

Three details that separate a demo from something a model uses well:

  1. The description is the API docs for a reader who cannot ask questions. Say what is returned, what the limits are and which tool to call next. Cheap tokens, far fewer wrong calls.
  2. .describe() on every non-obvious field. Models guess date formats badly; tell them ISO 8601.
  3. .lean() and an explicit .select(). Never hand a model a full Mongoose document. You are paying for every field in context, and hydrated documents serialise inconsistently.

Step 2: never trust the tenant scope from the model

The single most common security bug we find in MCP servers built on existing APIs: the tool takes orgId as an input parameter. The model then hallucinates — or an indirect prompt injection supplies — someone else's org id, and your server happily obliges.

The scope comes from the authenticated session, closed over in ctx, and never appears in inputSchema. Same rule as any multi-tenant REST endpoint; it is just easier to forget when the caller is an LLM.

A related trap is operator injection. If you interpolate a model-supplied string into a filter, {"$ne": null} still does what it always did. Our NoSQL injection write-up applies verbatim here — Zod's z.string() plus mongoSanitize on any pass-through object.

Step 3: a read tool with pagination, and a write tool with a guard rail

server.registerTool(
  'get_ticket',
  {
    title: 'Get one ticket with its message thread',
    description: 'Full ticket including up to 30 most recent messages. Call after search_tickets.',
    inputSchema: { ticketId: z.string().regex(/^[a-f\d]{24}$/i, 'Mongo ObjectId') },
  },
  async ({ ticketId }) => {
    const ticket = await Ticket.findOne({ _id: ticketId, org: ctx.orgId })
      .select('subject status priority messages customer createdAt')
      .slice('messages', -30)
      .lean();

    if (!ticket) {
      return { isError: true, content: [{ type: 'text', text: 'No such ticket in this organisation.' }] };
    }
    return { content: [{ type: 'text', text: JSON.stringify(ticket, null, 2) }] };
  },
);

server.registerTool(
  'add_ticket_note',
  {
    title: 'Add an internal note to a ticket',
    description: 'Appends an internal-only note. Not visible to the customer. Never sends email.',
    inputSchema: {
      ticketId: z.string().regex(/^[a-f\d]{24}$/i),
      note: z.string().min(1).max(2000),
    },
    annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
  },
  async ({ ticketId, note }) => {
    if (!ctx.scopes.includes('tickets:write')) {
      return { isError: true, content: [{ type: 'text', text: 'This token cannot write tickets.' }] };
    }
    const res = await Ticket.updateOne(
      { _id: ticketId, org: ctx.orgId },
      { $push: { notes: { body: note, author: ctx.userId, source: 'mcp', createdAt: new Date() } } },
    );
    return {
      content: [{ type: 'text', text: res.matchedCount ? 'Note added.' : 'Ticket not found.' }],
    };
  },
);

Notes on the write tool:

  • Return errors as isError: true content, not thrown exceptions. The model can read the message and correct itself; a 500 just ends the turn.
  • annotations are hints clients use to decide when to ask the human for confirmation. Mark anything that deletes or emails as destructiveHint: true and expect a consent dialog.
  • source: 'mcp' in the audit trail. When someone asks "who wrote this note", you want the answer to include "an agent, on behalf of user X".
  • Start with read-only tools. Add writes one at a time, each with its own scope. An agent that can only read is a bad day; an agent that can mass-close tickets is an incident.

Step 4: mount Streamable HTTP on your Express 5 app

Each MCP session gets its own transport instance keyed by the mcp-session-id header.

import express from 'express';
import { randomUUID } from 'node:crypto';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { buildMcpServer } from './server.js';
import { requireApiToken } from '../auth.js';

export const mcpRouter = express.Router();
const transports = new Map(); // sessionId -> transport

mcpRouter.post('/', requireApiToken, express.json(), async (req, res) => {
  const sessionId = req.get('mcp-session-id');
  let transport = sessionId && transports.get(sessionId);

  if (!transport) {
    transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),
      onsessioninitialized: (id) => transports.set(id, transport),
      enableDnsRebindingProtection: true,
      allowedHosts: ['mcp.example.com'],
    });
    transport.onclose = () => transport.sessionId && transports.delete(transport.sessionId);

    const server = buildMcpServer({
      userId: req.auth.userId,
      orgId: req.auth.orgId,
      scopes: req.auth.scopes,
    });
    await server.connect(transport);
  }

  await transport.handleRequest(req, res, req.body);
});

// GET opens the server->client stream; DELETE ends the session.
mcpRouter.get('/', requireApiToken, (req, res) => handleBySession(req, res));
mcpRouter.delete('/', requireApiToken, (req, res) => handleBySession(req, res));

async function handleBySession(req, res) {
  const transport = transports.get(req.get('mcp-session-id'));
  if (!transport) return res.status(400).json({ error: 'unknown session' });
  await transport.handleRequest(req, res);
}

Then in your app: app.use('/mcp', mcpRouter);

Two Express-specific gotchas:

  • enableDnsRebindingProtection with an explicit allowedHosts. Without it, a page in the user's browser can reach a locally bound MCP server. This bit a lot of early deployments.
  • The GET stream is long-lived. If you sit behind nginx or an ALB, raise the read timeout and disable proxy buffering for /mcp, exactly as you would for the SSE endpoint in our change-streams tutorial.

Also: sessions live in a Map, so with more than one Node process you need sticky sessions on mcp-session-id, or an external session store. Under PM2 cluster mode or multiple containers, plain round-robin will hand session traffic to a process that has never heard of it.

Step 5: authentication

For internal use, a scoped API token per user (the requireApiToken middleware above) is honest and simple: the human pastes it into the client's config, and every tool call carries their org and scopes.

For third-party clients, the MCP authorization spec makes your server an OAuth 2.1 resource server. You publish /.well-known/oauth-protected-resource pointing at your authorization server, validate bearer tokens with audience binding, and return 401 with a WWW-Authenticate header so the client can discover where to authenticate. Do not mint your own tokens inside the MCP server, and reject tokens whose aud is not your resource — token passthrough is explicitly forbidden by the spec and is the mechanism behind the confused-deputy attacks published this year.

Step 6: test with the inspector, then a real client

npx @modelcontextprotocol/inspector

Point it at http://localhost:3000/mcp, transport "Streamable HTTP", and add an Authorization: Bearer <token> header. You get a tool list, a form per tool, and the raw JSON-RPC traffic — which is where you will actually find your bugs.

For Claude Desktop or Cursor, add the remote server to the client config:

{
  "mcpServers": {
    "stackpilots-support": {
      "type": "http",
      "url": "https://mcp.example.com/mcp",
      "headers": { "Authorization": "Bearer ${SUPPORT_MCP_TOKEN}" }
    }
  }
}

Then evaluate it like a product, not a library. Give an agent ten real questions ("which open tickets from Acme mention billing?") and read the transcript. The failures are almost never crashes; they are the model calling search_tickets five times with slightly different queries because your description did not say the search is full-text, or blowing its context because one tool returned 400 KB of JSON.

Step 7: keep it cheap and observable

  • Cap every result set server-side, and say the cap in the description. limit should be advisory; MAX_ROWS is law.
  • Return structuredContent alongside the text block for clients that can consume typed output — plus an outputSchema if you want the SDK to validate it.
  • Trace it. Tool calls are just handlers; the OpenTelemetry setup from our tracing tutorial will show a slow search_tickets as a span with its Mongo query underneath. Add mcp.tool.name as a span attribute and you can chart cost per tool.
  • Rate-limit per token, not per IP. One enthusiastic agent loop can issue hundreds of calls a minute.
  • Log every call with arguments and outcome. When a customer asks what the assistant did, "we don't log tool calls" is not an answer.

Where this leaves you

Ten to fifteen tools over an existing Mongoose data layer is usually a week of work and covers most of what an internal assistant needs. The hard parts are not MCP: they are tenant scoping, result-size discipline, and deciding which writes an agent may perform unattended.

If you are weighing an MCP layer on top of an existing MEAN app — or you have one working locally and need it hardened, authenticated and deployed — get in touch. We do this as a scoped engagement alongside the rest of your Node and MongoDB stack.