diff --git a/.github/workflows/notion-mcp.yaml b/.github/workflows/notion-mcp.yaml new file mode 100644 index 0000000..ae3c725 --- /dev/null +++ b/.github/workflows/notion-mcp.yaml @@ -0,0 +1,40 @@ +name: Notion MCP tests +on: + pull_request: + paths: + - "modules/notion-mcp/**" + - ".github/workflows/notion-mcp.yaml" + push: + branches: [main] + paths: + - "modules/notion-mcp/**" + - ".github/workflows/notion-mcp.yaml" + +jobs: + adapter: + runs-on: ubuntu-latest + defaults: + run: + working-directory: modules/notion-mcp + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "24" + - run: npm install --global pnpm@11.19.0 --ignore-scripts --no-audit --no-fund + - run: pnpm install --frozen-lockfile --ignore-scripts + - run: node --test tests/*.test.mjs + + terraform: + runs-on: ubuntu-latest + defaults: + run: + working-directory: modules/notion-mcp + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3 + with: + terraform_version: "1.13.5" + - run: terraform fmt -check + - run: terraform init -backend=false + - run: terraform test diff --git a/README.md b/README.md index 033a63b..bfffc9f 100644 --- a/README.md +++ b/README.md @@ -144,3 +144,12 @@ module "mandoline_mcp" { agent_id = coder_agent.main.id } ``` + +## Notion MCP + +[`modules/notion-mcp`](modules/notion-mcp) installs an optional-auth stdio adapter +for Notion's hosted MCP server using the workspace owner's Coder OAuth token. +Read-only is the default and enforces a reviewed content-read tool allowlist +on discovery and direct calls. Read-write is an explicit opt-in. The module +includes frozen dependencies, optional-auth guidance, and tests. See its README +for the distinction between adapter policy and the broader OAuth grant. diff --git a/modules/notion-mcp/README.md b/modules/notion-mcp/README.md new file mode 100644 index 0000000..daff304 --- /dev/null +++ b/modules/notion-mcp/README.md @@ -0,0 +1,139 @@ +# Notion MCP + +Installs a stdio MCP adapter for the workspace owner's optional Coder external +authentication. It connects to the official hosted server at +`https://mcp.notion.com/mcp` using MCP-issued OAuth tokens. A regular Notion +REST API integration token is not a substitute. + +## Usage + +Configure a generic Notion MCP OAuth provider in Coder first. Register a client +once through Notion's dynamic client registration endpoint and retain its +credentials. Use the advertised MCP authorization/token endpoints, scope +`default`, and S256 PKCE. Coder owns token storage and refresh; this module +creates no OAuth clients or account links. See the +[Notion MCP client guide](https://developers.notion.com/guides/mcp/build-mcp-client). + +```hcl +data "coder_external_auth" "notion" { + id = "notion" + optional = true +} + +module "notion_mcp" { + source = "./modules/notion-mcp" + agent_id = coder_agent.main.id + external_auth_id = data.coder_external_auth.notion.id + access_mode = "read-only" +} +``` + +Register the exported launcher command in your template's clients: + +```json +{ + "mcpServers": { + "notion": { "command": "coder-notion-mcp" } + } +} +``` + +Codex filters stdio environments. Forward the Coder context by name: + +```toml +[mcp_servers.notion] +command = "coder-notion-mcp" +env_vars = ["CODER", "CODER_AGENT_TOKEN", "CODER_AGENT_URL", "GIT_ASKPASS"] +startup_timeout_sec = 30 +``` + +Only names belong in client configuration, never credential values. Clients +inside another container need access to the launcher and Coder context. +Wait for **Install Notion MCP** to finish before starting clients on first use; +a client that cached a missing-launcher failure needs to reconnect/restart. + +## Read-only policy + +The default `access_mode = "read-only"` exposes only these reviewed tools when +they are available upstream: + +- `notion-search` and `notion-fetch` (plus official `search`/`fetch` aliases) +- `notion-get-comments` +- `notion-query-data-sources` and `notion-query-multiple-data-sources` +- `notion-get-tool-access` + +The same allowlist is enforced on every incoming `tools/call` before fetching +credentials or contacting Notion. Unknown tools are denied regardless of name +prefix or `readOnlyHint`. Direct/stale calls to editing, comments writes, +Custom Agent execution, user-directory tools, and other non-allowlisted tools +return `isError: true`. Discovery traverses all pages, including write-only +pages, to find the permitted tools. + +Search discovery permits only `query_type: internal`; calls requesting user +lookups or unknown query types are rejected. Omitted query type is explicitly +forwarded as `internal`. The legacy fetch `id: self` identity lookup is blocked. +This narrows tool operations, not all data embedded in page results: page +content and metadata can contain names, emails, comments, or meeting transcripts. + +**Content search can include connected sources**, depending on Notion's plan +and search behavior. `internal` distinguishes content search from user lookup; +it does not guarantee Notion-only results. This adapter is not a page-access, +connected-source, or OAuth-scope boundary. Notion enforces the linked user's +permissions. See [Notion's tools](https://developers.notion.com/guides/mcp/mcp-supported-tools). + +Setting `access_mode = "read-write"` exposes and forwards all tools permitted +by the grant, including actions that may use Notion credits. Neither mode +changes the underlying OAuth token's permissions. Restart clients after changing +mode. If provider-enforced read-only/page-scoped credentials are required, use +a separately scoped Notion API connection with an API-backed implementation. + +## Optional authentication + +Unlinked accounts initialize and respond to ping with no tools and no upstream +requests. Allowed calls return guidance linking the account in Coder. When +Coder supplies a linking URL, the adapter includes it in the reply and prints +it once on stderr, stripping query parameters and raw subprocess diagnostics. +Otherwise the reply gives the CLI command to obtain the link. + +The adapter checks authentication every 30 seconds and sends tool-list-change +notifications after linking/unlinking. Clients that cache tools may need to +reconnect. HTTP 401/403 hides tools without launching a separate browser OAuth +flow; upstream outages produce sanitized errors and recover on later calls. + +Every upstream request fetches a current token with +`coder external-auth access-token `. Tokens remain in memory, +never client config, command arguments, or Terraform values. Coder handles +rotation and reconnect requirements. Requests use only the fixed official MCP +URL; redirects are rejected. + +## Installation and interface + +Standalone pnpm is required on PATH or at `/usr/local/share/pnpm/bin/pnpm`, +with Node environment management. Both pnpm 10 (`PNPM_HOME/node`) and pnpm 11 +(`PNPM_HOME/bin/node`) runtime layouts are supported. The blocking startup script provisions +user-local Node 24 and the official MCP SDK from a frozen lockfile, with +lifecycle scripts disabled. First installation needs Node/npm network access; +subsequent starts reuse dependencies when the lockfile is unchanged. Account +linking is not required for installation. + +Inputs: `agent_id` (required), `external_auth_id` (default `notion`), +`access_mode` (default `read-only`, also accepts `read-write`). +Outputs: `launcher_command`, `$HOME`-relative `launcher_path`, `access_mode`. + +## Validation + +```sh +cd modules/notion-mcp +pnpm install --frozen-lockfile --ignore-scripts +node --test tests/*.test.mjs +terraform init -backend=false +terraform test +``` + +Installer tests cover both runtime layouts on fresh installs and restarts, +including failure when pnpm produces no usable Node. Adapter tests cover +unlinked/expired authentication, URL guidance, current-token +retrieval, reconnects, discovery pagination, explicit read-only enforcement, +user-lookup rejection, and read-write calls to a simulated upstream. Live +validation should list tools and read a page; never exercise real write or +agent-execution tools merely to test read-only enforcement. diff --git a/modules/notion-mcp/main.tf b/modules/notion-mcp/main.tf new file mode 100644 index 0000000..9f4d8b1 --- /dev/null +++ b/modules/notion-mcp/main.tf @@ -0,0 +1,30 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} + +resource "coder_env" "notion_mcp_path" { + agent_id = var.agent_id + name = "PATH" + value = "$HOME/.local/bin:$PATH" + merge_strategy = "prepend" +} + +resource "coder_script" "notion_mcp" { + agent_id = var.agent_id + display_name = "Install Notion MCP" + run_on_start = true + start_blocks_login = true + timeout = 300 + script = templatefile("${path.module}/scripts/install.sh.tpl", { + external_auth_id = var.external_auth_id + access_mode = var.access_mode + adapter_js = file("${path.module}/scripts/notion-mcp.mjs") + package_json = file("${path.module}/package.json") + pnpm_lock = file("${path.module}/pnpm-lock.yaml") + dependency_hash = sha256(file("${path.module}/pnpm-lock.yaml")) + }) +} diff --git a/modules/notion-mcp/main.tftest.hcl b/modules/notion-mcp/main.tftest.hcl new file mode 100644 index 0000000..9d46442 --- /dev/null +++ b/modules/notion-mcp/main.tftest.hcl @@ -0,0 +1,73 @@ +mock_provider "coder" {} + +run "defaults" { + command = plan + variables { + agent_id = "test-agent" + } + assert { + condition = output.launcher_command == "coder-notion-mcp" + error_message = "Clients must receive a stable launcher command." + } + assert { + condition = strcontains(coder_script.notion_mcp.script, "--frozen-lockfile --ignore-scripts") + error_message = "The installer must use the frozen dependency graph without lifecycle scripts." + } + assert { + condition = strcontains(coder_script.notion_mcp.script, "https://mcp.notion.com/mcp") + error_message = "Only the official Notion endpoint is supported." + } +} + +run "default_read_only" { + command = plan + variables { + agent_id = "test-agent" + } + assert { + condition = strcontains(coder_script.notion_mcp.script, "'notion' 'read-only'") + error_message = "The module must default to read-only access." + } +} + +run "custom_provider" { + command = plan + variables { + agent_id = "test-agent" + external_auth_id = "notion-secondary" + } + assert { + condition = strcontains(coder_script.notion_mcp.script, "'notion-secondary'") + error_message = "The launcher must pass the configured external-auth ID." + } +} + +run "reject_shell_characters" { + command = plan + variables { + agent_id = "test-agent" + external_auth_id = "notion;false" + } + expect_failures = [var.external_auth_id] +} + +run "read_write_mode" { + command = plan + variables { + agent_id = "test-agent" + access_mode = "read-write" + } + assert { + condition = strcontains(coder_script.notion_mcp.script, "'notion' 'read-write'") + error_message = "The launcher must pass the selected access mode to the adapter." + } +} + +run "reject_invalid_mode" { + command = plan + variables { + agent_id = "test-agent" + access_mode = "custom-url" + } + expect_failures = [var.access_mode] +} diff --git a/modules/notion-mcp/outputs.tf b/modules/notion-mcp/outputs.tf new file mode 100644 index 0000000..53279d3 --- /dev/null +++ b/modules/notion-mcp/outputs.tf @@ -0,0 +1,14 @@ +output "launcher_command" { + description = "MCP stdio command on the agent PATH, usable in Claude Code, Codex, or other clients." + value = "coder-notion-mcp" +} + +output "launcher_path" { + description = "User-scoped launcher location. Expand HOME before using this as an absolute client command." + value = "$HOME/.local/bin/coder-notion-mcp" +} + +output "access_mode" { + description = "Selected MCP access mode. This does not change OAuth token permissions." + value = var.access_mode +} diff --git a/modules/notion-mcp/package.json b/modules/notion-mcp/package.json new file mode 100644 index 0000000..fb3958e --- /dev/null +++ b/modules/notion-mcp/package.json @@ -0,0 +1,9 @@ +{ + "name": "coder-notion-mcp", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "@modelcontextprotocol/sdk": "1.32.1" + } +} diff --git a/modules/notion-mcp/pnpm-lock.yaml b/modules/notion-mcp/pnpm-lock.yaml new file mode 100644 index 0000000..f38b5eb --- /dev/null +++ b/modules/notion-mcp/pnpm-lock.yaml @@ -0,0 +1,786 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@modelcontextprotocol/sdk': + specifier: 1.32.1 + version: 1.32.1(zod@4.6.7) + +packages: + + '@hono/node-server@2.1.4': + resolution: {integrity: sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 || ^5.0.0-0 + + '@modelcontextprotocol/sdk@1.32.1': + resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + express-rate-limit@8.7.1: + resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.13.13: + resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.7.4: + resolution: {integrity: sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + +snapshots: + + '@hono/node-server@2.1.4(hono@4.13.13)': + dependencies: + hono: 4.13.13 + + '@modelcontextprotocol/sdk@1.32.1(zod@4.6.7)': + dependencies: + '@hono/node-server': 2.1.4(hono@4.13.13) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.3.0 + express-rate-limit: 8.7.1(express@5.3.0) + hono: 4.13.13 + jose: 6.2.12 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.6.7 + zod-to-json-schema: 3.25.2(zod@4.6.7) + transitivePeerDependencies: + - supports-color + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + content-disposition@2.0.1: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + express-rate-limit@8.7.1(express@5.3.0): + dependencies: + debug: 4.4.3 + express: 5.3.0 + ip-address: 10.7.4 + transitivePeerDependencies: + - supports-color + + express@5.3.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 2.0.1 + content-type: 2.1.0 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.8: {} + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.13.13: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.7.4: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.2.12: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + + pkce-challenge@5.0.1: {} + + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + require-from-string@2.0.2: {} + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + statuses@2.0.2: {} + + toidentifier@1.0.1: {} + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrappy@1.0.2: {} + + zod-to-json-schema@3.25.2(zod@4.6.7): + dependencies: + zod: 4.6.7 + + zod@4.6.7: {} diff --git a/modules/notion-mcp/scripts/install.sh.tpl b/modules/notion-mcp/scripts/install.sh.tpl new file mode 100644 index 0000000..f4e91f2 --- /dev/null +++ b/modules/notion-mcp/scripts/install.sh.tpl @@ -0,0 +1,41 @@ +#!/bin/sh +# Install without requesting credentials; linking Notion is optional. +set -eu +umask 077 +PNPM=/usr/local/share/pnpm/bin/pnpm +[ -x "$PNPM" ] || PNPM="$(command -v pnpm)" || { echo 'Standalone pnpm is required to install Notion MCP.' >&2; exit 1; } +notion_dir="$HOME/.local/share/coder-notion-mcp" +export PNPM_HOME="$notion_dir/pnpm" +mkdir -p "$PNPM_HOME/bin" "$HOME/.local/bin" "$notion_dir/app" +export PATH="$PNPM_HOME/bin:$PNPM_HOME:$PATH" +if [ ! -x "$PNPM_HOME/bin/node" ] && [ ! -x "$PNPM_HOME/node" ]; then + "$PNPM" env use --global 24 +fi +# pnpm 10 installs into PNPM_HOME; pnpm 11 uses PNPM_HOME/bin. +if [ ! -x "$PNPM_HOME/bin/node" ] && [ ! -x "$PNPM_HOME/node" ]; then + echo 'pnpm did not install a usable Node runtime for Notion MCP.' >&2 + exit 1 +fi +cat > "$notion_dir/app/package.json" <<'PACKAGE' +${package_json} +PACKAGE +cat > "$notion_dir/app/pnpm-lock.yaml" <<'LOCK' +${pnpm_lock} +LOCK +if [ ! -d "$notion_dir/app/node_modules/@modelcontextprotocol/sdk" ] || \ + [ "$(cat "$notion_dir/dependency-hash" 2>/dev/null || true)" != '${dependency_hash}' ]; then + "$PNPM" --dir "$notion_dir/app" install --frozen-lockfile --ignore-scripts + printf '%s\n' '${dependency_hash}' > "$notion_dir/dependency-hash" +fi +cat > "$notion_dir/app/notion-mcp.mjs" <<'ADAPTER' +${adapter_js} +ADAPTER +cat > "$HOME/.local/bin/coder-notion-mcp" <<'LAUNCHER' +#!/bin/sh +notion_node="$HOME/.local/share/coder-notion-mcp/pnpm/bin/node" +[ -x "$notion_node" ] || notion_node="$HOME/.local/share/coder-notion-mcp/pnpm/node" +exec "$notion_node" \ + "$HOME/.local/share/coder-notion-mcp/app/notion-mcp.mjs" '${external_auth_id}' '${access_mode}' +LAUNCHER +chmod 0755 "$HOME/.local/bin/coder-notion-mcp" +echo 'Notion MCP installed (${access_mode}); unlinked accounts expose no tools.' diff --git a/modules/notion-mcp/scripts/notion-mcp.mjs b/modules/notion-mcp/scripts/notion-mcp.mjs new file mode 100644 index 0000000..3fad72b --- /dev/null +++ b/modules/notion-mcp/scripts/notion-mcp.mjs @@ -0,0 +1,235 @@ +// A healthy stdio MCP server even when optional Coder external auth is absent. +// Credentials are acquired at runtime and never written to client config. +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { pathToFileURL } from 'node:url'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { CallToolRequestSchema, ListToolsRequestSchema, McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; + +const run = promisify(execFile); +export const NOTION_URL = 'https://mcp.notion.com/mcp'; +// Reviewed content-read tools only; never trust prefixes or annotations. +const readTools = new Set([ + 'notion-search', 'notion-fetch', 'notion-get-comments', + 'notion-query-data-sources', 'notion-query-multiple-data-sources', + 'notion-get-tool-access', + // Official aliases for clients identified as OpenAI clients. + 'search', 'fetch', +]); +export function toolAllowed(name, accessMode) { + return accessMode === 'read-write' || readTools.has(name); +} +function callAllowed(params, accessMode) { + if (!toolAllowed(params.name, accessMode)) return false; + if (accessMode === 'read-write') return true; + const args = params.arguments || {}; + if (['notion-search', 'search'].includes(params.name) && + args.query_type !== undefined && args.query_type !== 'internal') return false; + // Legacy fetch alias for connection identity includes user/email data. + if (['notion-fetch', 'fetch'].includes(params.name) && args.id === 'self') return false; + return true; +} +function readTool(tool) { + const result = { ...tool, annotations: { ...tool.annotations, readOnlyHint: true } }; + if (['notion-search', 'search'].includes(tool.name)) { + const schema = tool.inputSchema; + result.inputSchema = { ...schema, properties: { ...schema.properties, + query_type: { type: 'string', enum: ['internal'], description: 'Content search only; user lookup is disabled.' }, + } }; + result.description = 'Search content (user lookup disabled). Notion may also search connected sources. ' + (tool.description || ''); + } + return result; +} +export class AuthUnavailable extends Error { + constructor(authUrl) { + super('Notion authentication unavailable'); + this.authUrl = authUrl; + } +} + +export function authenticationUrl(stdout, providerId) { + try { + const url = new URL(stdout.trim()); + if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || + url.pathname !== `/external-auth/${providerId}`) return undefined; + // Only return the Coder linking page, never query parameters or raw + // subprocess output that could contain credentials or diagnostics. + url.search = ''; + url.hash = ''; + return url.href; + } catch { return undefined; } +} + +export async function coderToken(providerId) { + try { + const { stdout } = await run(process.env.GIT_ASKPASS || 'coder', + ['external-auth', 'access-token', providerId], { timeout: 10000, maxBuffer: 65536 }); + const token = stdout.trim(); + if (!token || /\s/.test(token)) throw new AuthUnavailable(); + return token; + } catch (error) { + // Coder prints the linking URL to stdout and exits 1 when unlinked. + // Parse only that exact URL shape; never relay raw stdout/stderr. + throw new AuthUnavailable(authenticationUrl(error.stdout || '', providerId)); + } +} + +export function createAdapter({ providerId = 'notion', accessMode = 'read-only', getToken = () => coderToken(providerId), fetchImpl = fetch, log = message => console.error(message) } = {}) { + const upstreamUrl = NOTION_URL; + if (!['read-only', 'read-write'].includes(accessMode)) throw new Error('Invalid Notion access mode'); + const server = new Server({ name: 'coder-notion-mcp', version: '1.0.0' }, { + capabilities: { tools: { listChanged: true } }, + }); + let client; + let connecting; + let initialized = false; + let closed = false; + let poll; + let shutdownPromise; + let reportedAuthUrl; + const notify = () => { + if (initialized && !closed) server.sendToolListChanged().catch(() => {}); + }; + const disconnect = async () => { + const previous = client; + client = undefined; + if (previous) { + await previous.close().catch(() => {}); + notify(); + } + }; + const authenticatedFetch = async (url, options) => { + // No configurable upstream and no redirects: credentials go only to the + // selected official endpoint. No OAuth provider/browser flow is used. + if (String(url) !== upstreamUrl) throw new Error('Unexpected MCP endpoint'); + const token = await getToken(); + const headers = new Headers(options?.headers); + headers.set('authorization', `Bearer ${token}`); + const response = await fetchImpl(url, { ...options, headers, redirect: 'error' }); + if (response.status === 401 || response.status === 403) { + await response.body?.cancel(); + throw new AuthUnavailable(); + } + return response; + }; + const ensureClient = async () => { + if (closed) throw new Error('Adapter closed'); + // Check even when a session exists: account unlinking must hide tools. + await getToken(); + if (closed) throw new Error('Adapter closed'); + if (client) return client; + if (!connecting) connecting = (async () => { + const candidate = new Client({ name: 'coder-notion-mcp', version: '1.0.0' }); + try { + await candidate.connect(new StreamableHTTPClientTransport(new URL(upstreamUrl), { + fetch: authenticatedFetch, + reconnectionOptions: { maxRetries: 0 }, + })); + if (closed) throw new Error('Adapter closed'); + client = candidate; + notify(); + return candidate; + } catch (error) { + await candidate.close().catch(() => {}); + throw error; + } + })().finally(() => { connecting = undefined; }); + return connecting; + }; + const reportAuth = error => { + if (error instanceof AuthUnavailable && error.authUrl && error.authUrl !== reportedAuthUrl) { + reportedAuthUrl = error.authUrl; + log(`Notion MCP: link your account at ${error.authUrl}`); + } + }; + const unavailable = async error => { + await disconnect(); + if (error instanceof AuthUnavailable) { + reportAuth(error); + return true; + } + // Avoid returning SDK errors or upstream response bodies to the client. + throw new McpError(ErrorCode.InternalError, 'Notion MCP is temporarily unavailable. Try again later.'); + }; + server.setRequestHandler(ListToolsRequestSchema, async (request, extra) => { + try { + const remote = await ensureClient(); + if (accessMode === 'read-write') return await remote.listTools(request.params, { signal: extra.signal }); + // A page containing only write tools must not hide later read tools. + const tools = []; + const seen = new Set(); + let cursor = request.params?.cursor; + do { + const page = await remote.listTools({ ...request.params, cursor }, { signal: extra.signal }); + tools.push(...page.tools.filter(tool => toolAllowed(tool.name, accessMode)).map(readTool)); + cursor = page.nextCursor; + if (cursor && seen.has(cursor)) throw new Error('Repeated upstream cursor'); + seen.add(cursor); + } while (cursor); + return { tools }; + } catch (error) { + if (await unavailable(error)) return { tools: [] }; + } + }); + server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + // Enforce before acquiring credentials or calling Notion. Hiding a write + // tool from discovery alone does not stop stale or handcrafted calls. + if (!callAllowed(request.params, accessMode)) return { + isError: true, + content: [{ type: 'text', text: `Tool ${request.params.name} is disabled in Notion MCP read-only mode.` }], + }; + try { + const params = accessMode === 'read-only' && ['notion-search', 'search'].includes(request.params.name) + ? { ...request.params, arguments: { ...request.params.arguments, query_type: 'internal' } } + : request.params; + return await (await ensureClient()).callTool(params, undefined, { signal: extra.signal }); + } catch (error) { + if (await unavailable(error)) return { + isError: true, + content: [{ type: 'text', text: error.authUrl + ? `Open ${error.authUrl} to link Notion in Coder, then retry. Authentication is optional for this workspace.` + : `Link Notion in Coder to use Notion tools. Run \`coder external-auth access-token ${providerId}\` in the workspace to obtain the authentication URL. Authentication is optional for this workspace.` }], + }; + } + }); + // Clients can discover newly linked accounts without restarting. Also + // detect account removal while idle; revoked tokens are caught by fetch. + const checkAuth = async () => { + try { await ensureClient(); } catch (error) { reportAuth(error); await disconnect(); } + }; + server.oninitialized = () => { + initialized = true; + poll = setInterval(() => { checkAuth(); }, 30000); + poll.unref(); + // Defer until the initial stdio handshake has completed. + setImmediate(() => { checkAuth(); }); + }; + const close = () => shutdownPromise ??= (async () => { + closed = true; + clearInterval(poll); + await disconnect(); + await connecting?.catch(() => {}); + await server.close(); + })(); + return { server, checkAuth, close }; +} + +async function main() { + const providerId = process.argv[2] || 'notion'; + if (!/^[a-z0-9][a-z0-9-]*$/.test(providerId)) throw new Error('Invalid provider ID'); + const adapter = createAdapter({ providerId, accessMode: process.argv[3] || 'read-only' }); + await adapter.server.connect(new StdioServerTransport()); + process.stdin.once('end', () => { adapter.close(); }); + for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { + adapter.close().finally(() => process.exit(0)); + }); +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(() => { + console.error('Notion MCP: unable to start the adapter.'); + process.exitCode = 1; + }); +} diff --git a/modules/notion-mcp/tests/adapter.test.mjs b/modules/notion-mcp/tests/adapter.test.mjs new file mode 100644 index 0000000..ef79dc1 --- /dev/null +++ b/modules/notion-mcp/tests/adapter.test.mjs @@ -0,0 +1,320 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; +import { AuthUnavailable, authenticationUrl, createAdapter, NOTION_URL } from '../scripts/notion-mcp.mjs'; + +function fakeNotion(accessMode = 'read-only') { + const endpoint = NOTION_URL; + const requests = []; + const fetchImpl = async (url, options) => { + assert.equal(String(url), endpoint); + assert.equal(options.redirect, 'error'); + requests.push({ method: options.method, token: options.headers.get('authorization'), body: options.body }); + if (options.method !== 'POST') return new Response(null, { status: 405 }); + const message = JSON.parse(options.body); + if (!('id' in message)) return new Response(null, { status: 202 }); + let result; + if (message.method === 'initialize') result = { + protocolVersion: message.params.protocolVersion, + capabilities: { tools: {} }, serverInfo: { name: 'fake-notion', version: '1' }, + }; + else if (message.method === 'tools/list') result = { + tools: [ + { name: 'notion-fetch', description: 'Read pages', inputSchema: { type: 'object' } }, + { name: 'notion-create-pages', inputSchema: { type: 'object' } }, + { name: 'notion-update-page', inputSchema: { type: 'object' } }, + { name: 'notion-spawn-session', inputSchema: { type: 'object' } }, + { name: 'notion-future-write', annotations: { readOnlyHint: true }, inputSchema: { type: 'object' } }, + ], + }; + else result = { content: [{ type: 'text', text: 'Example page' }] }; + return Response.json({ jsonrpc: '2.0', id: message.id, result }); + }; + return { fetchImpl, requests }; +} + +async function connect(t, options) { + const adapter = createAdapter(options); + const client = new Client({ name: 'test', version: '1' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await adapter.server.connect(serverTransport); + await client.connect(clientTransport); + t.after(async () => { await adapter.close(); await client.close(); }); + return { adapter, client }; +} + +test('unlinked accounts initialize successfully and expose zero tools without network calls', async t => { + const { client } = await connect(t, { + getToken: async () => { throw new AuthUnavailable(); }, + fetchImpl: async () => assert.fail('unlinked accounts must not contact Notion'), + }); + assert.deepEqual(await client.listTools(), { tools: [] }); + await client.ping(); + const call = await client.callTool({ name: 'notion-fetch', arguments: {} }); + assert.equal(call.isError, true); + assert.match(call.content[0].text, /Link Notion in Coder/); +}); + +test('linking and unlinking update tools on the same MCP connection', async t => { + let linked = false; + const remote = fakeNotion(); + const { adapter, client } = await connect(t, { + getToken: async () => { if (!linked) throw new AuthUnavailable(); return 'fake-token'; }, + fetchImpl: remote.fetchImpl, + }); + assert.deepEqual((await client.listTools()).tools, []); + let changes = 0; + client.setNotificationHandler(ToolListChangedNotificationSchema, () => { changes++; }); + linked = true; + await adapter.checkAuth(); + assert.equal((await client.listTools()).tools[0].name, 'notion-fetch'); + assert.equal((await client.callTool({ name: 'notion-fetch', arguments: {} })).content[0].text, 'Example page'); + linked = false; + await adapter.checkAuth(); + assert.deepEqual((await client.listTools()).tools, []); + await client.ping(); + assert.ok(changes >= 2); +}); + +test('each upstream request retrieves a fresh Coder token, including long sessions', async t => { + let credentials = 0; + const remote = fakeNotion(); + const { client } = await connect(t, { + getToken: async () => `token-${++credentials}`, fetchImpl: remote.fetchImpl, + }); + await client.listTools(); + await client.callTool({ name: 'notion-fetch', arguments: {} }); + const tokens = remote.requests.map(request => request.token); + assert.ok(tokens.length >= 3); + assert.equal(new Set(tokens).size, tokens.length); +}); + +test('rejected OAuth credentials leave a healthy empty MCP without browser authentication', async t => { + const { client } = await connect(t, { + getToken: async () => 'revoked-token', + fetchImpl: async () => new Response('sensitive upstream body', { + status: 401, headers: { 'www-authenticate': 'Bearer resource_metadata="https://example.invalid"' }, + }), + }); + assert.deepEqual((await client.listTools()).tools, []); + await client.ping(); +}); + +test('upstream outages produce sanitized errors and recover on a subsequent request', async t => { + const remote = fakeNotion(); + let failing = true; + const { client } = await connect(t, { + getToken: async () => 'fake-token', + fetchImpl: async (...args) => { if (failing) throw new Error('sensitive-error-body'); return remote.fetchImpl(...args); }, + }); + await assert.rejects(client.listTools(), error => { + assert.match(error.message, /temporarily unavailable/); + assert.ok(!error.message.includes('sensitive-error-body')); + return true; + }); + failing = false; + assert.equal((await client.listTools()).tools[0].name, 'notion-fetch'); +}); + +test('actual stdio launcher remains healthy when the Coder subprocess is not authenticated', async t => { + const directory = await mkdtemp(join(tmpdir(), 'coder-notion-unlinked-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const fakeCoder = join(directory, 'coder'); + await writeFile(fakeCoder, '#!/bin/sh\necho sensitive-subprocess-error >&2\nexit 1\n', { mode: 0o700 }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [new URL('../scripts/notion-mcp.mjs', import.meta.url).pathname, 'custom-notion'], + env: { GIT_ASKPASS: fakeCoder }, stderr: 'pipe', + }); + let stderr = ''; + transport.stderr.on('data', chunk => { stderr += chunk; }); + const client = new Client({ name: 'stdio-test', version: '1' }); + t.after(() => client.close()); + await client.connect(transport); + assert.deepEqual((await client.listTools()).tools, []); + await client.ping(); + await client.close(); + assert.equal(stderr, ''); +}); + +for (const mode of ['read-only', 'read-write']) { + test(`${mode} selects its fixed official endpoint and corresponding tools`, async t => { + const remote = fakeNotion(mode); + const { client } = await connect(t, { + accessMode: mode, getToken: async () => 'fake-token', fetchImpl: remote.fetchImpl, + }); + const names = (await client.listTools()).tools.map(tool => tool.name); + assert.deepEqual(names, mode === 'read-only' ? ['notion-fetch'] : ['notion-fetch', 'notion-create-pages', 'notion-update-page', 'notion-spawn-session', 'notion-future-write']); + }); +} + +test('invalid access modes cannot select an arbitrary endpoint', () => { + assert.throws(() => createAdapter({ accessMode: 'https://example.invalid' }), /Invalid Notion access mode/); +}); + +test('unlinked tool replies include Coder linking URL and diagnostics are emitted once', async t => { + const url = 'https://coder.example.test/external-auth/notion'; + const messages = []; + const { adapter, client } = await connect(t, { + getToken: async () => { throw new AuthUnavailable(url); }, + log: message => messages.push(message), + fetchImpl: async () => assert.fail('must not contact Notion'), + }); + assert.deepEqual((await client.listTools()).tools, []); + const result = await client.callTool({ name: 'notion-fetch', arguments: {} }); + assert.equal(result.isError, true); + assert.ok(result.content[0].text.includes(url)); + await adapter.checkAuth(); + await client.ping(); + assert.deepEqual(messages, [`Notion MCP: link your account at ${url}`]); +}); + +test('authentication URL parsing strips queries and rejects arbitrary subprocess output', () => { + assert.equal(authenticationUrl('https://coder.example.test/external-auth/notion?secret=redacted#fragment', 'notion'), + 'https://coder.example.test/external-auth/notion'); + for (const output of ['sensitive-token', 'https://example.test/not-a-link', + 'https://user:password@coder.example.test/external-auth/notion', + 'Error: https://coder.example.test/external-auth/notion']) { + assert.equal(authenticationUrl(output, 'notion'), undefined); + } +}); + +test('real Coder CLI exit-one URL reaches the stdio tool result without leaking diagnostics', async t => { + const directory = await mkdtemp(join(tmpdir(), 'coder-notion-url-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const fakeCoder = join(directory, 'coder'); + await writeFile(fakeCoder, '#!/bin/sh\necho https://coder.example.test/external-auth/custom-notion?secret=redacted\necho sensitive-error >&2\nexit 1\n', { mode: 0o700 }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [new URL('../scripts/notion-mcp.mjs', import.meta.url).pathname, 'custom-notion'], + env: { GIT_ASKPASS: fakeCoder }, stderr: 'pipe', + }); + let stderr = ''; + transport.stderr.on('data', chunk => { stderr += chunk; }); + const client = new Client({ name: 'url-test', version: '1' }); + t.after(() => client.close()); + await client.connect(transport); + const result = await client.callTool({ name: 'notion-fetch', arguments: {} }); + assert.ok(result.content[0].text.includes('https://coder.example.test/external-auth/custom-notion')); + assert.ok(!result.content[0].text.includes('secret=')); + await client.close(); + assert.ok(stderr.includes('https://coder.example.test/external-auth/custom-notion')); + assert.ok(!stderr.includes('secret=') && !stderr.includes('sensitive-error')); +}); + +test('read-only blocks direct, stale, and unknown write calls before they reach Notion', async t => { + const remote = fakeNotion(); + const { client } = await connect(t, { + accessMode: 'read-only', getToken: async () => 'write-capable-fake-token', fetchImpl: remote.fetchImpl, + }); + assert.deepEqual((await client.listTools()).tools.map(tool => tool.name), ['notion-fetch']); + for (const name of ['notion-create-pages', 'notion-update-page', 'notion-spawn-session', 'notion-future-write', 'notion-read-and-delete']) { + const result = await client.callTool({ name, arguments: {} }); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /disabled.*read-only/); + } + assert.equal(remote.requests.filter(r => r.body && JSON.parse(r.body).method === 'tools/call').length, 0); + assert.equal((await client.callTool({ name: 'notion-fetch', arguments: {} })).content[0].text, 'Example page'); +}); + +test('read-write forwards explicitly enabled write calls to the simulated upstream', async t => { + const remote = fakeNotion(); + const { client } = await connect(t, { + accessMode: 'read-write', getToken: async () => 'fake-token', fetchImpl: remote.fetchImpl, + }); + assert.equal((await client.callTool({ name: 'notion-create-pages', arguments: {} })).isError, undefined); + assert.ok(remote.requests.some(r => r.body && JSON.parse(r.body).params?.name === 'notion-create-pages')); +}); + +test('read-only discovery traverses a write-only page to find later read tools', async t => { + const remote = fakeNotion(); + let pages = 0; + const { client } = await connect(t, { + getToken: async () => 'fake-token', + fetchImpl: async (url, options) => { + const message = options.body ? JSON.parse(options.body) : undefined; + if (message?.method !== 'tools/list') return remote.fetchImpl(url, options); + pages++; + const result = message.params?.cursor === 'page-two' + ? { tools: [{ name: 'notion-get-comments', inputSchema: { type: 'object' } }] } + : { tools: [{ name: 'notion-create-pages', inputSchema: { type: 'object' } }], nextCursor: 'page-two' }; + return Response.json({ jsonrpc: '2.0', id: message.id, result }); + }, + }); + const result = await client.listTools(); + assert.deepEqual(result.tools.map(tool => tool.name), ['notion-get-comments']); + assert.equal(result.nextCursor, undefined); + assert.equal(pages, 2); +}); + +test('read-write discovery sanitizes auth rejection after a session is established', async t => { + const remote = fakeNotion(); + let revoked = false; + const { client } = await connect(t, { + accessMode: 'read-write', getToken: async () => 'fake-token', + fetchImpl: async (url, options) => revoked + ? new Response('sensitive Notion response', { status: 401 }) + : remote.fetchImpl(url, options), + }); + assert.ok((await client.listTools()).tools.length > 1); + revoked = true; + assert.deepEqual((await client.listTools()).tools, []); + await client.ping(); +}); + +test('read-only blocks user lookup and legacy identity fetch before authentication', async t => { + const { client } = await connect(t, { + getToken: async () => { throw new Error('must not retrieve credentials'); }, + fetchImpl: async () => assert.fail('must not forward disallowed reads'), + }); + for (const name of ['notion-search', 'search']) { + for (const query_type of ['user', 'future-type']) { + assert.equal((await client.callTool({ name, arguments: { query: 'example', query_type } })).isError, true); + } + } + for (const name of ['notion-fetch', 'fetch']) { + assert.equal((await client.callTool({ name, arguments: { id: 'self' } })).isError, true); + } +}); + +test('read-only excludes identity, agents, memory, and future tools despite annotations', async t => { + const { client } = await connect(t, { + getToken: async () => { throw new Error('must not retrieve credentials'); }, + fetchImpl: async () => assert.fail('must not forward blocked tools'), + }); + for (const name of ['notion-get-users', 'notion-get-self', 'notion-get-teams', 'notion-get-groups', + 'notion-spawn-session', 'notion-send-message-to-session', 'notion-search-agents', + 'notion-memory-search', 'notion-check-mcp-next-steps', 'notion-ai-search', 'ai-search']) { + assert.equal((await client.callTool({ name, arguments: {} })).isError, true); + } +}); + +test('read-only narrows search discovery and forces internal content queries', async t => { + const remote = fakeNotion(); + const { client } = await connect(t, { + getToken: async () => 'fake-token', + fetchImpl: async (url, options) => { + const msg = options.body ? JSON.parse(options.body) : undefined; + if (msg?.method !== 'tools/list') return remote.fetchImpl(url, options); + return Response.json({ jsonrpc: '2.0', id: msg.id, result: { tools: + ['notion-search', 'search'].map(name => ({ name, inputSchema: { type: 'object', + properties: { query: { type: 'string' }, query_type: { type: 'string', enum: ['internal', 'user'] } }, + required: ['query'] } })) } }); + }, + }); + const tools = (await client.listTools()).tools; + assert.equal(tools.length, 2); + for (const tool of tools) { + assert.deepEqual(tool.inputSchema.properties.query_type.enum, ['internal']); + await client.callTool({ name: tool.name, arguments: { query: 'example' } }); + } + const calls = remote.requests.filter(r => r.body && JSON.parse(r.body).method === 'tools/call'); + assert.equal(calls.length, 2); + for (const call of calls) assert.equal(JSON.parse(call.body).params.arguments.query_type, 'internal'); +}); diff --git a/modules/notion-mcp/tests/installer.test.mjs b/modules/notion-mcp/tests/installer.test.mjs new file mode 100644 index 0000000..f4192fd --- /dev/null +++ b/modules/notion-mcp/tests/installer.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { test } from 'node:test'; + +const run = promisify(execFile); +const mockNode = '#!/bin/sh\nprintf "%s\\n" "$@"\n'; + +async function fixture(t, layout, preinstalled = false) { + const directory = await mkdtemp(join(tmpdir(), 'notion-installer-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const home = join(directory, 'home'); + const bin = join(directory, 'bin'); + const app = join(home, '.local/share/coder-notion-mcp/app'); + const runtime = join(home, '.local/share/coder-notion-mcp/pnpm', layout === 'v11' ? 'bin/node' : 'node'); + const calls = join(directory, 'pnpm-calls'); + await mkdir(bin, { recursive: true }); + // Fake pnpm models the two documented install layouts without network access. + await writeFile(join(bin, 'pnpm'), `#!/bin/sh +set -eu +printf '%s\\n' "$*" >> '${calls}' +if [ "$1" = env ]; then + ${layout === 'missing' ? 'exit 0' : `mkdir -p "$(dirname '${runtime}')" + cat > '${runtime}' <<'NODE' +${mockNode}NODE + chmod 0755 '${runtime}'`} +elif [ "$1" = --dir ]; then + mkdir -p "$2/node_modules/@modelcontextprotocol/sdk" +else + exit 2 +fi +`, { mode: 0o755 }); + if (preinstalled) { + await mkdir(join(runtime, '..'), { recursive: true }); + await writeFile(runtime, mockNode, { mode: 0o755 }); + } + let script = await readFile(new URL('../scripts/install.sh.tpl', import.meta.url), 'utf8'); + const values = { package_json: '{}', pnpm_lock: 'lockfileVersion: 9.0', + adapter_js: '// test adapter', dependency_hash: 'test-lock-hash', + external_auth_id: 'notion-test', access_mode: 'read-only' }; + for (const [key, value] of Object.entries(values)) script = script.replaceAll('${' + key + '}', value); + // Isolate filesystem writes without replacing the process HOME environment. + script = script.replaceAll('$HOME', home) + .replace('PNPM=/usr/local/share/pnpm/bin/pnpm', `PNPM=${directory}/missing-pnpm`); + const installer = join(directory, 'install.sh'); + await writeFile(installer, script); + const install = () => run('sh', [installer], { env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } }); + return { home, app, calls, install }; +} + +for (const layout of ['v10', 'v11']) { + for (const preinstalled of [false, true]) { + test(`${layout} ${preinstalled ? 'existing' : 'fresh'} runtime launches and is reused on restart`, async t => { + const f = await fixture(t, layout, preinstalled); + assert.match((await f.install()).stdout, /Notion MCP installed/); + const launcher = join(f.home, '.local/bin/coder-notion-mcp'); + const expected = [join(f.app, 'notion-mcp.mjs'), 'notion-test', 'read-only']; + assert.deepEqual((await run(launcher)).stdout.trim().split('\n'), expected); + await f.install(); + assert.deepEqual((await run(launcher)).stdout.trim().split('\n'), expected); + const calls = (await readFile(f.calls, 'utf8')).trim().split('\n'); + assert.equal(calls.filter(c => c === 'env use --global 24').length, preinstalled ? 0 : 1); + assert.equal(calls.filter(c => c.includes('--frozen-lockfile --ignore-scripts')).length, 1); + }); + } +} + +test('pnpm success without a usable runtime fails installation', async t => { + const f = await fixture(t, 'missing'); + await assert.rejects(f.install(), error => { + assert.equal(error.code, 1); + assert.match(error.stderr, /did not install a usable Node runtime/); + assert.ok(!error.stdout.includes('Notion MCP installed')); + return true; + }); +}); diff --git a/modules/notion-mcp/variables.tf b/modules/notion-mcp/variables.tf new file mode 100644 index 0000000..5fb4751 --- /dev/null +++ b/modules/notion-mcp/variables.tf @@ -0,0 +1,26 @@ +variable "agent_id" { + description = "Coder agent on which to install the Notion MCP launcher." + type = string +} + +variable "external_auth_id" { + description = "ID of the Notion OAuth provider configured on the Coder deployment." + type = string + default = "notion" + + validation { + condition = can(regex("^[a-z0-9][a-z0-9-]*$", var.external_auth_id)) + error_message = "external_auth_id must contain lowercase letters, digits, and hyphens." + } +} + +variable "access_mode" { + description = "Notion MCP tool access: read-only or read-write. OAuth scopes must also permit writes." + type = string + default = "read-only" + + validation { + condition = contains(["read-only", "read-write"], var.access_mode) + error_message = "access_mode must be read-only or read-write." + } +}