


























Break free from hardcoded integrations and empower your AI assistant to manage your Notion workspace dynamically using TypeScript, the MCP SDK, and standard I/O.
As AI agents transition from simple conversational assistants to active partners in our development workflows, a critical bottleneck has emerged: how do they interact with our data?
Every application has a custom API, and every model vendor expects functions to be defined in a specific format. Writing custom glue code for every tool-agent combination is slow, fragile, and doesn't scale.
Enter the Model Context Protocol (MCP), an open standard introduced by Anthropic. MCP defines a uniform protocol for AI clients (like Claude Desktop, Cursor, or VS Code) to securely connect to external tools, databases, and APIs.
In this article, we'll walk through how we built Notion Sprint Manager, a custom MCP server written in TypeScript. This server allows Claude to read, create, and update development tasks in your Notion workspace. More importantly, we’ll explain how we solved one of the most frustrating aspects of building Notion integrations: handling custom database schemas dynamically.
🔗 GitHub Repository: JMedina255/mcp-notion-server
🔗MCP Protocol Resources: Anthropic MCP Documentation
stdioUnlike traditional web-based integrations that rely on persistent WebSockets or polling HTTP endpoints, local MCP servers communicate with AI clients via simple standard input and output (stdio).
Communication Flow Diagram
Here's the step-by-step communication flow:
get_active_tasks tool.stdin).stdout).
⚠️ The Golden Rule of Stdio MCP Servers: Because the JSON-RPC messages flow through stdout, any console log statement (like console.log("Server started!")) will corrupt the stream and crash the connection. All diagnostic logs, warnings, and errors must be explicitly redirected to console.error, which prints to stderr and is safely captured by Claude's debug logs.
Most Notion integration tutorials assume a rigid database structure: a text property named exactly Name, a select property named exactly Status, and a date property named exactly Due Date.
But in the real world, workspaces are highly customized. Teams change property names (e.g., calling the task title Task Name or Título, renaming status to State or estado, or prioritizing using custom options). Writing an integration with hardcoded property names means that the moment a user tweaks their Notion workspace, the integration breaks.
To solve this, our MCP server implements Dynamic Schema Discovery.
At the start of every tool execution, the server calls resolveDatabaseProperties. This helper retrieves the database's metadata from Notion and inspects its schema at runtime.
By analyzing the property types and searching for common naming patterns, the server dynamically locates the appropriate columns:
async function resolveDatabaseProperties(dbId: string) {
const db = await notion.databases.retrieve({ database_id: dbId }) as any;
const props = db.properties || {};
// 1. Locate the Title property (always has type 'title')
const nameKey = Object.keys(props).find(
key => props[key].type === 'title'
) || 'Name';
// 2. Locate Status (type 'status' or a select property named 'status' or 'estado')
const statusKey = Object.keys(props).find(
key => props[key].type === 'status'
) || Object.keys(props).find(
key => (key.toLowerCase() === 'status' || key.toLowerCase() === 'estado') && props[key].type === 'select'
) || 'Status';
const statusType = props[statusKey]?.type || 'status';
// 3. Locate Priority (select or status property named priority/prioridad)
const priorityKey = Object.keys(props).find(
key => (key.toLowerCase() === 'priority' || key.toLowerCase() === 'prioridad') &&
(props[key].type === 'select' || props[key].type === 'status')
) || Object.keys(props).find(
key => props[key].type === 'select' &&
Array.isArray(props[key].select?.options) &&
props[key].select.options.some((opt: any) =>
['high', 'medium', 'low', 'alta', 'media', 'baja'].includes(opt.name.toLowerCase())
)
) || 'Priority';
const priorityType = props[priorityKey]?.type || 'select';
// 4. Locate Due Date (a date property, prioritizing names like due date, fecha, entrega)
const dueDateKey = Object.keys(props).find(
key => props[key].type === 'date' &&
['due date', 'due_date', 'fecha', 'vencimiento', 'entrega', 'fecha de entrega', 'due'].some(
term => key.toLowerCase().includes(term)
)
) || Object.keys(props).find(
key => props[key].type === 'date'
) || 'Due Date';
return { nameKey, statusKey, statusType, priorityKey, priorityType, dueDateKey };
}
By mapping the columns dynamically, the server can adapt on the fly:
"estado" and is a Select property, it resolves it.keys.statusKey, keys.priorityKey) and types (keys.statusType), avoiding validation schema errors.We initialize the MCP server using the official @modelcontextprotocol/sdk. We define two main interaction schemas:
ListToolsRequestSchema: Declares our tools and their parameters to the AI.CallToolRequestSchema: Executes the requested tool logic.Here is how we expose the add_task_to_backlog tool:
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { Client } from '@notionhq/client';
const server = new Server(
{ name: 'notion-sprint-manager', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
// Register list of tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'add_task_to_backlog',
description: 'Add a new task to the backlog in the Notion database.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'The name/title of the task.' },
status: { type: 'string', description: 'The status of the task (e.g. Backlog, Todo, In Progress).' },
priority: { type: 'string', description: 'The priority of the task (e.g. High, Medium, Low).' },
due_date: { type: 'string', description: 'The due date in YYYY-MM-DD format.' },
},
required: ['name'],
},
},
// ...other tools like get_active_tasks and update_task_status
],
};
});
When creating a new page in Notion, the server dynamically structures the database parameters based on the detected types. If the status field is a "status" property, it formats it as { status: { name: value } }. If it's a "select" property, it shifts to { select: { name: value } }.
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const keys = await resolveDatabaseProperties(databaseId);
if (name === 'add_task_to_backlog') {
const taskArgs = args as { name: string; status?: string; priority?: string; due_date?: string };
const properties: Record<string, any> = {
[keys.nameKey]: {
title: [{ text: { content: taskArgs.name } }],
},
};
// Dynamically choose between 'status' and 'select' properties
if (taskArgs.status) {
properties[keys.statusKey] = keys.statusType === 'status'
? { status: { name: taskArgs.status } }
: { select: { name: taskArgs.status } };
}
if (taskArgs.priority) {
properties[keys.priorityKey] = keys.priorityType === 'status'
? { status: { name: taskArgs.priority } }
: { select: { name: taskArgs.priority } };
}
if (taskArgs.due_date) {
properties[keys.dueDateKey] = {
date: { start: taskArgs.due_date },
};
}
const newPage = await notion.pages.create({
parent: { database_id: databaseId },
properties,
});
return {
content: [{ type: 'text', text: `Successfully created task "${taskArgs.name}" with ID: ${newPage.id}` }],
};
}
// ...other tools handler
});
Want to try this out yourself? Here's how to spin it up in 5 minutes.
First, clone the repository and install the project dependencies:
git clone https://github.com/JMedina255/mcp-notion-server.git
cd mcp-notion-server
npm install
Create a .env file in the root of the directory:
NOTION_TOKEN=your_notion_integration_token_here
NOTION_DATABASE_ID=your_notion_database_id_here
💡 Need a token? Go to notion.so/my-integrations to create an integration. Make sure you share the target database with your integration in the Notion UI!
Compile the TypeScript code to optimized JavaScript:
bashnpm run build
This output is saved to dist/index.js.
To tell Claude Desktop where to find our server, we must register it in the configuration file.
Depending on your installation type on Windows, open the config folder:
%APPDATA%\Claude\claude_desktop_config.json%LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\claude_desktop_config.jsonAdd your server config under the "mcpServers" object. Make sure to use absolute paths and forward slashes / (or escaped double-backslashes \\) to prevent JSON parsing issues on Windows:
{
"mcpServers": {
"notion-sprint-manager": {
"command": "node",
"args": [
"C:/Users/YourUsername/Desktop/mcp-notion-server/dist/index.js"
],
"env": {
"NOTION_TOKEN": "ntn_your_actual_token_here",
"NOTION_DATABASE_ID": "your_database_uuid_here"
}
}
}
}
Note: If Claude fails to find the node environment variable, replace "command": "node" with the absolute path to your node executable, e.g. "C:/Program Files/nodejs/node.exe".
Restart Claude Desktop completely (quit from the system tray and open it again), and you will see a plug icon 🔌 appear in the chat text box, signaling that the server is successfully connected!
Once connected, you can interact with your Notion workspace in natural language. Try prompts like:
Claude will translate your intent into structured JSON-RPC calls, send them to the server, invoke the Notion API, and give you a natural confirmation directly in the chat interface.
While running the MCP server locally via standard I/O is incredible for local setups and prototyping, you might want to share this server with your team or run it on cloud IDEs.
MCP supports remote connections using Server-Sent Events (SSE). You can easily containerize this Node.js application and deploy it to:
By deploying the server remotely, your custom AI agents running in the cloud can retrieve live data from Notion just as easily as they did on your local machine.
The Model Context Protocol makes AI tool integration structured and standard. By decoupling the interface from hardcoded schemas and resolving properties dynamically, our Notion Sprint Manager server remains resilient to changes, providing a robust workflow assistant for developers.
Try cloning the repo, hook it up to your workspace, and let us know what you think! 🚀
Have any questions, or built a cool extension? Open an issue or submit a PR on GitHub.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。