# Directory Structure
```
├── .eslintrc.json
├── .github
│   └── renovate.json5
├── .gitignore
├── .prettierrc.json
├── image
│   ├── prompts.png
│   ├── resources.png
│   └── tools.png
├── package-lock.json
├── package.json
├── pnpm-lock.yaml
├── README.md
├── src
│   ├── gateway.ts
│   ├── index.ts
│   ├── prompts
│   │   └── index.ts
│   ├── resources
│   │   └── index.ts
│   └── tools
│       └── index.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
node_modules/
build/
*.log
.env*
```
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
```json
{
  "singleQuote": true,
  "semi": false,
  "tabWidth": 2,
  "printWidth": 125,
  "endOfLine": "lf",
  "bracketSameLine": true,
  "arrowParens": "avoid"
}
```
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
```json
{
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:prettier/recommended",
    "plugin:import/typescript",
    "plugin:import/recommended",
    "plugin:import/typescript"
  ],
  "settings": { "react": { "version": "detect" } },
  "parser": "@typescript-eslint/parser",
  "parserOptions": { "parser": "@babel/eslint-parser", "requireConfigFile": false },
  "globals": { "process": true, "document": true, "window": true, "global": true },
  "env": { "browser": true, "node": true },
  "rules": {
    // Import Rules
    "import/no-cycle": ["warn", { "maxDepth": "∞" }],
    "import/no-unresolved": "off",
    "import/export": "off",
    // TS Rules
    "@typescript-eslint/no-empty-function": "off",
    "@typescript-eslint/no-explicit-any": "warn",
    "@typescript-eslint/explicit-module-boundary-types": "off",
    "@typescript-eslint/no-this-alias": "off",
    "@typescript-eslint/ban-types": "off",
    "@typescript-eslint/no-var-requires": "off",
    "@typescript-eslint/no-inferrable-types": "off",
    "@typescript-eslint/no-unused-vars": "error",
    "@typescript-eslint/no-empty-interface": "off",
    "@typescript-eslint/method-signature-style": "error",
    "@typescript-eslint/ban-ts-comment": "off",
    "@typescript-eslint/naming-convention": [
      "error",
      { "selector": "variableLike", "format": ["camelCase", "PascalCase", "UPPER_CASE"] },
      {
        "selector": "memberLike",
        "format": ["camelCase", "PascalCase", "snake_case", "UPPER_CASE"],
        "filter": {
          "regex": "/.*|@.*|[a-z|-|~|@].*",
          "match": false
        }
      },
      { "selector": "variable", "format": ["camelCase", "PascalCase", "UPPER_CASE"] },
      { "selector": "parameter", "format": ["camelCase", "PascalCase"], "leadingUnderscore": "allow" },
      { "selector": "typeLike", "format": ["PascalCase"] }
    ],
    // Common Rules
    "no-self-assign": "off",
    "no-constant-condition": "off",
    "no-unused-vars": "warn",
    "curly": ["error", "multi", "consistent"],
    "no-console": ["error", { "allow": ["warn", "error", "debug", "info", "groupCollapsed", "groupEnd"] }]
  },
  "ignorePatterns": ["**/*.js", "**/*.test.ts"]
}
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# Browse your entire Notion workspace, not just one database
Markdown based Notion navigating MCP with just a single `ROOT_PAGE` variable, eliminating the need for a token.
- Notion MCP Server: `notion-texonom`
- Notion pages are converted into `text/markdown` mimeType notes.
- Search and retrieve relevant pages based on graph distance, considering parent-child and reference relationships.
A Model Context Protocol (MCP) server for managing and interacting with Notion-based notes. This TypeScript-based server demonstrates MCP concepts by integrating resources, tools, and prompts to interact with Notion pages efficiently.
## Features
### Resources
<img width="768" alt="Resources Inspection" src="image/resources.png">
- **Access Notes**: List and retrieve Notion pages as `note://` URIs with UUID slugs.
- **Metadata**: Each resource includes a title, description, and content in Markdown format.
- **Mime Types**: Content is accessible in `text/markdown` format.
### Tools
<img width="768" alt="Tools Inspection" src="image/tools.png">
- **Search Notes**: Use the `search_notes` tool to search for Notion pages using a query string.
  - Input: Query text to filter relevant pages.
  - Output: Markdown content of matching notes.
