Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/notion-mcp.yaml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
139 changes: 139 additions & 0 deletions modules/notion-mcp/README.md
Original file line number Diff line number Diff line change
@@ -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 <external_auth_id>`. 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.
30 changes: 30 additions & 0 deletions modules/notion-mcp/main.tf
Original file line number Diff line number Diff line change
@@ -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"))
})
}
73 changes: 73 additions & 0 deletions modules/notion-mcp/main.tftest.hcl
Original file line number Diff line number Diff line change
@@ -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]
}
14 changes: 14 additions & 0 deletions modules/notion-mcp/outputs.tf
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 9 additions & 0 deletions modules/notion-mcp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "coder-notion-mcp",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "1.32.1"
}
}
Loading
Loading