diff --git a/package-lock.json b/package-lock.json index c4a33bc0..a4ef3994 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,6 @@ "notion-mcp-server": "bin/cli.mjs" }, "devDependencies": { - "@anthropic-ai/sdk": "^0.33.1", "@types/express": "^5.0.0", "@types/js-yaml": "^4.0.9", "@types/json-schema": "^7.0.15", @@ -36,62 +35,11 @@ "@vitest/coverage-v8": "^4.0.18", "esbuild": "^0.25.2", "multer": "1.4.5-lts.1", - "openai": "^4.91.1", "tsx": "^4.19.3", "typescript": "^5.8.2", "vitest": "^4.0.18" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.33.1", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.33.1.tgz", - "integrity": "sha512-VrlbxiAdVRGuKP2UQlCnsShDHJKWepzvfRCkZMpU+oaUdKLpOfmylLMRojGrAgebV+kDtPjewCVP0laHXg+vsA==", - "dev": true, - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7" - } - }, - "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { - "version": "18.19.110", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.110.tgz", - "integrity": "sha512-WW2o4gTmREtSnqKty9nhqF/vA0GKd0V/rbC0OyjSk9Bz6bzlsXKT+i7WDdS/a0z74rfT2PO4dArVCSnapNLA5Q==", - "dev": true, - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@anthropic-ai/sdk/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true - }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -1426,16 +1374,6 @@ "undici-types": "~6.19.2" } }, - "node_modules/@types/node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", - "dev": true, - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.0" - } - }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -1592,18 +1530,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1628,18 +1554,6 @@ "node": ">= 6.0.0" } }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "dev": true, - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -2300,15 +2214,6 @@ "node": ">= 0.6" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -2550,25 +2455,6 @@ "node": ">= 6" } }, - "node_modules/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "dev": true - }, - "node_modules/formdata-node": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", - "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "dev": true, - "dependencies": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.3" - }, - "engines": { - "node": ">= 12.20" - } - }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -2775,15 +2661,6 @@ "node": ">= 6" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "dev": true, - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -3468,71 +3345,6 @@ "wrappy": "1" } }, - "node_modules/openai": { - "version": "4.104.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", - "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", - "dev": true, - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7" - }, - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.23.8" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/openai/node_modules/@types/node": { - "version": "18.19.110", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.110.tgz", - "integrity": "sha512-WW2o4gTmREtSnqKty9nhqF/vA0GKd0V/rbC0OyjSk9Bz6bzlsXKT+i7WDdS/a0z74rfT2PO4dArVCSnapNLA5Q==", - "dev": true, - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/openai/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/openai/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true - }, "node_modules/openapi-client-axios": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/openapi-client-axios/-/openapi-client-axios-7.6.0.tgz", @@ -4184,12 +3996,6 @@ "node": ">=0.6" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4973,31 +4779,6 @@ } } }, - "node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "dev": true, - "engines": { - "node": ">= 14" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", diff --git a/package.json b/package.json index d2a3e81d..5070efd2 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "zod": "3.24.1" }, "devDependencies": { - "@anthropic-ai/sdk": "^0.33.1", "@types/express": "^5.0.0", "@types/js-yaml": "^4.0.9", "@types/json-schema": "^7.0.15", @@ -44,7 +43,6 @@ "@vitest/coverage-v8": "^4.0.18", "esbuild": "^0.25.2", "multer": "1.4.5-lts.1", - "openai": "^4.91.1", "tsx": "^4.19.3", "typescript": "^5.8.2", "vitest": "^4.0.18" diff --git a/src/openapi-mcp-server/LICENSE b/src/openapi-mcp-server/LICENSE new file mode 100644 index 00000000..0974f4ee --- /dev/null +++ b/src/openapi-mcp-server/LICENSE @@ -0,0 +1,29 @@ +The code in this directory is a vendored fork of v1 of +https://github.com/snaggle-ai/openapi-mcp-server, used under the MIT License. +Its original license and copyright notice are reproduced below, as required by +the MIT License. Modifications made by Notion Labs, Inc. are likewise released +under the MIT License (see the LICENSE file at the repository root). + +---------------------------------------------------------------------- + +MIT License + +Copyright (c) 2025 Jan Wilmake + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/openapi-mcp-server/README.md b/src/openapi-mcp-server/README.md index 588abbab..5b6e6ec2 100644 --- a/src/openapi-mcp-server/README.md +++ b/src/openapi-mcp-server/README.md @@ -1,3 +1,35 @@ -Note: This is a fork from v1 of https://github.com/snaggle-ai/openapi-mcp-server. The library took a different direction with v2 which is not compatible with our development approach. +# openapi-mcp-server (vendored) -Forked to upgrade vulnerable dependencies and easier setup. +This directory converts Notion's OpenAPI spec into MCP tools and executes the +underlying API calls. It is a **vendored fork** of v1 of +[`snaggle-ai/openapi-mcp-server`](https://github.com/snaggle-ai/openapi-mcp-server) +(MIT). See [`LICENSE`](./LICENSE) for the upstream copyright notice. + +## Why it's vendored (and why we keep it) + +Upstream took a different direction in v2 — it became an API *discovery* +meta-tool and is no longer a one-tool-per-endpoint converter — so there is no +upgrade path. We own this code deliberately. It is small, has no external +runtime dependencies of its own beyond what the server already ships, and +encodes **Notion-specific behavior** that off-the-shelf OpenAPI→MCP libraries do +not reproduce: + +- `"Notion | "` prefix on tool descriptions (`parser.ts`, `getDescription`). +- Tolerance for clients that double-serialize nested JSON params: complex + schemas are widened to `anyOf: [schema, string]` (`parser.ts`, + `withStringFallback`) and decoded at call time (`proxy.ts`, `deserializeParams`). + See issues [#176](https://github.com/makenotion/notion-mcp-server/issues/176) + and [#208](https://github.com/makenotion/notion-mcp-server/issues/208). +- `readOnly` / `destructive` tool annotations derived from the HTTP method + (`proxy.ts`). +- Multipart/file-upload operations mapped to local-file-path string params + (`parser.ts` binary handling, `file-upload.ts`). +- 64-char tool-name truncation with a uniqueness suffix (`parser.ts`, + `ensureUniqueName`). + +## Changing it safely + +`openapi/__tests__/notion-spec.snapshot.test.ts` snapshots the tools generated +from the real `scripts/notion-openapi.json` spec (names, descriptions, parameter +surface). If a change alters the public tool surface, that snapshot will fail — +review the diff and update with `vitest -u` only if the change is intentional. 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 new file mode 100644 index 00000000..2cf3dd86 --- /dev/null +++ b/src/openapi-mcp-server/openapi/__tests__/__snapshots__/notion-spec.snapshot.test.ts.snap @@ -0,0 +1,381 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Notion OpenAPI spec -> MCP tools (snapshot) > generates a stable set of tool names 1`] = ` +[ + "create-a-comment", + "create-a-data-source", + "delete-a-block", + "get-block-children", + "get-self", + "get-user", + "get-users", + "list-data-source-templates", + "move-page", + "patch-block-children", + "patch-page", + "post-page", + "post-search", + "query-data-source", + "retrieve-a-block", + "retrieve-a-comment", + "retrieve-a-data-source", + "retrieve-a-database", + "retrieve-a-page", + "retrieve-a-page-property", + "update-a-block", + "update-a-data-source", +] +`; + +exports[`Notion OpenAPI spec -> MCP tools (snapshot) > generates stable tool definitions (name, method, description, params) 1`] = ` +[ + { + "description": "Notion | Create comment +Error Responses: +400: Bad request", + "httpMethod": "post", + "name": "create-a-comment", + "properties": [ + "parent", + "rich_text", + ], + "required": [ + "parent", + "rich_text", + ], + }, + { + "description": "Notion | Create a data source +Error Responses: +400: Bad request", + "httpMethod": "post", + "name": "create-a-data-source", + "properties": [ + "Notion-Version", + "parent", + "properties", + "title", + ], + "required": [ + "parent", + "properties", + ], + }, + { + "description": "Notion | Delete a block +Error Responses: +400: Bad request", + "httpMethod": "delete", + "name": "delete-a-block", + "properties": [ + "Notion-Version", + "block_id", + ], + "required": [ + "block_id", + ], + }, + { + "description": "Notion | Retrieve block children +Error Responses: +400: Bad request", + "httpMethod": "get", + "name": "get-block-children", + "properties": [ + "Notion-Version", + "block_id", + "page_size", + "start_cursor", + ], + "required": [ + "block_id", + ], + }, + { + "description": "Notion | Retrieve your token's bot user +Error Responses: +400: Bad request", + "httpMethod": "get", + "name": "get-self", + "properties": [ + "Notion-Version", + ], + "required": [], + }, + { + "description": "Notion | Retrieve a user +Error Responses: +400: 400", + "httpMethod": "get", + "name": "get-user", + "properties": [ + "Notion-Version", + "user_id", + ], + "required": [ + "user_id", + ], + }, + { + "description": "Notion | List all users +Error Responses: +400: 400", + "httpMethod": "get", + "name": "get-users", + "properties": [ + "Notion-Version", + "page_size", + "start_cursor", + ], + "required": [], + }, + { + "description": "Notion | List templates in a data source +Error Responses: +400: Bad request", + "httpMethod": "get", + "name": "list-data-source-templates", + "properties": [ + "Notion-Version", + "data_source_id", + "page_size", + "start_cursor", + ], + "required": [ + "data_source_id", + ], + }, + { + "description": "Notion | Move a page +Error Responses: +400: Bad request", + "httpMethod": "post", + "name": "move-page", + "properties": [ + "Notion-Version", + "page_id", + "parent", + ], + "required": [ + "page_id", + "parent", + ], + }, + { + "description": "Notion | Append block children +Error Responses: +400: Bad request", + "httpMethod": "patch", + "name": "patch-block-children", + "properties": [ + "Notion-Version", + "after", + "block_id", + "children", + ], + "required": [ + "block_id", + "children", + ], + }, + { + "description": "Notion | Update page properties +Error Responses: +400: Bad request", + "httpMethod": "patch", + "name": "patch-page", + "properties": [ + "Notion-Version", + "archived", + "cover", + "icon", + "in_trash", + "page_id", + "properties", + ], + "required": [ + "page_id", + ], + }, + { + "description": "Notion | Create a page +Error Responses: +400: Bad request", + "httpMethod": "post", + "name": "post-page", + "properties": [ + "Notion-Version", + "children", + "cover", + "icon", + "parent", + "properties", + ], + "required": [ + "parent", + "properties", + ], + }, + { + "description": "Notion | Search by title +Error Responses: +400: Bad request", + "httpMethod": "post", + "name": "post-search", + "properties": [ + "Notion-Version", + "filter", + "page_size", + "query", + "sort", + "start_cursor", + ], + "required": [], + }, + { + "description": "Notion | Query a data source +Error Responses: +400: Bad request", + "httpMethod": "post", + "name": "query-data-source", + "properties": [ + "Notion-Version", + "archived", + "data_source_id", + "filter", + "filter_properties", + "in_trash", + "page_size", + "sorts", + "start_cursor", + ], + "required": [ + "data_source_id", + ], + }, + { + "description": "Notion | Retrieve a block +Error Responses: +400: Bad request", + "httpMethod": "get", + "name": "retrieve-a-block", + "properties": [ + "Notion-Version", + "block_id", + ], + "required": [ + "block_id", + ], + }, + { + "description": "Notion | Retrieve comments +Error Responses: +400: Bad request", + "httpMethod": "get", + "name": "retrieve-a-comment", + "properties": [ + "Notion-Version", + "block_id", + "page_size", + "start_cursor", + ], + "required": [ + "block_id", + ], + }, + { + "description": "Notion | Retrieve a data source +Error Responses: +400: Bad request", + "httpMethod": "get", + "name": "retrieve-a-data-source", + "properties": [ + "Notion-Version", + "data_source_id", + ], + "required": [ + "data_source_id", + ], + }, + { + "description": "Notion | Retrieve a database +Error Responses: +400: Bad request", + "httpMethod": "get", + "name": "retrieve-a-database", + "properties": [ + "Notion-Version", + "database_id", + ], + "required": [ + "database_id", + ], + }, + { + "description": "Notion | Retrieve a page +Error Responses: +400: Bad request", + "httpMethod": "get", + "name": "retrieve-a-page", + "properties": [ + "Notion-Version", + "filter_properties", + "page_id", + ], + "required": [ + "page_id", + ], + }, + { + "description": "Notion | Retrieve a page property item +Error Responses: +400: Bad request", + "httpMethod": "get", + "name": "retrieve-a-page-property", + "properties": [ + "Notion-Version", + "page_id", + "page_size", + "property_id", + "start_cursor", + ], + "required": [ + "page_id", + "property_id", + ], + }, + { + "description": "Notion | Update a block +Error Responses: +400: Bad request", + "httpMethod": "patch", + "name": "update-a-block", + "properties": [ + "Notion-Version", + "archived", + "block_id", + "type", + ], + "required": [ + "block_id", + ], + }, + { + "description": "Notion | Update a data source +Error Responses: +400: Bad request", + "httpMethod": "patch", + "name": "update-a-data-source", + "properties": [ + "Notion-Version", + "data_source_id", + "description", + "properties", + "title", + ], + "required": [ + "data_source_id", + ], + }, +] +`; diff --git a/src/openapi-mcp-server/openapi/__tests__/notion-spec.snapshot.test.ts b/src/openapi-mcp-server/openapi/__tests__/notion-spec.snapshot.test.ts new file mode 100644 index 00000000..0caf8b21 --- /dev/null +++ b/src/openapi-mcp-server/openapi/__tests__/notion-spec.snapshot.test.ts @@ -0,0 +1,45 @@ +import fs from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import type { OpenAPIV3 } from 'openapi-types' +import { OpenAPIToMCPConverter } from '../parser' + +/** + * Locks the public MCP tool surface generated from the real Notion OpenAPI spec. + * + * This is the regression guard for the vendored OpenAPI -> MCP converter: any + * change that alters tool names, descriptions, the HTTP method (which drives + * read-only/destructive annotations), or the parameter surface will fail here. + * If a change is intentional, review the diff and update with `vitest -u`. + */ +describe('Notion OpenAPI spec -> MCP tools (snapshot)', () => { + const specPath = path.resolve(process.cwd(), 'scripts/notion-openapi.json') + const spec = JSON.parse(fs.readFileSync(specPath, 'utf-8')) as OpenAPIV3.Document + const { tools, openApiLookup } = new OpenAPIToMCPConverter(spec).convertToMCPTools() + const methods = tools['API']?.methods ?? [] + + const summary = methods + .map((method) => ({ + name: method.name, + httpMethod: openApiLookup[`API-${method.name}`]?.method, + description: method.description, + required: [...(method.inputSchema.required ?? [])].sort(), + properties: Object.keys(method.inputSchema.properties ?? {}).sort(), + })) + .sort((a, b) => a.name.localeCompare(b.name)) + + it('generates a stable set of tool names', () => { + expect(summary.map((tool) => tool.name)).toMatchSnapshot() + }) + + it('generates stable tool definitions (name, method, description, params)', () => { + expect(summary).toMatchSnapshot() + }) + + it('prefixes every tool description with "Notion | "', () => { + expect(summary.length).toBeGreaterThan(0) + for (const tool of summary) { + expect(tool.description.startsWith('Notion | ')).toBe(true) + } + }) +}) diff --git a/src/openapi-mcp-server/openapi/parser.ts b/src/openapi-mcp-server/openapi/parser.ts index 7f603484..1b209592 100644 --- a/src/openapi-mcp-server/openapi/parser.ts +++ b/src/openapi-mcp-server/openapi/parser.ts @@ -1,7 +1,5 @@ import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types' import type { JSONSchema7 as IJsonSchema } from 'json-schema' -import type { ChatCompletionTool } from 'openai/resources/chat/completions' -import type { Tool } from '@anthropic-ai/sdk/resources/messages/messages' type NewToolMethod = { name: string @@ -10,13 +8,6 @@ type NewToolMethod = { returnSchema?: IJsonSchema } -type FunctionParameters = { - type: 'object' - properties?: Record - required?: string[] - [key: string]: unknown -} - export class OpenAPIToMCPConverter { private schemaCache: Record = {} private nameCounter: number = 0 @@ -202,59 +193,6 @@ export class OpenAPIToMCPConverter { return { tools, openApiLookup, zip } } - /** - * Convert the OpenAPI spec to OpenAI's ChatCompletionTool format - */ - convertToOpenAITools(): ChatCompletionTool[] { - const tools: ChatCompletionTool[] = [] - - for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) { - if (!pathItem) continue - - for (const [method, operation] of Object.entries(pathItem)) { - if (!this.isOperation(method, operation)) continue - - const parameters = this.convertOperationToJsonSchema(operation, method, path) - const tool: ChatCompletionTool = { - type: 'function', - function: { - name: operation.operationId!, - description: this.getDescription(operation.summary || operation.description || ''), - parameters: parameters as FunctionParameters, - }, - } - tools.push(tool) - } - } - - return tools - } - - /** - * Convert the OpenAPI spec to Anthropic's Tool format - */ - convertToAnthropicTools(): Tool[] { - const tools: Tool[] = [] - - for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) { - if (!pathItem) continue - - for (const [method, operation] of Object.entries(pathItem)) { - if (!this.isOperation(method, operation)) continue - - const parameters = this.convertOperationToJsonSchema(operation, method, path) - const tool: Tool = { - name: operation.operationId!, - description: this.getDescription(operation.summary || operation.description || ''), - input_schema: parameters as Tool['input_schema'], - } - tools.push(tool) - } - } - - return tools - } - private convertComponentsToJsonSchema(): Record { const components = this.openApiSpec.components || {} const schema: Record = {} @@ -263,60 +201,6 @@ export class OpenAPIToMCPConverter { } return schema } - /** - * Helper method to convert an operation to a JSON Schema for parameters - */ - private convertOperationToJsonSchema( - operation: OpenAPIV3.OperationObject, - method: string, - path: string, - ): IJsonSchema & { type: 'object' } { - const schema: IJsonSchema & { type: 'object' } = { - type: 'object', - properties: {}, - required: [], - $defs: this.convertComponentsToJsonSchema(), - } - - // Handle parameters (path, query, header, cookie) - if (operation.parameters) { - for (const param of operation.parameters) { - const paramObj = this.resolveParameter(param) - if (paramObj && paramObj.schema) { - const paramSchema = this.convertOpenApiSchemaToJsonSchema(paramObj.schema, new Set()) - // Merge parameter-level description if available - if (paramObj.description) { - paramSchema.description = paramObj.description - } - schema.properties![paramObj.name] = paramSchema - if (paramObj.required) { - schema.required!.push(paramObj.name) - } - } - } - } - - // Handle requestBody - if (operation.requestBody) { - const bodyObj = this.resolveRequestBody(operation.requestBody) - if (bodyObj?.content) { - if (bodyObj.content['application/json']?.schema) { - const bodySchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content['application/json'].schema, new Set()) - if (bodySchema.type === 'object' && bodySchema.properties) { - for (const [name, propSchema] of Object.entries(bodySchema.properties)) { - schema.properties![name] = propSchema - } - if (bodySchema.required) { - schema.required!.push(...bodySchema.required) - } - } - } - } - } - - return schema - } - private isOperation(method: string, operation: any): operation is OpenAPIV3.OperationObject { return ['get', 'post', 'put', 'delete', 'patch'].includes(method.toLowerCase()) }