From 1be339ca6598c26a22ff4e279717d07edf33f367 Mon Sep 17 00:00:00 2001 From: Kenneth Sinder Date: Wed, 17 Jun 2026 14:09:43 -0700 Subject: [PATCH] Add page-as-Markdown tools; source Notion-Version per operation Adds two tools from the public Notion API that fit agentic clients and were missing from the spec: - retrieve-page-markdown (GET /v1/pages/{page_id}/markdown) - update-page-markdown (PATCH /v1/pages/{page_id}/markdown) They read/write page content as enhanced Markdown instead of block JSON, which is far more token-efficient for LLMs. These endpoints require Notion-Version 2026-03-11 while the rest of the API stays on 2025-09-03, so Notion-Version is now sourced per operation from the OpenAPI spec: HttpClient applies each operation's header-param default (resolving $refs), an explicitly-configured Notion-Version still wins, the proxy no longer hardcodes it, and the parser no longer exposes header params as model-visible tool inputs. Also hardens the server-based integration tests against flakiness (root cause, not retries): a shared startTestServer/stopTestServer helper binds an ephemeral port (0), awaits the `listening` event before issuing requests, awaits full shutdown, and uses 127.0.0.1 to avoid localhost IPv4/IPv6 mismatch. This removes the intermittent ECONNRESET / "socket hang up" failures from random-port collisions and request-before-bind races. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 11 + scripts/notion-openapi.json | 526 +++++++++++++++++- .../__tests__/http-client.headers.test.ts | 96 ++++ .../__tests__/http-client.integration.test.ts | 18 +- .../client/__tests__/test-server.ts | 40 ++ src/openapi-mcp-server/client/http-client.ts | 53 ++ .../mcp/__tests__/proxy.test.ts | 4 +- src/openapi-mcp-server/mcp/proxy.ts | 3 +- .../notion-spec.snapshot.test.ts.snap | 66 ++- .../__tests__/notion-markdown-tools.test.ts | 54 ++ src/openapi-mcp-server/openapi/parser.ts | 6 + 11 files changed, 839 insertions(+), 38 deletions(-) create mode 100644 src/openapi-mcp-server/client/__tests__/http-client.headers.test.ts create mode 100644 src/openapi-mcp-server/client/__tests__/test-server.ts create mode 100644 src/openapi-mcp-server/openapi/__tests__/notion-markdown-tools.test.ts diff --git a/README.md b/README.md index 7c3e1b6f..d7c5d5e6 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,17 @@ If you have hardcoded tool names or prompts that reference the old database tool --- +## Page content as Markdown + +The server exposes two tools for working with page content as enhanced Markdown instead of block JSON, which is significantly more token-efficient for AI agents: + +- `retrieve-page-markdown` — Read a page's full content as Markdown (`GET /v1/pages/{page_id}/markdown`). Pass `include_transcript: true` to inline meeting-note transcripts. +- `update-page-markdown` — Edit a page's content with Markdown (`PATCH /v1/pages/{page_id}/markdown`). Prefer `replace_content` to overwrite the whole page, or `update_content` for targeted find-and-replace edits. + +These endpoints require Notion API version `2026-03-11`. The server now sources the `Notion-Version` header **per operation** from the OpenAPI spec, so these tools use `2026-03-11` while the rest of the API continues to use `2025-09-03` — no configuration needed. If you set `Notion-Version` yourself via `OPENAPI_MCP_HEADERS`, your value takes precedence for every tool. + +--- + ### Installation #### 1. Setting up integration in Notion diff --git a/scripts/notion-openapi.json b/scripts/notion-openapi.json index 18669f22..8fa15770 100644 --- a/scripts/notion-openapi.json +++ b/scripts/notion-openapi.json @@ -2212,6 +2212,530 @@ "deprecated": false, "security": [] } + }, + "/v1/pages/{page_id}/markdown": { + "get": { + "summary": "Retrieve a page as Markdown", + "description": "Retrieve the full content of a Notion page rendered as enhanced Markdown. Designed for agentic and developer tools that work natively with Markdown instead of the block JSON returned by Retrieve block children.", + "operationId": "retrieve-page-markdown", + "tags": [ + "Pages" + ], + "parameters": [ + { + "name": "page_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Identifier for a Notion page (or block) to read or update as markdown." + }, + { + "name": "include_transcript", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false + }, + "description": "Whether to include meeting note transcripts. When false (default), a placeholder with the meeting note URL is shown instead of the full transcript." + }, + { + "name": "Notion-Version", + "in": "header", + "required": true, + "schema": { + "type": "string", + "default": "2026-03-11" + }, + "description": "The Notion API version. The page-markdown endpoints require 2026-03-11 or later." + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "object": { + "type": "string", + "example": "page_markdown" + }, + "id": { + "type": "string", + "format": "uuid", + "description": "ID of the page or block." + }, + "markdown": { + "type": "string", + "description": "Page content rendered as enhanced Markdown." + }, + "truncated": { + "type": "boolean", + "description": "True if content was truncated because the page exceeds the block limit." + }, + "unknown_block_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Block IDs that could not be rendered (e.g. due to permissions or truncation). Pass them back to this endpoint to fetch them individually." + } + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "object": { + "type": "string", + "example": "error" + }, + "status": { + "type": "integer", + "example": 400 + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "403": { + "description": "The integration lacks the read/update content capability required for this page.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "object": { + "type": "string", + "example": "error" + }, + "status": { + "type": "integer", + "example": 403 + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Page not found or not shared with the integration.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "object": { + "type": "string", + "example": "error" + }, + "status": { + "type": "integer", + "example": 404 + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "object": { + "type": "string", + "example": "error" + }, + "status": { + "type": "integer", + "example": 429 + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + }, + "deprecated": false, + "security": [] + }, + "patch": { + "summary": "Update a page's content as Markdown", + "description": "Update the content of a Notion page using enhanced Markdown. Prefer `replace_content` to overwrite the whole page or `update_content` for targeted find-and-replace edits.", + "operationId": "update-page-markdown", + "tags": [ + "Pages" + ], + "parameters": [ + { + "name": "page_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Identifier for a Notion page (or block) to read or update as markdown." + }, + { + "name": "Notion-Version", + "in": "header", + "required": true, + "schema": { + "type": "string", + "default": "2026-03-11" + }, + "description": "The Notion API version. The page-markdown endpoints require 2026-03-11 or later." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "type" + ], + "description": "Specify exactly one edit operation. Set `type` to the operation name and provide the matching object.", + "properties": { + "type": { + "type": "string", + "enum": [ + "replace_content", + "update_content", + "insert_content", + "replace_content_range" + ], + "description": "The kind of edit to apply. Prefer `replace_content` (overwrite the whole page) or `update_content` (targeted find-and-replace). `insert_content` and `replace_content_range` are deprecated." + }, + "replace_content": { + "type": "object", + "required": [ + "new_str" + ], + "description": "Provide when type is `replace_content`. Replaces the entire page content.", + "properties": { + "new_str": { + "type": "string", + "description": "The complete new page content as enhanced Markdown." + }, + "allow_deleting_content": { + "type": "boolean", + "description": "Permit deletion of existing child pages/databases that are no longer present in the new content." + } + } + }, + "update_content": { + "type": "object", + "required": [ + "content_updates" + ], + "description": "Provide when type is `update_content`. Applies targeted find-and-replace edits.", + "properties": { + "content_updates": { + "type": "array", + "maxItems": 100, + "description": "Up to 100 find-and-replace edits applied in order.", + "items": { + "type": "object", + "required": [ + "old_str", + "new_str" + ], + "properties": { + "old_str": { + "type": "string", + "description": "Exact existing Markdown text to find." + }, + "new_str": { + "type": "string", + "description": "Replacement Markdown text." + }, + "replace_all_matches": { + "type": "boolean", + "description": "Replace every occurrence of old_str instead of just the first (default false)." + } + } + } + }, + "allow_deleting_content": { + "type": "boolean", + "description": "Permit deletion of existing child pages/databases removed by the edits." + } + } + }, + "insert_content": { + "type": "object", + "deprecated": true, + "required": [ + "content" + ], + "description": "Deprecated. Provide when type is `insert_content`. Inserts Markdown at a position.", + "properties": { + "content": { + "type": "string", + "description": "Enhanced Markdown to insert." + }, + "after": { + "type": "string", + "description": "Ellipsis-format selection (\"start text...end text\") to insert after. Cannot be combined with position." + }, + "position": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "start", + "end" + ] + } + }, + "description": "Insert at the start or end of the page. Cannot be combined with after." + } + } + }, + "replace_content_range": { + "type": "object", + "deprecated": true, + "required": [ + "content", + "content_range" + ], + "description": "Deprecated. Provide when type is `replace_content_range`. Replaces a selected range of Markdown.", + "properties": { + "content": { + "type": "string", + "description": "New Markdown content for the range." + }, + "content_range": { + "type": "string", + "description": "Ellipsis-format selection (\"start text...end text\") identifying the range to replace." + }, + "allow_deleting_content": { + "type": "boolean", + "description": "Permit deletion of child pages/databases within the replaced range." + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "object": { + "type": "string", + "example": "page_markdown" + }, + "id": { + "type": "string", + "format": "uuid", + "description": "ID of the page or block." + }, + "markdown": { + "type": "string", + "description": "Page content rendered as enhanced Markdown." + }, + "truncated": { + "type": "boolean", + "description": "True if content was truncated because the page exceeds the block limit." + }, + "unknown_block_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Block IDs that could not be rendered (e.g. due to permissions or truncation). Pass them back to this endpoint to fetch them individually." + } + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "object": { + "type": "string", + "example": "error" + }, + "status": { + "type": "integer", + "example": 400 + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "403": { + "description": "The integration lacks the read/update content capability required for this page.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "object": { + "type": "string", + "example": "error" + }, + "status": { + "type": "integer", + "example": 403 + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Page not found or not shared with the integration.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "object": { + "type": "string", + "example": "error" + }, + "status": { + "type": "integer", + "example": 404 + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "object": { + "type": "string", + "example": "error" + }, + "status": { + "type": "integer", + "example": 429 + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "409": { + "description": "Conflict (e.g. row limit exceeded).", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "object": { + "type": "string", + "example": "error" + }, + "status": { + "type": "integer", + "example": 409 + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + }, + "deprecated": false, + "security": [] + } } } -} \ No newline at end of file +} diff --git a/src/openapi-mcp-server/client/__tests__/http-client.headers.test.ts b/src/openapi-mcp-server/client/__tests__/http-client.headers.test.ts new file mode 100644 index 00000000..34e0f599 --- /dev/null +++ b/src/openapi-mcp-server/client/__tests__/http-client.headers.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, afterEach } from 'vitest' +import express, { type Express } from 'express' +import type { Server } from 'http' +import type { OpenAPIV3 } from 'openapi-types' +import { HttpClient } from '../http-client' +import { startTestServer, stopTestServer } from './test-server' + +/** + * Verifies that Notion-Version is sourced per-operation from the OpenAPI spec + * (server-managed header), so endpoints can pin the API version they require + * while still letting an explicitly-configured header win. + */ +describe('HttpClient server-managed header parameters', () => { + let server: Server | undefined + + afterEach(async () => { + await stopTestServer(server) + server = undefined + }) + + function echoApp(): Express { + const app = express() + app.use(express.json()) + app.get('/v1/legacy', (req, res) => { + res.json({ version: req.headers['notion-version'] ?? null }) + }) + app.get('/v1/markdown', (req, res) => { + res.json({ version: req.headers['notion-version'] ?? null }) + }) + return app + } + + function spec(baseUrl: string): OpenAPIV3.Document { + return { + openapi: '3.0.0', + info: { title: 't', version: '1' }, + servers: [{ url: baseUrl }], + components: { + parameters: { + notionVersion: { + name: 'Notion-Version', + in: 'header', + required: false, + schema: { type: 'string', default: '2025-09-03' }, + }, + }, + }, + paths: { + '/v1/legacy': { + get: { + operationId: 'legacyOp', + // References the shared component default (2025-09-03) + parameters: [{ $ref: '#/components/parameters/notionVersion' } as any], + responses: { '200': { description: 'ok' } }, + }, + }, + '/v1/markdown': { + get: { + operationId: 'markdownOp', + // Inline override pinning a newer version (2026-03-11) + parameters: [ + { name: 'Notion-Version', in: 'header', required: true, schema: { type: 'string', default: '2026-03-11' } }, + ], + responses: { '200': { description: 'ok' } }, + }, + }, + }, + } + } + + it('sends each operation’s spec-default Notion-Version', async () => { + let baseUrl: string + ;({ server, baseUrl } = await startTestServer(echoApp())) + const s = spec(baseUrl) + const client = new HttpClient({ baseUrl }, s) + + const legacy = await client.executeOperation(s.paths!['/v1/legacy']!.get as any) + const markdown = await client.executeOperation(s.paths!['/v1/markdown']!.get as any) + + expect(legacy.data.version).toBe('2025-09-03') + expect(markdown.data.version).toBe('2026-03-11') + }) + + it('lets an explicitly-configured Notion-Version win over spec defaults', async () => { + let baseUrl: string + ;({ server, baseUrl } = await startTestServer(echoApp())) + const s = spec(baseUrl) + const client = new HttpClient( + { baseUrl, headers: { 'Notion-Version': '2022-06-28' } }, + s, + ) + + const legacy = await client.executeOperation(s.paths!['/v1/legacy']!.get as any) + expect(legacy.data.version).toBe('2022-06-28') + }) +}) diff --git a/src/openapi-mcp-server/client/__tests__/http-client.integration.test.ts b/src/openapi-mcp-server/client/__tests__/http-client.integration.test.ts index 887b17a3..7b07796e 100644 --- a/src/openapi-mcp-server/client/__tests__/http-client.integration.test.ts +++ b/src/openapi-mcp-server/client/__tests__/http-client.integration.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { HttpClient } from '../http-client' -import express from 'express' +import express, { type Express } from 'express' import type { OpenAPIV3 } from 'openapi-types' import type { Server } from 'http' +import { startTestServer, stopTestServer } from './test-server' interface Pet { id: number @@ -26,7 +27,7 @@ function resetPets() { nextId = 4 } -function createTestServer(port: number): Server { +function createTestApp(): Express { const app = express() app.use(express.json()) @@ -79,26 +80,21 @@ function createTestServer(port: number): Server { res.status(204).send() }) - return app.listen(port) + return app } describe('HttpClient Integration Tests', () => { - let PORT: number let BASE_URL: string let server: Server let openApiSpec: OpenAPIV3.Document let client: HttpClient beforeEach(async () => { - // Use a random port to avoid conflicts - PORT = 3000 + Math.floor(Math.random() * 1000) - BASE_URL = `http://localhost:${PORT}` - // Initialize pets data resetPets() - // Start the test server - server = createTestServer(PORT) + // Start the test server on an ephemeral port and wait until it is listening + ;({ server, baseUrl: BASE_URL } = await startTestServer(createTestApp())) // Create a minimal OpenAPI spec for the test server openApiSpec = { @@ -152,7 +148,7 @@ describe('HttpClient Integration Tests', () => { }) afterEach(async () => { - server.close() + await stopTestServer(server) }) it('should list all pets', async () => { diff --git a/src/openapi-mcp-server/client/__tests__/test-server.ts b/src/openapi-mcp-server/client/__tests__/test-server.ts new file mode 100644 index 00000000..d6aaa4a4 --- /dev/null +++ b/src/openapi-mcp-server/client/__tests__/test-server.ts @@ -0,0 +1,40 @@ +import type { Express } from 'express' +import type { Server } from 'http' +import type { AddressInfo } from 'net' + +/** + * Start an Express app on an OS-assigned ephemeral port and resolve only once it + * is actually accepting connections. + * + * This avoids two classes of test flakiness: + * - **Port collisions:** vitest runs test files in parallel workers, so any + * fixed or randomly-chosen port can clash with another server. Port `0` lets + * the OS hand out a guaranteed-free port. + * - **Request-before-bind races:** awaiting the `listening` event guarantees the + * socket is bound before tests issue requests, eliminating the intermittent + * `socket hang up` / ECONNRESET failures seen when requests raced `listen()`. + * + * The base URL uses `127.0.0.1` (not `localhost`) so the client connects over + * IPv4 to match the IPv4 listener, avoiding `localhost` → `::1` mismatches. + */ +export function startTestServer(app: Express): Promise<{ server: Server; baseUrl: string }> { + return new Promise((resolve, reject) => { + const server = app.listen(0) + server.once('listening', () => { + const { port } = server.address() as AddressInfo + resolve({ server, baseUrl: `http://127.0.0.1:${port}` }) + }) + server.once('error', reject) + }) +} + +/** Close a server and resolve once it has fully shut down. */ +export function stopTestServer(server: Server | undefined): Promise { + return new Promise((resolve) => { + if (!server) { + resolve() + return + } + server.close(() => resolve()) + }) +} diff --git a/src/openapi-mcp-server/client/http-client.ts b/src/openapi-mcp-server/client/http-client.ts index d18a951e..eaed34e7 100644 --- a/src/openapi-mcp-server/client/http-client.ts +++ b/src/openapi-mcp-server/client/http-client.ts @@ -32,8 +32,12 @@ export class HttpClientError extends Error { export class HttpClient { private api: Promise private client: OpenAPIClientAxios + private config: HttpClientConfig + private openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document constructor(config: HttpClientConfig, openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document) { + this.config = config + this.openApiSpec = openApiSpec // @ts-expect-error this.client = new (OpenAPIClientAxios.default ?? OpenAPIClientAxios)({ definition: openApiSpec, @@ -49,6 +53,54 @@ export class HttpClient { this.api = this.client.init() } + /** + * Resolve a possibly-$ref'd parameter to its inline definition. + * Only local refs (e.g. `#/components/parameters/notionVersion`) are supported. + */ + private resolveParameter( + param: OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject, + ): OpenAPIV3.ParameterObject | null { + if (!('$ref' in param)) { + return param as OpenAPIV3.ParameterObject + } + const ref = param.$ref + if (!ref.startsWith('#/')) { + return null + } + let node: any = this.openApiSpec + for (const segment of ref.slice(2).split('/')) { + node = node?.[segment] + if (node === undefined) return null + } + return node && node.name ? (node as OpenAPIV3.ParameterObject) : null + } + + /** + * Build the server-managed header parameters declared on an operation. + * + * Header parameters (currently `Notion-Version`) are not exposed as tool + * inputs; their value comes from the operation's header-parameter `default` + * in the OpenAPI spec. This lets each endpoint pin the API version it needs — + * e.g. the page-markdown endpoints require `2026-03-11` while the rest of the + * API stays on `2025-09-03`. A header the caller configured globally (via + * HttpClientConfig.headers) takes precedence and is left untouched. + */ + private buildDefaultHeaders(operation: OpenAPIV3.OperationObject): Record { + const configured = new Set(Object.keys(this.config.headers ?? {}).map((key) => key.toLowerCase())) + const headers: Record = {} + for (const param of operation.parameters ?? []) { + const resolved = this.resolveParameter(param) + if (!resolved || resolved.in !== 'header' || configured.has(resolved.name.toLowerCase())) { + continue + } + const schema = resolved.schema as OpenAPIV3.SchemaObject | undefined + if (schema && schema.default !== undefined) { + headers[resolved.name] = String(schema.default) + } + } + return headers + } + private async prepareFileUpload(operation: OpenAPIV3.OperationObject, params: Record): Promise { const fileParams = isFileUploadParameter(operation) if (fileParams.length === 0) return null @@ -157,6 +209,7 @@ export class HttpClient { : { ...(hasBody ? { 'Content-Type': 'application/json' } : { 'Content-Type': null }) } const requestConfig = { headers: { + ...this.buildDefaultHeaders(operation), ...headers, }, } diff --git a/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts b/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts index f80f232d..76bb8c7b 100644 --- a/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts +++ b/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts @@ -265,11 +265,12 @@ describe('MCPProxy', () => { process.env.NOTION_TOKEN = 'ntn_test_token_123' const proxy = new MCPProxy('test-proxy', mockOpenApiSpec) + // Notion-Version is no longer hardcoded here; it is sourced per-operation + // from the OpenAPI spec by HttpClient. expect(HttpClient).toHaveBeenCalledWith( expect.objectContaining({ headers: { 'Authorization': 'Bearer ntn_test_token_123', - 'Notion-Version': '2025-09-03' }, }), expect.anything(), @@ -317,7 +318,6 @@ describe('MCPProxy', () => { expect.objectContaining({ headers: { 'Authorization': 'Bearer ntn_test_token_123', - 'Notion-Version': '2025-09-03' }, }), expect.anything(), diff --git a/src/openapi-mcp-server/mcp/proxy.ts b/src/openapi-mcp-server/mcp/proxy.ts index f83a57c3..486c43ba 100644 --- a/src/openapi-mcp-server/mcp/proxy.ts +++ b/src/openapi-mcp-server/mcp/proxy.ts @@ -221,9 +221,10 @@ export class MCPProxy { // Alternative: try NOTION_TOKEN const notionToken = process.env.NOTION_TOKEN if (notionToken) { + // Notion-Version is intentionally omitted: it is sourced per-operation from + // the OpenAPI spec by HttpClient, so endpoints can pin the version they need. return { 'Authorization': `Bearer ${notionToken}`, - 'Notion-Version': '2025-09-03' } } diff --git a/src/openapi-mcp-server/openapi/__tests__/__snapshots__/notion-spec.snapshot.test.ts.snap b/src/openapi-mcp-server/openapi/__tests__/__snapshots__/notion-spec.snapshot.test.ts.snap index 2cf3dd86..5819d1d9 100644 --- a/src/openapi-mcp-server/openapi/__tests__/__snapshots__/notion-spec.snapshot.test.ts.snap +++ b/src/openapi-mcp-server/openapi/__tests__/__snapshots__/notion-spec.snapshot.test.ts.snap @@ -22,8 +22,10 @@ exports[`Notion OpenAPI spec -> MCP tools (snapshot) > generates a stable set of "retrieve-a-database", "retrieve-a-page", "retrieve-a-page-property", + "retrieve-page-markdown", "update-a-block", "update-a-data-source", + "update-page-markdown", ] `; @@ -51,7 +53,6 @@ Error Responses: "httpMethod": "post", "name": "create-a-data-source", "properties": [ - "Notion-Version", "parent", "properties", "title", @@ -68,7 +69,6 @@ Error Responses: "httpMethod": "delete", "name": "delete-a-block", "properties": [ - "Notion-Version", "block_id", ], "required": [ @@ -82,7 +82,6 @@ Error Responses: "httpMethod": "get", "name": "get-block-children", "properties": [ - "Notion-Version", "block_id", "page_size", "start_cursor", @@ -97,9 +96,7 @@ Error Responses: 400: Bad request", "httpMethod": "get", "name": "get-self", - "properties": [ - "Notion-Version", - ], + "properties": [], "required": [], }, { @@ -109,7 +106,6 @@ Error Responses: "httpMethod": "get", "name": "get-user", "properties": [ - "Notion-Version", "user_id", ], "required": [ @@ -123,7 +119,6 @@ Error Responses: "httpMethod": "get", "name": "get-users", "properties": [ - "Notion-Version", "page_size", "start_cursor", ], @@ -136,7 +131,6 @@ Error Responses: "httpMethod": "get", "name": "list-data-source-templates", "properties": [ - "Notion-Version", "data_source_id", "page_size", "start_cursor", @@ -152,7 +146,6 @@ Error Responses: "httpMethod": "post", "name": "move-page", "properties": [ - "Notion-Version", "page_id", "parent", ], @@ -168,7 +161,6 @@ Error Responses: "httpMethod": "patch", "name": "patch-block-children", "properties": [ - "Notion-Version", "after", "block_id", "children", @@ -185,7 +177,6 @@ Error Responses: "httpMethod": "patch", "name": "patch-page", "properties": [ - "Notion-Version", "archived", "cover", "icon", @@ -204,7 +195,6 @@ Error Responses: "httpMethod": "post", "name": "post-page", "properties": [ - "Notion-Version", "children", "cover", "icon", @@ -223,7 +213,6 @@ Error Responses: "httpMethod": "post", "name": "post-search", "properties": [ - "Notion-Version", "filter", "page_size", "query", @@ -239,7 +228,6 @@ Error Responses: "httpMethod": "post", "name": "query-data-source", "properties": [ - "Notion-Version", "archived", "data_source_id", "filter", @@ -260,7 +248,6 @@ Error Responses: "httpMethod": "get", "name": "retrieve-a-block", "properties": [ - "Notion-Version", "block_id", ], "required": [ @@ -274,7 +261,6 @@ Error Responses: "httpMethod": "get", "name": "retrieve-a-comment", "properties": [ - "Notion-Version", "block_id", "page_size", "start_cursor", @@ -290,7 +276,6 @@ Error Responses: "httpMethod": "get", "name": "retrieve-a-data-source", "properties": [ - "Notion-Version", "data_source_id", ], "required": [ @@ -304,7 +289,6 @@ Error Responses: "httpMethod": "get", "name": "retrieve-a-database", "properties": [ - "Notion-Version", "database_id", ], "required": [ @@ -318,7 +302,6 @@ Error Responses: "httpMethod": "get", "name": "retrieve-a-page", "properties": [ - "Notion-Version", "filter_properties", "page_id", ], @@ -333,7 +316,6 @@ Error Responses: "httpMethod": "get", "name": "retrieve-a-page-property", "properties": [ - "Notion-Version", "page_id", "page_size", "property_id", @@ -344,6 +326,23 @@ Error Responses: "property_id", ], }, + { + "description": "Notion | Retrieve a page as Markdown +Error Responses: +400: Bad request +403: The integration lacks the read/update content capability required for this page. +404: Page not found or not shared with the integration. +429: Rate limited.", + "httpMethod": "get", + "name": "retrieve-page-markdown", + "properties": [ + "include_transcript", + "page_id", + ], + "required": [ + "page_id", + ], + }, { "description": "Notion | Update a block Error Responses: @@ -351,7 +350,6 @@ Error Responses: "httpMethod": "patch", "name": "update-a-block", "properties": [ - "Notion-Version", "archived", "block_id", "type", @@ -367,7 +365,6 @@ Error Responses: "httpMethod": "patch", "name": "update-a-data-source", "properties": [ - "Notion-Version", "data_source_id", "description", "properties", @@ -377,5 +374,28 @@ Error Responses: "data_source_id", ], }, + { + "description": "Notion | Update a page's content as Markdown +Error Responses: +400: Bad request +403: The integration lacks the read/update content capability required for this page. +404: Page not found or not shared with the integration. +409: Conflict (e.g. row limit exceeded). +429: Rate limited.", + "httpMethod": "patch", + "name": "update-page-markdown", + "properties": [ + "insert_content", + "page_id", + "replace_content", + "replace_content_range", + "type", + "update_content", + ], + "required": [ + "page_id", + "type", + ], + }, ] `; diff --git a/src/openapi-mcp-server/openapi/__tests__/notion-markdown-tools.test.ts b/src/openapi-mcp-server/openapi/__tests__/notion-markdown-tools.test.ts new file mode 100644 index 00000000..9cb9e194 --- /dev/null +++ b/src/openapi-mcp-server/openapi/__tests__/notion-markdown-tools.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest' +import path from 'path' +import fs from 'fs' +import type { OpenAPIV3 } from 'openapi-types' +import { OpenAPIToMCPConverter } from '../parser' + +/** + * Guards the page-markdown tools against the real Notion OpenAPI spec and + * verifies that header parameters (Notion-Version) are not exposed as model + * inputs. + */ +describe('Notion page-markdown tools', () => { + const spec = JSON.parse( + fs.readFileSync(path.resolve(process.cwd(), 'scripts/notion-openapi.json'), 'utf-8'), + ) as OpenAPIV3.Document + + const { tools, openApiLookup } = new OpenAPIToMCPConverter(spec).convertToMCPTools() + const methods = Object.values(tools).flatMap((t) => t.methods) + const byName = (name: string) => methods.find((m) => m.name === name) + + it('exposes retrieve-page-markdown and update-page-markdown', () => { + expect(byName('retrieve-page-markdown')).toBeDefined() + expect(byName('update-page-markdown')).toBeDefined() + }) + + it('maps the markdown tools to the correct HTTP operations', () => { + const get = Object.entries(openApiLookup).find(([, op]) => op.operationId === 'retrieve-page-markdown') + const patch = Object.entries(openApiLookup).find(([, op]) => op.operationId === 'update-page-markdown') + expect(get?.[1].method).toBe('get') + expect(get?.[1].path).toBe('/v1/pages/{page_id}/markdown') + expect(patch?.[1].method).toBe('patch') + expect(patch?.[1].path).toBe('/v1/pages/{page_id}/markdown') + }) + + it('does not expose Notion-Version (a server-managed header) as a tool input', () => { + for (const method of methods) { + expect(Object.keys(method.inputSchema.properties ?? {})).not.toContain('Notion-Version') + } + }) + + it('exposes the expected inputs for the markdown tools', () => { + const retrieve = byName('retrieve-page-markdown')! + const retrieveProps = Object.keys(retrieve.inputSchema.properties ?? {}) + expect(retrieveProps).toContain('page_id') + expect(retrieveProps).toContain('include_transcript') + + const update = byName('update-page-markdown')! + const updateProps = Object.keys(update.inputSchema.properties ?? {}) + expect(updateProps).toContain('page_id') + expect(updateProps).toContain('type') + expect(updateProps).toContain('replace_content') + expect(updateProps).toContain('update_content') + }) +}) diff --git a/src/openapi-mcp-server/openapi/parser.ts b/src/openapi-mcp-server/openapi/parser.ts index 1b209592..595c4ce4 100644 --- a/src/openapi-mcp-server/openapi/parser.ts +++ b/src/openapi-mcp-server/openapi/parser.ts @@ -269,6 +269,12 @@ export class OpenAPIToMCPConverter { for (const param of operation.parameters) { const paramObj = this.resolveParameter(param) if (paramObj && paramObj.schema) { + // Header parameters (e.g. Notion-Version) are transport concerns that the + // server manages, not values the model should supply. Skip them so they + // don't clutter the tool schema; HttpClient applies their spec defaults. + if (paramObj.in === 'header') { + continue + } const schema = this.convertOpenApiSchemaToJsonSchema(paramObj.schema, new Set(), false) // Merge parameter-level description if available if (paramObj.description) {