Dendra 教學:如何將 Next.js 函數公開為 MCP 工具
Expose Next.js functions as MCP tools with Dendra
Dendra 教學示範如何將 Next.js 函數公開為 MCP 工具,讓 Next.js API 路由和本機 MCP 伺服器共用同一輸入結構描述及處理程序。範例使用 Dendra 0.7.1 和 MCP TypeScript SDK 1.32.1,並測試有效輸入、無效輸入及未啟用 AI 存取的程序均按預期處理。
Expose a Next.js operation as an MCP tool with Dendra, sharing its input validation and handler.
Your application already knows how to look up a record, calculate a result or create a task. When you add an AI assistant, those same operations need a tool name, an input schema and a way to execute them.
I built Dendra, the open source framework used in this example, to keep those definitions together.
Dendra is an open source TypeScript framework for typed APIs and explicitly enabled AI tools. A procedure contains the input contract and handler. Adapters connect that procedure to the interfaces your application needs, including Next.js API routes and Model Context Protocol tools.
In this tutorial, we will expose one small operation through a Next.js route and a local MCP server. Both will use the same handler. We will also verify what happens when a tool receives invalid input or tries to call a procedure that has not been enabled for AI access.
The example uses addition so the connection between the two interfaces is easy to inspect. The same structure can hold application logic such as searching your own records or calculating a project estimate.
What the application will contain
There are three pieces:
- A router that defines the operations and their input schemas.
- A Next.js adapter that exposes the router over HTTP.
- An MCP server that exposes the selected operations through the official MCP TypeScript SDK.
The MCP server in this tutorial uses local standard input and output, usually called stdio. It runs as a separate process beside the Next.js application. A remote MCP endpoint requires additional HTTP transport and authentication setup.
Install the dependencies
Start in an existing Next.js App Router project with TypeScript and Node.js 22 or later. The paths below assume an app directory at the project root. If your project uses src/app, place the router in src/server and adjust the MCP server import accordingly.
This walkthrough uses Dendra 0.7.1 and the MCP TypeScript SDK 1.32.1:
npm install dendra@0.7.1 @modelcontextprotocol/sdk@1.32.1
npm install --save-dev tsx@4.23.15
Use a supported, patched Next.js release for your application. The example adds Dendra and the MCP SDK to your existing project; it does not change your Next.js version.
Dendra is released under the MIT license and is currently before version 1.0. Pinning the version makes this walkthrough reproducible while the framework develops.
Define the operation once
Create server/router.ts:
import { initDendra, s, type RouterRecord } from "dendra";
const { route } = initDendra<Record<string, never>>();
export const appRouter = {
add: route
.input(s.object({ a: s.number(), b: s.number() }))
.ai({ description: "Add two numbers" })
.handler(({ input }) => input.a + input.b),
health: route.get().handler(() => ({ ok: true })),
} satisfies RouterRecord;
export type AppRouter = typeof appRouter;
The add procedure has a numeric input schema, a tool description and one handler. Calling .ai() explicitly includes it in the generated tool list.
The health procedure is available through the application API. It has no .ai() declaration, so the MCP bridge excludes it. We will test that boundary later.
The empty context in this example is intentional: adding two numbers does not access a user account or private data. Operations that do need access controls should receive a verified user context and enforce permissions in middleware and business logic.
Mount the Next.js route
Create app/rpc/[...path]/route.ts:
import { toNextHandler } from "dendra";
import { appRouter } from "../../../server/router";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const { GET, POST } = toNextHandler(appRouter, {
prefix: "/rpc/",
createContext: () => ({}),
});
Start your Next.js application with its usual development command. With the default port of 3000, call the operation:
curl http://localhost:3000/rpc/add \
-H 'Content-Type: application/json' \
-d '{"a":20,"b":22}'
The response is:
{"result":42}
If you change a to the string "twenty", input validation rejects the request with HTTP 400. The handler expects numbers on every supported execution path.
Connect the router to MCP
Create mcp-server.mts at the project root:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { createMcpBridge } from "dendra";
import { appRouter } from "./server/router";
const bridge = createMcpBridge(appRouter, {});
const server = new Server(
{ name: "dendra-tutorial", version: "1.0.0" },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: bridge.listTools(),
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const result = await bridge.callTool(
request.params.name,
request.params.arguments,
);
return { content: result.content, isError: result.isError };
});
await server.connect(new StdioServerTransport());
createMcpBridge derives the tool definitions from the router and executes calls through Dendra. The official MCP SDK handles the protocol connection over stdio.
The tool names here are simple top level names. If you later expose nested router paths, check the tool naming requirements of your MCP clients and use a collision checked mapping where necessary.
The Next.js route and this MCP server import the same router. They run in separate processes, so shared in memory state would not be synchronized between them. For a real application with stored data, connect both to the appropriate database or use a server architecture that routes calls to the same application service.
Verify the tool through an MCP client
Create verify-mcp.mts at the project root:
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const transport = new StdioClientTransport({
command: process.execPath,
args: ["--import", "tsx", fileURLToPath(new URL("./mcp-server.mts", import.meta.url))],
});
const client = new Client({ name: "dendra-tutorial-check", version: "1.0.0" });
try {
await client.connect(transport);
const tools = (await client.listTools()).tools;
assert.deepEqual(tools.map((tool) => tool.name), ["add"]);
console.log("Tools: add");
const result = await client.callTool({ name: "add", arguments: { a: 20, b: 22 } });
assert.notEqual(result.isError, true);
assert.deepEqual(result.content, [{ type: "text", text: "42" }]);
console.log("Valid call: 42");
const invalid = await client.callTool({ name: "add", arguments: { a: "twenty", b: 22 } });
assert.equal(invalid.isError, true);
console.log("Invalid argument: rejected");
const unexposed = await client.callTool({ name: "health", arguments: {} });
assert.equal(unexposed.isError, true);
console.log("Unexposed procedure: rejected");
} finally {
await client.close();
}
Run it from the project root:
npx tsx verify-mcp.mts
Expected output:
Tools: add
Valid call: 42
Invalid argument: rejected
Unexposed procedure: rejected
This check uses an MCP client to launch the server, list its tools and make calls. It does not require an AI model subscription or API key. The Next.js server can also be stopped during this check because the MCP process imports the procedure directly.
To use the server with a desktop MCP client, configure that client to launch your Node.js executable with --import, the absolute path to the installed tsx loader and the absolute path to mcp-server.mts. Follow your client's local server configuration format. Keep application logs off standard output because stdio uses that channel for protocol messages.
Add the application permissions deliberately
The .ai() declaration controls whether a procedure is exposed as a tool. Input validation checks the arguments. Authentication establishes who is calling, and authorization decides what that caller may do.
Dendra lets you attach middleware with .use() and supply context to the execution path. Your application must implement the policy and pass the correct context for each user. Do not carry the empty context from this arithmetic example into a tool that reads private records or changes customer data.
For remote access, use an appropriate MCP transport, HTTPS and authentication, with permissions tied to the caller. The local stdio example does not implement those deployment concerns.
What this example establishes
The HTTP request and MCP tool call use one input schema and handler. Both reject the same invalid argument. The tool list contains add, while the unexposed health procedure stays out of the MCP interface.
The distinction matters: selecting a procedure with .ai() controls tool exposure. It does not establish a user's identity, grant permission, synchronize separate processes, or deploy a remote server.
Dendra also has adapters for typed clients, REST routing and OpenAPI generation. They are outside this example. The HTTP route shown here is an RPC endpoint; it is not an MCP endpoint.
Verification
The example was checked on 19 October 2026 using a clean dependency installation with Dendra 0.7.1, MCP SDK 1.32.1, TypeScript 5.9.3 and tsx 4.23.15. The local Next.js application used Next.js 16.4.2 and React 19.3.1.
Type checking, MCP initialization, tool listing, valid execution, invalid input rejection and rejection of an unexposed procedure passed. Requests to the running Next.js development server also returned the expected HTTP results. These checks cover this small local example, not a deployed application's authentication or security.
References
I created Dendra. AI assisted with drafting and checking this tutorial. The example was verified through executable TypeScript, HTTP and MCP tests.
來源:DEV Community · dev.to