Skip to content
AI Full-Stack Wiki

Project: Build a Remote MCP Server for Your Product with Next.js

Expose your product to AI agents: a remote MCP server in a Next.js route with Streamable HTTP, hashed API tokens, scopes, per-user data access, and tests.

Difficulty: Advanced21 min readUpdated
On this page· What you will build

Why this matters

More and more work happens inside AI tools: coding agents, chat assistants, custom agents. A product that those tools can use directly is more useful than one they cannot. An MCP server is how you make your product usable by any MCP-compatible agent, with the same authorization rules as your app. Building one teaches API design for models, authentication, and least privilege, and it is a feature real companies are shipping right now.

What you will build#

A remote MCP server for a small task-tracking product, inside your existing Next.js app:

  • Tools to search, create, and complete tasks, with read and write scopes.
  • A resource template for individual tasks and a prompt for a weekly summary.
  • Personal API tokens, stored hashed, that map each request to a user.
  • Connection from Claude Code and from your own agents.
  • Tests that drive the server through a real MCP client.
Remote MCP server architectureClaude Code and other MCP hosts connect over Streamable HTTP with a bearer token. The server authenticates each request, maps the token to a user, and exposes tools and resources that call your product's API with that user's permissions.toolsClaude CodeMCP hostYour agentsMCP clientAuthtoken → userMCP serverPOST /mcpProduct APIas that userDatabase

Hover or tap a component to see what it does.

Remote MCP server architecture: MCP hosts connect over Streamable HTTP with a token; the server authenticates each request and exposes tools that call your product API with that user's permissions.
Show text description

The MCP server is a thin, authenticated adapter over the API you already have.

  • Claude Code: Connects with `claude mcp add --transport http`.
  • Your agents: Any MCP client, including the agents you build with the TypeScript SDK.
  • Auth: Every request carries a bearer token. Invalid tokens get 401; valid ones resolve to a user and scopes.
  • MCP server: Streamable HTTP endpoint exposing tools, resources, and prompts.
  • Product API: Tools call your existing API with the user's permissions, never with an admin key.
  • Database: Reached only through the product API's authorization rules.

Read the MCP article first; this project takes its local server remote.

1. Data and the product layer#

The MCP server must not get its own path to the data. Tools call the same product functions your app uses, which already enforce who can see what:

db/schema.sql
CREATE TABLE tasks (
  id          bigserial PRIMARY KEY,
  owner_id    text NOT NULL,
  title       text NOT NULL,
  notes       text NOT NULL DEFAULT '',
  done        boolean NOT NULL DEFAULT false,
  due_date    date,
  created_at  timestamptz NOT NULL DEFAULT now()
);
 
CREATE TABLE api_tokens (
  token_hash  text PRIMARY KEY,           -- sha256 of the token; the token itself is shown once and never stored
  user_id     text NOT NULL,
  scopes      text[] NOT NULL,            -- {tasks:read} or {tasks:read,tasks:write}
  label       text NOT NULL,
  last_used   timestamptz,
  revoked_at  timestamptz
);
lib/db.ts
import pg from "pg";
 
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
lib/tasks.ts
import { pool } from "./db.ts";
 
export interface Task {
  id: string;
  title: string;
  notes: string;
  done: boolean;
  due_date: string | null;
}
 
const COLS = "id::text, title, notes, done, to_char(due_date, 'YYYY-MM-DD') AS due_date";
 
export async function searchTasks(userId: string, query: string, includeDone: boolean): Promise<Task[]> {
  const { rows } = await pool.query<Task>(
    `SELECT ${COLS} FROM tasks
      WHERE owner_id = $1 AND (title ILIKE $2 OR notes ILIKE $2) AND ($3 OR NOT done)
      ORDER BY done, due_date NULLS LAST, id DESC LIMIT 25`,
    [userId, `%${query}%`, includeDone],
  );
  return rows;
}
 
