DEV Community

Cover image for Test MCP workspace isolation with Next.js and Dendra
Zahid Hussain
Zahid Hussain

Posted on Fully Autonomous

Test MCP workspace isolation with Next.js and Dendra

Sharing a handler between your application and an AI tool removes duplicated logic. It does not, by itself, prove that the caller can only access the right workspace.

After my first Dendra tutorial, a reader asked a useful question: could an MCP connection for workspace A read or create data in workspace B?

The Next.js notes example now has regression tests for that boundary. This article walks through the existing application, the credential mapping, and a few requests you can run yourself.

I built Dendra, an open source TypeScript framework. This walkthrough uses Dendra 0.9.0 and the updated example on main. The example changes came after the 0.9.0 package release; they are not a new framework release.

What the notes example demonstrates

The Examples documentation has the setup instructions and a guide to the application. Its local /demo page contains interactive cards covering RPC, REST, validation, streaming, subscriptions, tools, and other integrations.

For this exercise, we will use two interfaces to the same notes procedures:

Interface Endpoint Purpose
MCP /api/mcp Create and list notes through named tools
RPC /rpc/notes.list Read the same application data

The procedures live in server/router.ts. The data layer filters note reads and deletes by tenantId, and stamps new notes with the tenant and user from the context. Notes are shared within a workspace; they are not private to their creator.

The important question is where that context gets its identity.

Keep this exercise local. The example still includes demo browser tenancy and an OAuth flow that automatically approves a demo user. These checks demonstrate credential binding, not a complete production authorization system.

Run the existing application

Use a current Node.js 22 LTS release, npm, Git, and curl. The checked version was Node 22.22.3. You do not need an AI provider key for the note operations below.

git clone https://github.com/zeestack/dendra.git
cd dendra
git checkout 7e41296f982d27673ef60c329b68bfba0dd38ba1
cd examples/nextjs-app
npm ci
npm test
Enter fullscreen mode Exit fullscreen mode

The checkout pins the example used here. It includes the credential changes and the nine isolation tests. npm ci installs the example's declared dependencies; there is no separate MCP SDK setup step for this walkthrough.

Start the app with two local test credentials:

MCP_TOKENS='acme:alice:local-acme-token,beta:bob:local-beta-token' \
  npm run dev -- --hostname 127.0.0.1
Enter fullscreen mode Exit fullscreen mode

These strings are disposable demonstration credentials. Do not reuse them for a deployment.

Open http://127.0.0.1:3100/demo to explore the interface. Keep the server running and use a second terminal for the following requests.

Let the credential select the workspace

The app's MCP_TOKENS setting maps each credential to exactly one workspace and user:

Credential Workspace User
local-acme-token acme alice
local-beta-token beta bob

server/credentials.ts resolves that mapping. When a request supplies a valid bearer token, createContext uses its principal for both the tenant and user. The scoped memory store is then constructed from that same identity.

A caller cannot override a valid token's identity by supplying a different x-tenant, x-user, or hostname. Dendra's .ai() chooses which procedures are exposed as tools; the application's context and database queries enforce the workspace boundary.

Create a note through MCP

First initialize the example's MCP endpoint:

curl -sS http://127.0.0.1:3100/api/mcp \
  -H 'Authorization: Bearer local-acme-token' \
  -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":"workspace-check","version":"1.0.0"}}}'

curl -i http://127.0.0.1:3100/api/mcp \
  -H 'Authorization: Bearer local-acme-token' \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
Enter fullscreen mode Exit fullscreen mode

The notification returns HTTP 202. This example's HTTP endpoint is stateless, so these requests do not need a session identifier.

Create a note as Alice in Acme:

curl -sS http://127.0.0.1:3100/api/mcp \
  -H 'Authorization: Bearer local-acme-token' \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"notes_create","arguments":{"title":"Acme isolation check","body":"This note belongs to Acme."}}}'
