Fix the Zod handshake bug — build a Notion-ready MCP server in 60 lines

Fix the Zod handshake bug — build a Notion-ready MCP server in 60 lines

Notion's Custom Agent silently kills any MCP server with strict Zod validation by injecting three undocumented non-standard fields during the initialization handshake. A 6-line Express middleware — notionStripMiddleware — strips them before the SDK transport ever parses the request. The article explains why .strip() and .passthrough() alone don't reach the failure point, provides a complete copy-pasteable 60-line server with stateless transport mode, a deployment quick-reference table, two verification signals, and four production gotchas.

Notion Automation Pro Tips
2026/5/30 · 23:26
購読 9 件 · コンテンツ 14 件
Required: Business or Enterprise plan, Node.js ≥ 20. 1
Yesterday's tip covered connecting a custom MCP server to a Notion Custom Agent — and surfaced the silent connection killer: Notion's Agent sends three non-standard fields (nonce, notion_user_id, prompt) during the MCP initialization handshake that aren't part of the official protocol. Any server using strict schema validation rejects them immediately, and Notion's UI shows no useful error. 2
Today's tip is the fix. One Express middleware, 6 lines, placed before the SDK ever touches the request body.
コンテンツカードを読み込んでいます…

Why .strip() or .passthrough() alone won't save you

The natural instinct — change your Zod schema from .strict() to .strip() — doesn't work here. The .strict() call that rejects Notion's extra fields lives inside the MCP TypeScript SDK itself, in JSONRPCRequestSchema within @modelcontextprotocol/core. You can't reach it from your own code. 3
The exact failure point is NodeStreamableHTTPServerTransport.handleRequest()JSONRPCMessageSchema.parse(rawMessage). Notion's extra fields hit .strict() at parse time and the entire message is rejected before your tool logic ever runs.
There are only two layers where you control the bytes: before the parse call, or after. Before is the only viable option. The fix: strip the three extra fields from req.body in an Express middleware before handing the request to the SDK transport.

The 6-line fix

// Place this BEFORE app.post('/mcp', ...)
const NOTION_EXTRA_FIELDS = ['nonce', 'notion_user_id', 'prompt'] as const;

function notionStripMiddleware(req: Request, _res: Response, next: Function) {
  if (req.body && typeof req.body === 'object') {
    for (const field of NOTION_EXTRA_FIELDS) {
      delete (req.body as Record<string, unknown>)[field];
    }
  }
  next();
}
That's the entire workaround. The middleware is a no-op when those fields are absent, so if Notion ever ships a fix on their end, this code becomes inert — it won't break anything.
Middleware strip flow: Notion Agent sends nonce/notion_user_id/prompt → middleware deletes them → MCP SDK parse succeeds
Request pipeline after applying notionStripMiddleware. The three Notion-specific fields are removed before JSONRPCMessageSchema.parse() runs. 2
The fields (nonce and prompt) are OIDC-spec parameters; Supabase's engineering team noted that silently dropping them without documentation gives clients a false impression they were handled. 4 To stay honest about it, log a one-liner when fields are stripped — that gives you a diagnostic signal and makes the workaround visible in your server logs.

Complete copy-pasteable server

The full server below wires up the middleware, registers an example query-dashboard tool, and handles both POST and GET at /mcp. Replace the mock dashboard data with your actual data source.
// server.ts — Notion-compatible MCP server
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import { McpServer } from '@modelcontextprotocol/server';
import type { Request, Response } from 'express';
import * as z from 'zod/v4';

// ── 1. The middleware ──────────────────────────────────────────────────────
const NOTION_EXTRA_FIELDS = ['nonce', 'notion_user_id', 'prompt'] as const;

function notionStripMiddleware(req: Request, _res: Response, next: Function) {
  if (req.body && typeof req.body === 'object') {
    const body = req.body as Record<string, unknown>;
    const stripped = NOTION_EXTRA_FIELDS.filter(f => f in body);
    if (stripped.length) {
      console.log('[notionStrip] removed:', stripped.join(', '));
      for (const field of stripped) delete body[field];
    }
  }
  next();
}

// ── 2. Tool definition ─────────────────────────────────────────────────────
function createServer(): McpServer {
  const server = new McpServer(
    { name: 'pm-dashboard', version: '1.0.0' },
    { capabilities: { logging: {} } }
  );

server.registerTool(
    'query-dashboard',
    {
      title: 'Query Dashboard',
      description: 'Fetch key product metrics',
      inputSchema: z.object({
        metric: z.enum(['dau', 'mau', 'revenue', 'retention', 'nps']),
        period: z.enum(['7d', '30d', '90d']).default('7d'),
      }),
    },
    async ({ metric, period }) => {
      // Replace with your real data source
      const data: Record<string, { value: number; change: number }> = {
        dau: { value: 482000, change: 3.2 },
        mau: { value: 2800000, change: 1.8 },
        revenue: { value: 12400000, change: -0.5 },
        retention: { value: 62.4, change: 0.3 },
        nps: { value: 47, change: 2.1 },
      };
      const d = data[metric] ?? { value: 0, change: 0 };
      return {
        content: [{
          type: 'text',
          text: `${metric.toUpperCase()} (${period}): ${d.value} (${d.change > 0 ? '+' : ''}${d.change}%)`,
        }],
      };
    }
  );

return server;
}