export async function getTask(userId: string, id: string): Promise<Task | null> {
  const { rows } = await pool.query<Task>(`SELECT ${COLS} FROM tasks WHERE owner_id = $1 AND id = $2`, [userId, id]);
  return rows[0] ?? null;
}
 
export async function createTask(userId: string, input: { title: string; notes?: string; due_date?: string }): Promise<Task> {
  const { rows } = await pool.query<Task>(
    `INSERT INTO tasks (owner_id, title, notes, due_date) VALUES ($1, $2, $3, $4) RETURNING ${COLS}`,
    [userId, input.title, input.notes ?? "", input.due_date ?? null],
  );
  return rows[0]!;
}
 
export async function completeTask(userId: string, id: string): Promise<Task | null> {
  const { rows } = await pool.query<Task>(`UPDATE tasks SET done = true WHERE owner_id = $1 AND id = $2 RETURNING ${COLS}`, [userId, id]);
  return rows[0] ?? null;
}

2. Tokens#

Users create personal tokens in your app's settings page. Store only a hash, show the token once, and let users revoke it:

lib/tokens.ts
import { createHash, randomBytes } from "node:crypto";
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import { pool } from "./db.ts";
 
const hash = (token: string) => createHash("sha256").update(token).digest("hex");
 
/** Create a token for a user. Returns the plain token, which is shown to the user once. */
export async function createToken(userId: string, scopes: string[], label: string): Promise<string> {
  const token = `tsk_${randomBytes(24).toString("base64url")}`;
  await pool.query("INSERT INTO api_tokens (token_hash, user_id, scopes, label) VALUES ($1, $2, $3, $4)", [hash(token), userId, scopes, label]);
  return token;
}
 
/** Resolve a bearer token to MCP auth info, or null when it is missing, unknown, or revoked. */
export async function authenticate(header: string | null): Promise<AuthInfo | null> {
  const token = header?.match(/^Bearer (\S+)$/)?.[1];
  if (!token) return null;
  const { rows } = await pool.query<{ user_id: string; scopes: string[] }>(
    "UPDATE api_tokens SET last_used = now() WHERE token_hash = $1 AND revoked_at IS NULL RETURNING user_id, scopes",
    [hash(token)],
  );
  if (!rows[0]) return null;
  return { token, clientId: rows[0].user_id, scopes: rows[0].scopes, extra: { userId: rows[0].user_id } };
}

3. The MCP server#

The server is built per request (stateless), with the authenticated user available to every tool through extra.authInfo. Write tools check the scope before acting:

lib/mcp-server.ts
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import { z } from "zod";
import { completeTask, createTask, getTask, searchTasks } from "./tasks.ts";
 
function userOf(auth: AuthInfo | undefined, scope: string): string {
  const userId = auth?.extra?.userId;
  if (typeof userId !== "string") throw new Error("Not authenticated");
  if (!auth!.scopes.includes(scope)) throw new Error(`This token lacks the ${scope} scope`);
  return userId;
}
 
