{"slug": "expose-next-js-functions-as-mcp-tools-with-dendra", "title": "Expose Next.js functions as MCP tools with Dendra", "summary": "A developer built Dendra, an open source MIT-licensed TypeScript framework that lets a single procedure definition—input schema plus handler—be exposed both as a Next.js API route and as a Model Context Protocol tool. In a walkthrough using Dendra 0.7.1 and the MCP TypeScript SDK 1.32.1, an addition procedure is served through a Next.js route and a local stdio MCP server sharing the same handler, with input validation rejecting non-numeric values with HTTP 400 and procedures lacking an explicit .ai() declaration excluded from the generated tool list.", "body_md": "Expose a Next.js operation as an MCP tool with Dendra, sharing its input validation and handler.\n\nYour 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.\n\nI built Dendra, the open source framework used in this example, to keep those definitions together.\n\nDendra 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.\n\nIn 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.\n\nThe 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.\n\nThere are three pieces:\n\nThe 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.\n\nStart 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.\n\nThis walkthrough uses Dendra 0.7.1 and the MCP TypeScript SDK 1.32.1:\n\n```\nnpm install dendra@0.7.1 @modelcontextprotocol/sdk@1.32.1\nnpm install --save-dev tsx@4.23.15\n```\n\nUse 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.\n\nDendra is released under the MIT license and is currently before version 1.0. Pinning the version makes this walkthrough reproducible while the framework develops.\n\nCreate `server/router.ts`:\n\n``` js\nimport { initDendra, s, type RouterRecord } from \"dendra\";\n\nconst { route } = initDendra<Record<string, never>>();\n\nexport const appRouter = {\n  add: route\n    .input(s.object({ a: s.number(), b: s.number() }))\n    .ai({ description: \"Add two numbers\" })\n    .handler(({ input }) => input.a + input.b),\n\n  health: route.get().handler(() => ({ ok: true })),\n} satisfies RouterRecord;\n\nexport type AppRouter = typeof appRouter;\n```\n\nThe `add` procedure has a numeric input schema, a tool description and one handler. Calling `.ai()` explicitly includes it in the generated tool list.\n\nThe `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.\n\nThe 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.\n\nCreate `app/rpc/[...path]/route.ts`:\n\n``` js\nimport { toNextHandler } from \"dendra\";\nimport { appRouter } from \"../../../server/router\";\n\nexport const runtime = \"nodejs\";\nexport const dynamic = \"force-dynamic\";\n\nexport const { GET, POST } = toNextHandler(appRouter, {\n  prefix: \"/rpc/\",\n  createContext: () => ({}),\n});\n```\n\nStart your Next.js application with its usual development command. With the default port of 3000, call the operation:\n\n```\ncurl http://localhost:3000/rpc/add \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"a\":20,\"b\":22}'\n```\n\nThe response is:\n\n```\n{\"result\":42}\n```\n\nIf you change `a` to the string `\"twenty\"`, input validation rejects the request with HTTP 400. The handler expects numbers on every supported execution path.\n\nCreate `mcp-server.mts` at the project root:\n\n``` js\nimport { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport {\n  CallToolRequestSchema,\n  ListToolsRequestSchema,\n} from \"@modelcontextprotocol/sdk/types.js\";\nimport { createMcpBridge } from \"dendra\";\nimport { appRouter } from \"./server/router\";\n\nconst bridge = createMcpBridge(appRouter, {});\nconst server = new Server(\n  { name: \"dendra-tutorial\", version: \"1.0.0\" },\n  { capabilities: { tools: {} } },\n);\n\nserver.setRequestHandler(ListToolsRequestSchema, async () => ({\n  tools: bridge.listTools(),\n}));\n\nserver.setRequestHandler(CallToolRequestSchema, async (request) => {\n  const result = await bridge.callTool(\n    request.params.name,\n    request.params.arguments,\n  );\n  return { content: result.content, isError: result.isError };\n});\n\nawait server.connect(new StdioServerTransport());\n```\n\n`createMcpBridge` derives the tool definitions from the router and executes calls through Dendra. The official MCP SDK handles the protocol connection over stdio.\n\nThe 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.\n\nThe 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.\n\nCreate `verify-mcp.mts` at the project root:\n\n``` python\nimport assert from \"node:assert/strict\";\nimport { fileURLToPath } from \"node:url\";\nimport { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { StdioClientTransport } from \"@modelcontextprotocol/sdk/client/stdio.js\";\n\nconst transport = new StdioClientTransport({\n  command: process.execPath,\n  args: [\"--import\", \"tsx\", fileURLToPath(new URL(\"./mcp-server.mts\", import.meta.url))],\n});\nconst client = new Client({ name: \"dendra-tutorial-check\", version: \"1.0.0\" });\n\ntry {\n  await client.connect(transport);\n  const tools = (await client.listTools()).tools;\n  assert.deepEqual(tools.map((tool) => tool.name), [\"add\"]);\n  console.log(\"Tools: add\");\n\n  const result = await client.callTool({ name: \"add\", arguments: { a: 20, b: 22 } });\n  assert.notEqual(result.isError, true);\n  assert.deepEqual(result.content, [{ type: \"text\", text: \"42\" }]);\n  console.log(\"Valid call: 42\");\n\n  const invalid = await client.callTool({ name: \"add\", arguments: { a: \"twenty\", b: 22 } });\n  assert.equal(invalid.isError, true);\n  console.log(\"Invalid argument: rejected\");\n\n  const unexposed = await client.callTool({ name: \"health\", arguments: {} });\n  assert.equal(unexposed.isError, true);\n  console.log(\"Unexposed procedure: rejected\");\n} finally {\n  await client.close();\n}\n```\n\nRun it from the project root:\n\n```\nnpx tsx verify-mcp.mts\n```\n\nExpected output:\n\n```\nTools: add\nValid call: 42\nInvalid argument: rejected\nUnexposed procedure: rejected\n```\n\nThis 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.\n\nTo 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.\n\nThe `.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.\n\nDendra 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.\n\nFor 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.\n\nThe 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.\n\nThe 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.\n\nDendra 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.\n\nThe 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.\n\nType 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.\n\n*I created Dendra. AI assisted with drafting and checking this tutorial. The example was verified through executable TypeScript, HTTP and MCP tests.*", "url": "https://wpnews.pro/news/expose-next-js-functions-as-mcp-tools-with-dendra", "canonical_source": "https://dev.to/zhussain/expose-nextjs-functions-as-mcp-tools-with-dendra-2np3", "published_at": "2026-10-19 15:01:43+00:00", "updated_at": "2026-10-19 15:14:38.088092+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "developer-tools", "ai-tools"], "entities": ["Dendra", "Next.js", "Model Context Protocol", "TypeScript", "MCP TypeScript SDK"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/expose-next-js-functions-as-mcp-tools-with-dendra", "markdown": "https://wpnews.pro/news/expose-next-js-functions-as-mcp-tools-with-dendra.md", "text": "https://wpnews.pro/news/expose-next-js-functions-as-mcp-tools-with-dendra.txt", "jsonld": "https://wpnews.pro/news/expose-next-js-functions-as-mcp-tools-with-dendra.jsonld"}}