// ── 3. Express wiring ──────────────────────────────────────────────────────
const app = createMcpExpressApp();

app.post('/mcp', notionStripMiddleware, async (req: Request, res: Response) => {
  const server = createServer();
  try {
    const transport = new NodeStreamableHTTPServerTransport({
      sessionIdGenerator: undefined, // stateless — required for Notion
    });
    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
    res.on('close', () => { transport.close(); server.close(); });
  } catch (err) {
    console.error('MCP error:', err);
    if (!res.headersSent) {
      res.status(500).json({
        jsonrpc: '2.0',
        error: { code: -32603, message: 'Internal server error' },
        id: null,
      });
    }
  }
});

// GET /mcp — Notion probes this; return 405 for stateless servers
app.get('/mcp', (_req: Request, res: Response) => {
  res.writeHead(405).end(JSON.stringify({
    jsonrpc: '2.0',
    error: { code: -32000, message: 'Use POST.' },
    id: null,
  }));
});

const PORT = parseInt(process.env.PORT ?? '3000', 10);
app.listen(PORT, () => console.log(`MCP server on http://localhost:${PORT}/mcp`));
process.on('SIGINT', () => process.exit(0));
// package.json
{
  "name": "notion-mcp-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": { "start": "tsx server.ts", "dev": "tsx watch server.ts" },
  "dependencies": {
    "@modelcontextprotocol/express": "^2.0.0-alpha.2",
    "@modelcontextprotocol/node": "^2.0.0-alpha.2",
    "@modelcontextprotocol/server": "^2.0.0-alpha.2",
    "express": "^4.21.0",
    "zod": "^4.0.0"
  },
  "devDependencies": { "tsx": "^4.16.0", "typescript": "^5.9.0" }
}
The server uses stateless mode (sessionIdGenerator: undefined). Each POST creates a fresh transport instance — Notion's agent doesn't maintain session IDs between calls, so stateful mode would cause every request after the first to fail. 5

Deploying and connecting to Notion

Notion's Custom Agent won't reach localhost. The server needs a public HTTPS URL. 1 Quick options:
Deployment pathTime to live URLCost
Railway / Render~3 minutesFree tier available
Fly.io~5 minutesFree tier available
Cloudflare Tunnel~2 minutesFree (proxies localhost)
Once deployed, connect it to your Custom Agent: Settings → Tools & Access → Add connection → Custom MCP server → paste https://your-server.com/mcp. Use header-based auth (API key / bearer token) rather than OAuth — Dynamic Client Registration is not supported by Notion Custom Agents, so OAuth-only servers require Notion to pre-register a client application on their end before they'll connect. 1

Verifying it worked

After saving the connection, check two signals:
  1. Tool enumeration: Expand the MCP connection entry in the agent's settings. If query-dashboard appears in the tool list, the handshake completed — the middleware worked.
  2. Log confirmation: Your server console should show [notionStrip] removed: nonce, notion_user_id, prompt on the first request. If that line appears, the three fields were present and stripped correctly.
Notion Custom Agent Tools &amp; Access panel showing pm-dashboard server connected with query-dashboard tool registered and set to run automatically
Successful connection: the agent's Tools & Access panel enumerates the tools served by your MCP endpoint. A green "Connected" status and the tool chip appearing confirms the handshake passed. 1
If the connection still fails, run through this order: HTTPS reachable → Business/Enterprise plan confirmed → server log shows the strip line → SDK version is 2.0.0-alpha.2.

4 gotchas specific to this setup

SDK v2 is pre-alpha. The @modelcontextprotocol/server package README states: "This is the main branch which contains v2 of the SDK (currently in development, pre-alpha)." 3 For production workloads, pin to a specific alpha tag and test upgrades before deploying, or use v1.x. 3
Notion may add more non-standard fields. Only nonce, notion_user_id, and prompt are confirmed as of May 30, 2026 — they haven't been formally documented anywhere in Notion's help center. 1 The stripped.length log line in notionStripMiddleware will surface any future additions before they silently break the connection.
No community-verified working example exists yet. As of May 30, 2026, a search across GitHub (258+ issues mentioning Notion + MCP), Reddit, and Google found no independently verified custom MCP server running successfully inside Notion Custom Agents. 2 Supabase's MCP server remains blocked by a separate HTTP status code issue (returns 201 where Notion expects 200, fix scheduled for June 1, 2026). 6 This pattern is early-adopter territory.
OAuth token expiry is a separate pain. If you choose OAuth auth instead of an API key, expect re-authentication every 1–2 days — GitHub issue #225 has users reporting token lifetimes as short as 30–90 minutes, with no silent refresh mechanism. 7 API key auth sidesteps this entirely.
Cover image: AI-generated illustration

このコンテンツについて、さらに観点や背景を補足しましょう。

  • ログインするとコメントできます。