### Prompts
<img width="768" alt="Prompts Inspection" src="image/prompts.png">
- **Summarize Notes**: Generate summaries for individual Notion pages.
  - Available Prompts:
    - `summarize_note`: Summarize a specific note by URI.
    - `suggest_refactor`: Propose structural improvements.
    - `suggest_fix`: Identify potential fixes for note content.
    - `suggest_enhance`: Recommend enhancements to improve the note.
  - Input: Notion page URI.
  - Output: Structured messages for summarization and enhancement.
## Development
### Setup
Install dependencies:
```bash
pnpm install
```
Build the project:
```bash
pnpm build
```
For development with auto-rebuild:
```bash
pnpm watch
```
## Configuration
To configure the server with Notion:
- Set environment variables:
  - `ROOT_PAGE`: The root page ID of your Notion workspace.
## Installation for Claude Desktop
To use this server with Claude Desktop, add the configuration:
- MacOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
- Windows: `%APPDATA%/Claude/claude_desktop_config.json`
Example configuration:
```json
{
  "mcpServers": {
    "notion-texonom": {
      "command": "node",
      "args": [
        "/path/to/mcp/build/index.js"
      ],
      "env": {
        "ROOT_PAGE": "$SOME_UUID"
      }
    }
  }
}
```
### Debugging
For troubleshooting and debugging the MCP server, use the MCP Inspector. To start the Inspector, run:
```bash
pnpm inspector
```
The Inspector provides a browser-based interface for inspecting stdio-based server communication.
## Key Technologies
- Notion Integration: Powered by `@texonom/nclient` and `@texonom/cli.`
- MCP SDK: Implements `@modelcontextprotocol/sdk` for server operations.
## Remote Deployment
The server now uses `SSEServerTransport` for remote communication, enabling shared usage of the server. Ensure that the necessary dependencies are installed and the server is configured correctly for remote deployment.
## Usage Instructions
To run the server with `SSEServerTransport`, use the following command:
```bash
npx -y supergateway --port 8000 --stdio "npx -y @modelcontextprotocol/server-filesystem /some/folder"
```
Make sure to replace `/some/folder` with the appropriate path to your folder.
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}
```
--------------------------------------------------------------------------------
/.github/renovate.json5:
--------------------------------------------------------------------------------
```
{
  schedule: 'after 12pm on Friday',
  extends: ['config:base', 'group:allNonMajor'],
  labels: ['dependencies'],
  pin: false,
  rangeStrategy: 'bump',
  packageRules: [{ depTypeList: ['peerDependencies'], enabled: false }],
  dependencyDashboardTitle: "deps: renovate dependency dashboard",
  branchPrefix: "deps/",
  commitMessagePrefix: "deps:"
}
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
{
  "name": "mcp",
  "version": "0.1.0",
  "description": "Texonom's Model Context Protocol server interface for AI retrieval",
  "private": true,
  "type": "module",
  "bin": {
    "mcp": "./build/gateway.js"
  },
  "files": [
    "build"
  ],
  "scripts": {
    "build": "tsc && node -e \"require('fs').chmodSync('build/gateway.js', '755')\"",
    "watch": "tsc --watch",
    "inspector": "npx @modelcontextprotocol/inspector src/index.ts",
    "lint": "eslint --cache \"**/*.{ts,tsx}\" --fix --ignore-path .gitignore",
    "format": "prettier --cache \"**/*.{js,json,md,css,js,ts,tsx}\" --write --ignore-path .gitignore"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "0.7.0",
    "@texonom/cli": "^1.4.4",
    "@texonom/nclient": "^1.4.4",
    "@texonom/ntypes": "^1.4.4",
    "@texonom/nutils": "^1.4.4",
    "@types/body-parser": "^1.19.5",
    "@types/express": "^5.0.0",
    "body-parser": "^1.20.3",
    "cors": "^2.8.5",
    "eventsource": "^2.0.2",
    "express": "^4.21.2"
  },
  "devDependencies": {
    "@babel/eslint-parser": "^7.26.5",
    "@types/cors": "^2.8.17",
    "@types/eventsource": "^1.1.15",
    "@types/node": "^20.11.24",
    "@typescript-eslint/eslint-plugin": "^8.19.1",
    "@typescript-eslint/parser": "^8.19.1",
    "eslint": "^9.18.0",
    "eslint-config-prettier": "^9.1.0",
    "eslint-plugin-import": "^2.31.0",
    "prettier": "^3.4.2",
    "typescript": "^5.3.3",
    "vitest": "^2.1.8"
  }
}
```
--------------------------------------------------------------------------------
/src/tools/index.ts:
--------------------------------------------------------------------------------
```typescript
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import type { Server } from '@modelcontextprotocol/sdk/server/index.js'
import type { NotionAPI } from '@texonom/nclient'
import type { NotionExporter } from '@texonom/cli'
import type { ExtendedRecordMap } from '@texonom/ntypes'
export function setListTools(server: Server) {
  server.setRequestHandler(ListToolsRequestSchema, async () => {
    return {
      tools: [
        {
          name: 'search_notes',
          description: 'Search notes by a query',
          inputSchema: {
            type: 'object',
            properties: {
              query: {
                type: 'string',
                description: 'Query to search',
              },
            },
            required: ['query'],
          },
        },
      ],
    }
  })
}
export function setCallTool(server: Server, client: NotionAPI, exporter: NotionExporter) {
  server.setRequestHandler(CallToolRequestSchema, async request => {
    switch (request.params.name) {
      case 'search_notes': {
        const query = String(request.params.arguments?.query)
        if (!query) throw new Error('Query is required')
        const response = await client.search({
          query,
          ancestorId: process.env.ROOT_PAGE as string,
          filters: {
            isDeletedOnly: false,
            excludeTemplates: true,
            navigableBlockContentOnly: true,
            requireEditPermissions: false,
          },
        })
        const { results, recordMap } = response
        const filteredResults = results.filter(result => {
          const block = recordMap.block[result.id]?.value
          return block && block.type === 'page' && !block.is_template
        })
        return {
          content: filteredResults.map(result => {
            return {
              type: 'text',
              // @ts-ignore
              text: result.highlight.title
            }
          }),
        }
      }
      default:
        throw new Error('Unknown tool')
    }
  })
}
```
--------------------------------------------------------------------------------
/src/prompts/index.ts:
--------------------------------------------------------------------------------
```typescript
import { ListPromptsRequestSchema, GetPromptRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { parsePageId } from '@texonom/nutils'
import type { Server } from '@modelcontextprotocol/sdk/server/index.js'
import type { NotionAPI } from '@texonom/nclient'
import type { NotionExporter } from '@texonom/cli'
const prompts = ['summarize_note', 'suggest_refactor', 'suggest_fix', 'suggest_enhance']
export function setListPrompts(server: Server) {
  server.setRequestHandler(ListPromptsRequestSchema, async () => {
    return {
      prompts: [
        {
          name: 'summarize_note',
          description: 'Summarize the given note',
          arguments: [
            {
              name: 'uri',
              description: 'The URI of the note to summarize',
              required: true,
            },
          ]
        },
        {
          name: 'suggest_refactor',
          description: 'Suggest refactoring the structure of children classification',
        },
        {
          name: 'suggest_fix',
          description: 'Suggest some fixes of the content of the givent note',
        },
        {
          name: 'suggest_enhance',
          description: 'Suggest some enhancements of the content of the givent note',
        },
      ],
    }
  })
}
export function setGetPrompt(server: Server, client: NotionAPI, exporter: NotionExporter) {
  server.setRequestHandler(GetPromptRequestSchema, async request => {
    if (!prompts.includes(request.params.name)) 
      throw new Error('Unknown prompt')
    const uri = String(request.params.arguments?.uri)
    const id = parsePageId(uri)
    if (!id) throw new Error(`Note ${uri} not found`)
    const recordMap = await client.getPage(id)
    if (!recordMap) throw new Error(`Record Map ${id} not found`)
    const md = await exporter.pageToMarkdown(id, recordMap)
    return {
      messages: [
        {
          role: 'user',
          content: {
            type: 'text',
            text: 'Summarize the given note:',
          },
        },
        {
          role: 'user',
          content: {
            type: 'text',
            text: md
          },
        },
        {
          role: 'user',
          content: {
            type: 'text',
            text: 'Provide a concise summary of the note above.',
          },
        },
      ],
    }
  })
}
```
--------------------------------------------------------------------------------
/src/gateway.ts:
--------------------------------------------------------------------------------
```typescript
/**
 * This code enables the Claude Desktop App to communicate with the MCP SSE Transport server since Claude Desktop only supports STD in/out Transport only.
 * This source code is from https://github.com/boilingdata/mcp-server-and-gw/blob/main/src/claude_gateway.ts 
 */
// @ts-ignore
import EventSource from 'eventsource'
const MCP_HOST = process.env['MCP_HOST'] ?? 'localhost'
const MCP_PORT = process.env['MCP_PORT'] ?? 3000
const baseUrl = `http://${MCP_HOST}:${MCP_PORT}`
const backendUrlSse = `${baseUrl}/sse`
const backendUrlMsg = `${baseUrl}/message`
const debug = console.error // With stdio transport stderr is the only channel for debugging
const respond = console.log // Message back to Claude Desktop App.
/*
 * Claude MCP has two communications channels.
 * 1. All the responses (and notifications) from the MCP server comes through the
 *    persistent HTTP connection (i.e. Server-Side Events).
 * 2. However, the requests are sent as HTTP POST messages and for which the server
 *    responds HTTP 202 Accept (the "actual" response is sent through the SSE connection)
 */
// 1. Establish persistent MCP server SSE connection and forward received messages to stdin
function connectSSEBackend() {
  return new Promise((resolve, reject) => {
    const source = new EventSource(backendUrlSse)
    source.onopen = (evt: MessageEvent) => resolve(evt)
    source.addEventListener('error', (e: unknown) => reject(e))
    source.addEventListener('open', (e: unknown) => debug(`--- SSE backend connected`))
    source.addEventListener('error', (e: unknown) => debug(`--- SSE backend disc./error: ${(<{message: string}>e)?.message}`))
    source.addEventListener('message', (e: unknown) => debug(`<-- ${(e as {data: string}).data}`))
    source.addEventListener('message', (e: unknown) => respond((e as {data: string}).data)) // forward to Claude Desktop App via stdio transport
  })
}
// 2. Forward received message to the MCP server
async function processMessage(inp: Buffer) {
  const msg = inp.toString()
  debug('-->', msg.trim())
  const [method, body, headers] = ['POST', msg, { 'Content-Type': 'application/json' }]
  await fetch(new URL(backendUrlMsg), { method, body, headers }).catch(e => debug('fetch error:', e))
}
async function runBridge() {
  await connectSSEBackend()
  process.stdin.on('data', processMessage)
  debug('-- MCP stdio to SSE gw running')
}
runBridge().catch(error => {
  debug('Fatal error running server:', error)
  process.exit(1)
})
```
--------------------------------------------------------------------------------
/src/resources/index.ts:
--------------------------------------------------------------------------------
```typescript
import { ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { parsePageId, getAllInSpace, getBlockTitle, getCanonicalPageId } from '@texonom/nutils'
import type { Block, ExtendedRecordMap } from '@texonom/ntypes'
import type { Server } from '@modelcontextprotocol/sdk/server/index.js'
import type { NotionAPI } from '@texonom/nclient'
import type { NotionExporter } from '@texonom/cli'
import type { Resource, Content } from '../index.js'
export function setListResources(server: Server, client: NotionAPI, exporter: NotionExporter) {
  server.setRequestHandler(ListResourcesRequestSchema, async () => {
    const id = parsePageId(process.env.ROOT_PAGE as string)
    const { recordMap, pageTree, pageMap } = await getAllInSpace(
      id,
      client.getPage.bind(client),
      client.getBlocks.bind(client),
      client.fetchCollections.bind(client),
      {
        startRecordMap: exporter.recordMap,
        collectionConcurrency: 100,
        concurrency: 100,
        maxPage: 10,
        fetchOption: { timeout: 1000 }
      }
    )
    exporter.recordMap = recordMap
    exporter.pageTree = pageTree
    exporter.pageMap = pageMap
    const resources: Resource[] = []
    exporter.writeFile = async function writeFile (path: string, target: string) {
      const id = parsePageId(path)
      const slug = getCanonicalPageId(id, recordMap)
      resources.push({
        uri: `note://${slug}`,
        mimeType: 'text/markdown',
        name: getBlockTitle(recordMap.block[id]?.value, recordMap),
        description: getPageDescription(recordMap.block[id]?.value, recordMap)
      })
    }
    await exporter.exportMd(id)
    await Promise.all(exporter.promises)
    return { resources }
  })
}
export function setReadResource(server: Server, client: NotionAPI, exporter: NotionExporter) {
  server.setRequestHandler(ReadResourceRequestSchema, async request => {
    const id = parsePageId(request.params.uri)
    if (!id) throw new Error(`Note ${request.params.uri} not found`)
    const recordMap = await client.getPage(id)
    if (!recordMap) throw new Error(`Record Map ${id} not found`)
    const md = await exporter.pageToMarkdown(id, recordMap)
    const contents: Content[] = [
      {
        uri: request.params.uri,
        mimeType: 'text/markdown',
        text: md,
      }
    ]
    return { contents }
  })
}
function getPageDescription(block: Block, recordMap: ExtendedRecordMap) {
  const firstBlock = recordMap.block[block?.content?.[0] as string]?.value
  const firstBlockTitle = firstBlock ? getBlockTitle(firstBlock, recordMap).trim() : ''
  let contentDescription: string
  if (firstBlockTitle.length > 50) {
    contentDescription = firstBlockTitle
  } else {
    const secondBlock = recordMap.block[block?.content?.[1] as string]?.value
    const secondBlockTitle = secondBlock ? getBlockTitle(secondBlock, recordMap).trim() : ''
    contentDescription = `${firstBlockTitle}\n${secondBlockTitle}`
  }
  return contentDescription
}
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import express from 'express'
import bodyParser from 'body-parser'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'
import { setReadResource, setListResources } from './resources/index.js'
import { setListTools, setCallTool } from './tools/index.js'
import { setListPrompts, setGetPrompt } from './prompts/index.js'
import { NotionExporter } from '@texonom/cli'
import cors from 'cors'
// Type definitions (if needed)
export type Note = { title: string; content: string }
export type Content = { uri: string; mimeType: string; text: string }
export type Resource = { uri: string; mimeType: string; name: string; description: string }
/**
 * Start the server using SSE transport.
 * This migration is required for remote deployment for shared usage of the server.
 */
async function main() {
  // Retrieve the root page and other configurations from environment variables.
  const root = process.env.ROOT_PAGE as string
  if (!root) {
    console.error('Error: ROOT_PAGE environment variable is not set.')
    process.exit(1)
  }
  // Initialize NotionExporter.
  const exporter = new NotionExporter({
    page: root,
    domain: String(),
    folder: String(),
    validation: true,
    recursive: true,
  })
  const client = exporter.notion
  // Create a server instance
  const server = new Server(
    {
      name: 'notion-texonom',
      version: '0.2.0',
    },
    {
      capabilities: {
        resources: {},
        tools: {},
        prompts: {},
      },
    },
  )
  // Resources
  setReadResource(server, client, exporter)
  setListResources(server, client, exporter)
  // Tools
  setListTools(server)
  setCallTool(server, client, exporter)
  // Prompts
  setListPrompts(server)
  setGetPrompt(server, client, exporter)
  // Configure Express app and SSE transport
  const app = express()
  app.use(cors())
  const BASE_URL = 'http://localhost'
  const PORT = Number(process.env.PORT) || 3000
  const SSE_PATH = '/sse'
  const MESSAGE_PATH = '/message'
  // JSON request parsing middleware (only for POST /message)
  app.use((req, res, next) => {
    if (req.path === MESSAGE_PATH)
      return next()
    return bodyParser.json()(req, res, next)
  })
  // SSE connection setup endpoint
  let sseTransport: SSEServerTransport | null = null
  app.get(SSE_PATH, async (req, res) => {
    req.query.transportType = 'sse'
    console.log(`[notion-texonom] New SSE connection from ${req.ip}`)
    sseTransport = new SSEServerTransport(`${BASE_URL}:${PORT}${MESSAGE_PATH}`, res)
    try {
      await server.connect(sseTransport)
      console.log('[notion-texonom] Server connected via SSE transport.')
    } catch (error) {
      console.error('[notion-texonom] Error connecting server via SSE:', error)
      res.status(500).end()
    }
    // Optionally, define handlers for onmessage, onclose, and onerror.
    sseTransport.onmessage = (msg) => {
      console.log('[notion-texonom] SSE received message:', msg)
      // Additional processing if needed
    }
    sseTransport.onclose = () => {
      console.log('[notion-texonom] SSE connection closed.')
      sseTransport = null
    }
    sseTransport.onerror = (err) => {
      console.error('[notion-texonom] SSE transport error:', err)
    }
  })
  // Endpoint to handle messages sent from the client
  app.post(MESSAGE_PATH, async (req, res) => {
    req.query.transportType = 'sse'
    if (sseTransport && sseTransport.handlePostMessage) {
      console.log(`[notion-texonom] POST ${MESSAGE_PATH} -> processing SSE message`)
      await sseTransport.handlePostMessage(req, res)
    } else {
      res.status(503).send('No SSE connection active')
    }
  })
  // Start the server
  app.listen(PORT, () => {
    console.log(`[notion-texonom] Listening on port ${PORT}`)
    console.log(`  SSE endpoint:   http://localhost:${PORT}${SSE_PATH}`)
    console.log(`  POST messages:  http://localhost:${PORT}${MESSAGE_PATH}`)
  })
}
main().catch(error => {
  console.error('Server error:', error)
  process.exit(1)
})
```