Enter fullscreen mode Exit fullscreen mode

The note is encoded as JSON inside result.content[0].text. It should contain tenantId: "acme" and userId: "alice". Save its generated id for the delete check below.

Now read it through RPC using the same credential:

curl -sS http://127.0.0.1:3100/rpc/notes.list \
  -H 'Authorization: Bearer local-acme-token'
Enter fullscreen mode Exit fullscreen mode

The note should appear in result. The MCP operation and application API are using the same stored data.

Try the other workspace's credential

Repeat the RPC read as Bob:

curl -sS http://127.0.0.1:3100/rpc/notes.list \
  -H 'Authorization: Bearer local-beta-token'
Enter fullscreen mode Exit fullscreen mode

Acme's note must not appear. In a fresh example database, Beta's result is empty.

Now use Beta's token through MCP while claiming to be Acme in the headers:

curl -sS http://127.0.0.1:3100/api/mcp \
  -H 'Authorization: Bearer local-beta-token' \
  -H 'Content-Type: application/json' \
  -H 'Host: acme.localhost:3100' \
  -H 'x-tenant: acme' \
  -H 'x-user: alice' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"notes_list","arguments":{}}}'
Enter fullscreen mode Exit fullscreen mode

The note must still be absent. The token maps to Beta, regardless of those headers. The automated suite also checks this spoofing attempt through RPC and verifies that a spoofed create request still writes to Beta.

Check a rejected write

Knowing a record's ID should not grant permission to delete it. Replace ACME_NOTE_ID with the ID returned earlier:

curl -sS http://127.0.0.1:3100/api/mcp \
  -H 'Authorization: Bearer local-beta-token' \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"notes_delete","arguments":{"id":"ACME_NOTE_ID"}}}'
Enter fullscreen mode Exit fullscreen mode

Expect result.isError: true and a not found message. The HTTP status can still be 200: this is an MCP tool execution error, so inspect the JSON result rather than relying on curl's exit code.

Run the Acme RPC read again. Its note should still exist. That final read verifies that the rejected operation did not remove it.

What the regression suite covers

The test file exercises the real Next.js route functions against a temporary SQLite database.

Check Expected result
Create through MCP Tenant and user come from the credential
Read through MCP and RPC Acme sees its note; Beta does not
Delete another workspace's note through MCP Rejected; the note survives
Create as Beta, including spoofed headers The write belongs to Beta
OAuth registration, authorization, and token exchange The token retains the workspace selected during authorization
Missing or unknown MCP credential HTTP 401 when MCP authentication is configured
Unknown credential on RPC HTTP 401
Only the old server MCP_TOKEN setting is present Migration error instead of an unprotected MCP endpoint

There are nine tests because some rows above summarize more than one test. They invoke handlers in process; they do not launch a browser or exercise every network transport.

For this walkthrough, all nine tests passed on Node 22.22.3. The create, read, spoofed-header, and rejected-delete requests were also checked against the running Next.js application.

The plural MCP_TOKENS is the app's server configuration. The singular MCP_TOKEN is still used by the separate stdio client. They serve different purposes.

The boundary this does not prove

The example still permits requests without a bearer token on browser and RPC paths to use demo subdomain tenancy. Its OAuth authorization route also auto-approves a fixed user without a real login or membership check.

Before using this pattern in a deployed application, authenticate the caller, verify their workspace membership, and apply that policy consistently across every entry point. Replace the demonstration credential and OAuth stores with your application's identity system.

The useful result here is narrower and testable: a supplied valid credential keeps its workspace identity across the covered MCP and RPC operations, and the regression suite checks that attempts to override it fail.

For the complete application and local demo instructions, start with the Dendra Examples documentation. The repository contains the implementation and tests so you can inspect the boundary yourself.

Disclosure: I created Dendra. This walkthrough was drafted and checked with an AI coding agent against the pinned example.

Top comments (0)