const text = (value: unknown) => ({ content: [{ type: "text" as const, text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
 
export function buildServer(): McpServer {
  const server = new McpServer({ name: "acme-tasks", version: "1.0.0" });
 
  server.registerTool(
    "search_tasks",
    {
      title: "Search tasks",
      description: "Search the user's tasks by words in the title or notes. Open tasks come first, sorted by due date. Use an empty query to list open tasks.",
      inputSchema: { query: z.string().max(200).default(""), include_done: z.boolean().default(false) },
      annotations: { readOnlyHint: true },
    },
    async ({ query, include_done }, extra) => text(await searchTasks(userOf(extra.authInfo, "tasks:read"), query, include_done)),
  );
 
  server.registerTool(
    "create_task",
    {
      title: "Create task",
      description: "Create a task for the user. Use only when the user asks to add or remember something.",
      inputSchema: {
        title: z.string().min(1).max(200),
        notes: z.string().max(5000).optional(),
        due_date: z.iso.date().optional().describe("YYYY-MM-DD"),
      },
      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
    },
    async (input, extra) => text(await createTask(userOf(extra.authInfo, "tasks:write"), input)),
  );
 
  server.registerTool(
    "complete_task",
    {
      title: "Complete task",
      description: "Mark one of the user's tasks as done by ID.",
      inputSchema: { id: z.string().regex(/^\d+$/) },
      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
    },
    async ({ id }, extra) => {
      const task = await completeTask(userOf(extra.authInfo, "tasks:write"), id);
      return task ? text(task) : { ...text(`No task ${id}`), isError: true };
    },
  );
 
  server.registerResource(
    "task",
    new ResourceTemplate("task://{id}", { list: undefined }),
    { title: "Task", description: "One task as JSON", mimeType: "application/json" },
    async (uri, { id }, extra) => {
      const task = await getTask(userOf(extra.authInfo, "tasks:read"), String(id));
      if (!task) throw new Error(`No task ${String(id)}`);
      return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(task) }] };
    },
  );
 
  server.registerPrompt(
    "weekly_summary",
    { title: "Weekly task summary", description: "Summarize open tasks and what is due this week" },
    () => ({
      messages: [{ role: "user", content: { type: "text", text: "Use search_tasks to list my open tasks, then summarize what is overdue, due this week, and later, in three short sections." } }],
    }),
  );
 
  return server;
}

4. The route#

The request handler lives in lib/ so tests can import it without Next.js; the route file only re-exports it. It uses the web-standard Streamable HTTP transport, and authentication happens before any MCP message is processed:

lib/mcp-handler.ts
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { buildServer } from "./mcp-server.ts";
import { authenticate } from "./tokens.ts";
 
export async function handleMcp(request: Request): Promise<Response> {
  const authInfo = await authenticate(request.headers.get("authorization"));
  if (!authInfo) {
    return Response.json(
      { jsonrpc: "2.0", error: { code: -32001, message: "Invalid or missing API token" }, id: null },
      { status: 401, headers: { "www-authenticate": 'Bearer realm="acme-tasks"' } },
    );
  }
 
  // Stateless: a fresh server and transport per request, so requests never share state.
  const server = buildServer();
  const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
  await server.connect(transport);
  try {
    return await transport.handleRequest(request, { authInfo });
  } finally {
    // In JSON response mode the response is complete here, so the per-request server can be closed.
    void server.close();
  }
}
 
// Stateless servers offer no standalone event stream and no sessions to delete.
export function methodNotAllowed(): Response {
  return new Response(null, { status: 405, headers: { allow: "POST" } });
}
app/mcp/route.ts
import { handleMcp, methodNotAllowed } from "@/lib/mcp-handler";
 
export const POST = handleMcp;
export const GET = methodNotAllowed;
export const DELETE = methodNotAllowed;

5. Connect clients#

From Claude Code, add the server over HTTP with the user's token:

terminal
claude mcp add --transport http acme-tasks https://your-app.example/mcp --header "Authorization: Bearer tsk_your_token"

From your own agent, the MCP SDK's HTTP client transport does the same:

client-example.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
 
const client = new Client({ name: "my-agent", version: "1.0.0" });
await client.connect(
  new StreamableHTTPClientTransport(new URL(process.env.MCP_URL ?? "http://localhost:3000/mcp"), {
    requestInit: { headers: { authorization: `Bearer ${process.env.ACME_TOKEN}` } },
  }),
);
console.log((await client.listTools()).tools.map((t) => t.name));
console.log(await client.callTool({ name: "search_tasks", arguments: { query: "" } }));
await client.close();

6. Tests through a real client#

The client transport accepts a custom fetch, so tests can call the handler directly, with no server running. Run them against a test database that has the schema applied (DATABASE_URL=postgres://…/acme_test node --test mcp.test.ts):

mcp.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { handleMcp } from "./lib/mcp-handler.ts";
import { createToken } from "./lib/tokens.ts";
 
async function connect(token: string) {
  const client = new Client({ name: "test", version: "1.0.0" });
  const transport = new StreamableHTTPClientTransport(new URL("http://test/mcp"), {
    requestInit: { headers: { authorization: `Bearer ${token}` } },
    fetch: (url, init) => handleMcp(new Request(url, init)), // the handler, in-process
  });
  await client.connect(transport);
  return client;
}
 
const first = (r: Awaited<ReturnType<Client["callTool"]>>) => (r.content as { text: string }[])[0]!.text;
 
test("a read-write token can create, find, and complete tasks", async () => {
  const client = await connect(await createToken("user_a", ["tasks:read", "tasks:write"], "test"));
  const created = JSON.parse(first(await client.callTool({ name: "create_task", arguments: { title: "Ship MCP server", due_date: "2026-10-10" } })));
  const found = JSON.parse(first(await client.callTool({ name: "search_tasks", arguments: { query: "MCP" } })));
  assert.equal(found[0].id, created.id);
  const done = JSON.parse(first(await client.callTool({ name: "complete_task", arguments: { id: created.id } })));
  assert.equal(done.done, true);
  await client.close();
});
 
test("a read-only token cannot write", async () => {
  const client = await connect(await createToken("user_a", ["tasks:read"], "read-only"));
  const result = await client.callTool({ name: "create_task", arguments: { title: "Nope" } });
  assert.equal(result.isError, true);
  assert.match(first(result), /lacks the tasks:write scope/);
  await client.close();
});
 
test("users only see their own tasks", async () => {
  const client = await connect(await createToken("user_b", ["tasks:read"], "other user"));
  const found = JSON.parse(first(await client.callTool({ name: "search_tasks", arguments: { query: "MCP", include_done: true } })));
  assert.deepEqual(found, []);
  await client.close();
});
 
test("an invalid token is rejected before MCP handling", async () => {
  await assert.rejects(connect("tsk_wrong"));
});

Shipping it#

  • Rate-limit per token, like any API.
  • Log tool calls with the user, tool, and outcome (not the full arguments if they may contain personal data).
  • Version tool contracts carefully: renaming a tool or a parameter breaks agents that use it. Add new tools rather than changing old ones.
  • For public distribution, implement the MCP authorization flow (OAuth 2.1 with protected resource metadata) so users can connect without copying tokens. Personal tokens are fine for internal and early use.
  • Document it: a page listing tools, scopes, and example prompts.

Common mistakes#

  • A separate data path for MCP. Tools must go through the same authorization as your app.
  • Admin credentials in the server. Every call acts as the token's user.
  • One giant tool. Several small, well-described tools are easier for models to use correctly.
  • Storing tokens in plain text. Store hashes; show the token once.
  • Huge tool results. Paginate and limit; results go into the model's context.

Try it

Use your own server from an agent

  1. Implement the server, create a read-write token for yourself, and connect it to Claude Code.
  2. Ask "What's due this week?" and "Add a task to renew the domain on Friday". Check which tools ran.
  3. Revoke the token in the database and confirm the next request fails with 401. Run the tests.

Check your understanding

Why do the MCP tools call the product's existing functions instead of querying tables directly?
How is a read-only token prevented from creating tasks?
Why store only a hash of each API token?

Key takeaways

  • An MCP server is a thin, authenticated adapter over your product's existing functions.
  • Serve it from a Next.js route with the web-standard Streamable HTTP transport, one stateless server per request.
  • Authenticate every request with hashed personal tokens, pass the user to tools, and enforce scopes in code.
  • Test through a real MCP client with a custom fetch, and plan for OAuth when distributing publicly.

Next steps#

Finished this article? Track it to see your progress on the roadmap.