Expose Next.js operations as MCP tools with Dendra, using the same input validation and application logic as your API.
Your application already knows how to look up a record, create a task or save a note. An AI assistant needs a way to discover those operations and call them with the right arguments.
I built Dendra, an open source TypeScript framework, to keep that definition in one place. You define a procedure, describe its input and explicitly enable it as an AI tool with .ai().
This walkthrough uses Dendra's existing Next.js example. The application already includes API routes and an HTTP MCP endpoint. We will follow a note from an MCP request into the application, then read the same note through its API.
Start with the Dendra Next.js example
Use a current Node.js 22 LTS release, npm and Git. Clone the repository into a new directory:
git clone https://github.com/zeestack/dendra.git
cd dendra/examples/nextjs-app
npm install
npm install dendra@0.7.1
npm run dev -- --hostname 127.0.0.1
Open http://127.0.0.1:3100/demo to explore the example. Keep the development server running while you try the requests below.
npm install installs the example application's declared dependencies. The next command pins Dendra to the version used for this walkthrough. Dendra's core has no required runtime dependencies; the complete sample application also uses packages for its database, interface and integrations.
Use this as a local development exercise. The example's identity and OAuth flows are demonstrations; configure real authentication and authorization before exposing it publicly.
Define an operation once
Open server/router.ts. The existing notes.create procedure contains the input schema, tool description and application handler:
create: route
.post("/notes")
.input(s.object({ title: s.string(), body: s.string() }))
.ai({ description: "Create a note with a title and body" })
.handler(async ({ input, ctx }): Promise<Note> => {
const note = await ctx.db.notes.insert(ctx.tenantId, ctx.userId, input);
ctx.pubsub.publish(`notes:${ctx.tenantId}`, {
type: "created",
note,
} satisfies NoteEvent);
return note;
}),
This is an excerpt from the existing router, not a replacement for the file. Its imports and context are already provided by the example.
The definition does four things:
- Declares a POST operation for the REST adapter.
- Requires a string
titleandbody. - Makes the procedure available to the AI tool interface through
.ai(). - Saves the note in the current workspace and publishes its creation event.
The same handler serves the application API and tool execution. There is no second copy of the note creation logic to maintain.
Follow the existing routes
The example mounts the router in these files:
| File | Purpose |
|---|---|
server/router.ts |
Defines procedures, schemas and AI exposure |
app/rpc/[...path]/route.ts |
Exposes procedures through Dendra's Next.js RPC adapter |
app/api/mcp/route.ts |
Handles MCP requests using Dendra's tool definitions and executor |
server/context.ts |
Supplies workspace, user and application services |
The MCP route imports its tool functions directly from Dendra:
import { mcpToolDefinitions, createToolExecutor } from "dendra";
mcpToolDefinitions(appRouter) derives the available tools from procedures marked with .ai(). createToolExecutor(appRouter, ctx) executes those procedures using their input validation and the supplied application context.
The example's MCP route maps dotted procedure names to names such as notes_create and notes_list. Those are the names to use in the requests below.
Try the MCP endpoint
The following commands use a second terminal. They call the local example at /api/mcp. No model subscription or AI provider key is needed for these note operations.
First, initialize the MCP connection:
curl http://127.0.0.1:3100/api/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"dendra-tutorial","version":"1.0.0"}}}'
The response identifies the example server and its tool capability. Send the initialization notification:
curl -i http://127.0.0.1:3100/api/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
This example returns HTTP 202 with no response body for that notification. Its HTTP endpoint is stateless, so these requests do not require a session identifier.
List the available tools:
curl http://127.0.0.1:3100/api/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
The list includes notes_create and notes_list, with their descriptions and input schemas. It excludes procedures without .ai(), such as the example's connector management operations.
Create a note through MCP:
curl http://127.0.0.1:3100/api/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"notes_create","arguments":{"title":"One procedure, two interfaces","body":"Created through Dendra MCP."}}}'
The response contains the saved note, including its generated ID. Now retrieve the notes through the application's RPC endpoint:
curl http://127.0.0.1:3100/rpc/notes.list
The same note should appear under result. Both requests use the example's default workspace. This demonstrates that the MCP route and application API operate on the same application data.
Check the validation boundary
Repeat the notes_create call with a numeric title:
curl http://127.0.0.1:3100/api/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"notes_create","arguments":{"title":123,"body":"This should be rejected."}}}'
The tool result should contain isError: true. The input schema rejects the invalid title before the handler creates a note. For this tool failure, inspect the JSON result rather than relying on HTTP status alone.
Use it in an application
An MCP compatible client can connect to the HTTP endpoint when it supports this transport. The repository also includes a local desktop integration example. Use the connection settings appropriate to your client and deployment.
Adding .ai() selects which procedures become tools. Your application still supplies the authenticated user context and decides what each user may read or change. Treat the example's workspace headers and demo login behavior as development scaffolding, not production access controls.
For your own operation, keep its schema, description and handler together in the router. Then let Dendra's adapters reuse that definition across the application and tool interfaces.
Verification
Checked on 19 October 2026 against the Next.js example at Dendra revision 5a424f0, using Dendra 0.7.1 and Node.js 22.22.3. The requests above passed against the running local application: initialization, tool listing, note creation, reading the same note through RPC and invalid input rejection. A direct attempt to call an unexposed operation was also rejected. These checks do not certify a production deployment or every MCP client.
References
I created Dendra. AI assisted with drafting and checking this tutorial.
Top comments (0)