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.
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.
Hover or tap a component to see what it does.
Show text descriptionHide 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:
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
);import pg from "pg";
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });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:
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:
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:
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" } });
}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:
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:
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):
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
- Implement the server, create a read-write token for yourself, and connect it to Claude Code.
- Ask "What's due this week?" and "Add a task to renew the domain on Friday". Check which tools ran.
- Revoke the token in the database and confirm the next request fails with 401. Run the tests.
Check your understanding
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.
Related articles
- Model Context Protocol (MCP): Build and Use MCP Servers in TypeScriptUnderstand MCP hosts, clients, servers, and transports, build a TypeScript MCP server with tools, resources, and prompts, and use it from Claude Code and your agents.
- Security for LLM Apps: Prompt Injection, Data Leaks, and Agent RisksThreat-model an AI feature, defend against direct and indirect prompt injection, block exfiltration and SSRF, scope tools, and protect secrets and personal data.
- Project: Build a Customer Support Agent with Tools, Approvals, and HandoffBuild a support agent that searches help articles, looks up orders, proposes refunds for human approval, and hands off to staff, with runs persisted in Postgres.