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
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,51 @@ curl -H "Authorization: Bearer your-token-here" \

**Note:** Make sure to set either the `NOTION_TOKEN` environment variable (recommended) or the `OPENAPI_MCP_HEADERS` environment variable with your Notion integration token when using either transport mode.

##### Serving multiple integrations (per-request token passthrough)

By default the server authenticates to Notion with a single token baked in at
startup, which locks one deployment to one Notion integration. To let a single
deployment serve **multiple** integrations, enable token passthrough so each
client supplies its own Notion integration token per connection:

```bash
# Enable per-request Notion tokens (flag or ENABLE_TOKEN_PASSTHROUGH=true)
npx @notionhq/notion-mcp-server --transport http --enable-token-passthrough
```

Clients then send their Notion token on the **initialize** request using the
dedicated `Notion-Token` header:

```bash
curl -H "Authorization: Bearer <server-auth-token>" \
-H "Notion-Token: ntn_****" \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "method": "initialize", "params": {}, "id": 1}' \
http://localhost:3000/mcp
```

How the token is resolved for each connection, in order:

1. The `Notion-Token` header (preferred — unambiguous, and works alongside the
server's own `Authorization` gateway auth). If present it must be a valid
Notion token, otherwise the request is rejected with `401`.
2. `Authorization: Bearer ntn_****` — only when the server's own bearer auth is
turned off (`--unsafe-disable-auth`), so the header is free to carry the
Notion token directly.
3. Otherwise the startup env token (`NOTION_TOKEN` / `OPENAPI_MCP_HEADERS`), if
set, so passthrough and a default integration can coexist on one deployment.

Notes:

- Only values with a Notion token prefix (`ntn_`, legacy `secret_`) are treated
as Notion tokens, so the server's gateway secret and a tenant's Notion token
never collide.
- Each token is bound to its MCP session; tokens are never logged (only a
redacted prefix is emitted).
- This is a deliberate token-passthrough setup. Always deploy it over TLS, and
prefer keeping the server's own bearer auth (`--auth-token`) enabled as a
gateway in front of multi-tenant traffic.

### Examples

1. Using the following instruction
Expand Down
10 changes: 10 additions & 0 deletions scripts/server-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type ServerOptions = {
authToken: string | undefined
unsafeDisableAuth: boolean
usedDeprecatedDisableAuthFlag: boolean
enableTokenPassthrough: boolean
}

type DnsRebindingProtectionOptions = Pick<
Expand All @@ -24,6 +25,7 @@ export function parseServerOptions(argv: string[] = process.argv): ServerOptions
let authToken: string | undefined
let unsafeDisableAuth = false
let usedDeprecatedDisableAuthFlag = false
let enableTokenPassthrough = process.env.ENABLE_TOKEN_PASSTHROUGH === 'true'

for (let i = 0; i < args.length; i++) {
if (args[i] === '--transport' && i + 1 < args.length) {
Expand All @@ -43,6 +45,8 @@ export function parseServerOptions(argv: string[] = process.argv): ServerOptions
} else if (args[i] === '--disable-auth') {
unsafeDisableAuth = true
usedDeprecatedDisableAuthFlag = true
} else if (args[i] === '--enable-token-passthrough') {
enableTokenPassthrough = true
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(getHelpText())
process.exit(0)
Expand All @@ -57,6 +61,7 @@ export function parseServerOptions(argv: string[] = process.argv): ServerOptions
authToken,
unsafeDisableAuth,
usedDeprecatedDisableAuthFlag,
enableTokenPassthrough,
}
}

Expand All @@ -71,12 +76,16 @@ Options:
--auth-token <token> Bearer token for HTTP transport authentication (auto-generated if not provided)
--unsafe-disable-auth Disable bearer token authentication for HTTP transport. Unsafe; use only on isolated networks.
--disable-auth Deprecated alias for --unsafe-disable-auth
--enable-token-passthrough Let each HTTP client supply its own Notion token per request
via the 'Notion-Token' header, so one deployment can serve
multiple Notion integrations (default: off).
--help, -h Show this help message

Environment Variables:
NOTION_TOKEN Notion integration token (recommended)
OPENAPI_MCP_HEADERS JSON string with Notion API headers (alternative)
AUTH_TOKEN Bearer token for HTTP transport authentication (alternative to --auth-token)
ENABLE_TOKEN_PASSTHROUGH Set to 'true' to enable per-request Notion tokens (alternative to --enable-token-passthrough)

Examples:
notion-mcp-server # Use stdio transport (default)
Expand All @@ -87,6 +96,7 @@ Examples:
notion-mcp-server --transport http --auth-token mytoken # Use Streamable HTTP transport with custom auth token
notion-mcp-server --transport http --unsafe-disable-auth # Use Streamable HTTP transport without authentication
AUTH_TOKEN=mytoken notion-mcp-server --transport http # Use Streamable HTTP transport with auth token from env var
notion-mcp-server --transport http --enable-token-passthrough # Per-request Notion token via the Notion-Token header
`
}

Expand Down
55 changes: 54 additions & 1 deletion scripts/start-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import os from 'node:os'
import express from 'express'

import { initProxy, ValidationError } from '../src/init-server'
import {
NOTION_TOKEN_HEADER,
notionHeadersForToken,
redactToken,
resolveNotionToken,
} from '../src/openapi-mcp-server/mcp/token'
import {
getDnsRebindingProtectionOptions,
getHttpServerDisplayUrl,
Expand Down Expand Up @@ -100,6 +106,12 @@ export async function startServer(args: string[] = process.argv) {
}
}

// Per-request Notion token passthrough lets one deployment serve multiple
// Notion integrations: each connection brings its own token via a header
// instead of everyone sharing the startup env token.
const enableTokenPassthrough = options.enableTokenPassthrough
const hasEnvNotionToken = Boolean(process.env.NOTION_TOKEN || process.env.OPENAPI_MCP_HEADERS)

// Map to store transports by session ID
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}
const dnsRebindingProtectionOptions = getDnsRebindingProtectionOptions(options)
Expand All @@ -115,6 +127,42 @@ export async function startServer(args: string[] = process.argv) {
// Reuse existing transport
transport = transports[sessionId]
} else if (!sessionId && isInitializeRequest(req.body)) {
// Resolve which Notion token this connection should authenticate with.
// When passthrough is off we leave this undefined so the proxy uses the
// startup env token (the original, single-integration behavior).
let perRequestHeaders: Record<string, string> | undefined
if (enableTokenPassthrough) {
const resolution = resolveNotionToken(req.headers, {
// Only mine the Authorization header for a Notion token when it
// isn't already reserved for the server's own gateway auth.
allowAuthorizationFallback: options.unsafeDisableAuth,
})
if (resolution.status === 'invalid') {
res.status(401).json({
jsonrpc: '2.0',
error: { code: -32001, message: `Unauthorized: ${resolution.reason}` },
id: null,
})
return
}
if (resolution.status === 'ok') {
perRequestHeaders = notionHeadersForToken(resolution.token)
console.log(`Initializing session with per-request Notion token ${redactToken(resolution.token)}`)
} else if (!hasEnvNotionToken) {
// Passthrough is on, no token was supplied, and there is no env
// token to fall back to — fail clearly instead of 401-ing later.
res.status(401).json({
jsonrpc: '2.0',
error: {
code: -32001,
message: `Unauthorized: missing Notion token. Provide one via the '${NOTION_TOKEN_HEADER}' header.`,
},
id: null,
})
return
}
}

// New initialization request
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
Expand All @@ -132,7 +180,7 @@ export async function startServer(args: string[] = process.argv) {
}
}

const proxy = await initProxy(specPath, baseUrl)
const proxy = await initProxy(specPath, baseUrl, perRequestHeaders)
await proxy.connect(transport)
} else {
// Invalid request
Expand Down Expand Up @@ -203,6 +251,11 @@ export async function startServer(args: string[] = process.argv) {
console.log(`Read your auth token from: ${authTokenFilePath}`)
}
}
if (enableTokenPassthrough) {
console.log(
`Notion token passthrough: Enabled (clients may send their own token via the '${NOTION_TOKEN_HEADER}' header)`,
)
}
// Try to resolve the Notion integration link so users can manage their token
const notionToken = process.env.NOTION_TOKEN
if (notionToken) {
Expand Down
8 changes: 6 additions & 2 deletions src/init-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,13 @@ async function loadOpenApiSpec(specPath: string, baseUrl: string | undefined): P
}
}

export async function initProxy(specPath: string, baseUrl: string |undefined) {
export async function initProxy(
specPath: string,
baseUrl: string | undefined,
headers?: Record<string, string>,
) {
const openApiSpec = await loadOpenApiSpec(specPath, baseUrl)
const proxy = new MCPProxy('Notion API', openApiSpec)
const proxy = new MCPProxy('Notion API', openApiSpec, headers)

return proxy
}
41 changes: 41 additions & 0 deletions src/openapi-mcp-server/mcp/__tests__/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,47 @@ describe('MCPProxy', () => {
)
})
})
describe('explicit headers (per-request token passthrough)', () => {
const originalEnv = process.env

beforeEach(() => {
process.env = { ...originalEnv }
})

afterEach(() => {
process.env = originalEnv
})

it('uses explicit headers instead of the environment when provided', () => {
process.env.NOTION_TOKEN = 'ntn_env_token_should_be_ignored'

const headers = {
Authorization: 'Bearer ntn_per_request_token',
'Notion-Version': '2025-09-03',
}
const proxy = new MCPProxy('test-proxy', mockOpenApiSpec, headers)
expect(HttpClient).toHaveBeenCalledWith(
expect.objectContaining({ headers }),
expect.anything(),
)
})

it('falls back to the environment when headers are omitted', () => {
process.env.NOTION_TOKEN = 'ntn_env_token_123'
delete process.env.OPENAPI_MCP_HEADERS

const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
expect(HttpClient).toHaveBeenCalledWith(
expect.objectContaining({
headers: {
Authorization: 'Bearer ntn_env_token_123',
},
}),
expect.anything(),
)
})
})

describe('connect', () => {
it('should connect to transport', async () => {
const mockTransport = {} as Transport
Expand Down
113 changes: 113 additions & 0 deletions src/openapi-mcp-server/mcp/__tests__/token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest'
import type { IncomingHttpHeaders } from 'node:http'
import {
NOTION_TOKEN_HEADER,
isNotionToken,
notionHeadersForToken,
redactToken,
resolveNotionToken,
} from '../token'

const NTN = `ntn_${'a'.repeat(40)}`
const LEGACY = `secret_${'b'.repeat(40)}`

describe('isNotionToken', () => {
it('accepts current and legacy Notion token prefixes', () => {
expect(isNotionToken(NTN)).toBe(true)
expect(isNotionToken(LEGACY)).toBe(true)
})

it('rejects values without a Notion prefix', () => {
expect(isNotionToken('Bearer-abc')).toBe(false)
expect(isNotionToken('some-gateway-secret')).toBe(false)
})

it('rejects empty, too-short, and too-long values', () => {
expect(isNotionToken('')).toBe(false)
expect(isNotionToken(undefined)).toBe(false)
expect(isNotionToken(null)).toBe(false)
expect(isNotionToken('ntn_')).toBe(false)
expect(isNotionToken(`ntn_${'x'.repeat(400)}`)).toBe(false)
})

it('trims surrounding whitespace', () => {
expect(isNotionToken(` ${NTN} `)).toBe(true)
})
})

describe('notionHeadersForToken', () => {
it('builds an Authorization header (Notion-Version is sourced per-operation from the spec)', () => {
expect(notionHeadersForToken(NTN)).toEqual({
Authorization: `Bearer ${NTN}`,
})
})
})

describe('resolveNotionToken', () => {
const headers = (h: IncomingHttpHeaders) => h

it('reads a valid token from the dedicated header', () => {
const result = resolveNotionToken(headers({ [NOTION_TOKEN_HEADER]: NTN }), {
allowAuthorizationFallback: false,
})
expect(result).toEqual({ status: 'ok', token: NTN })
})

it('errors when the dedicated header is present but malformed', () => {
const result = resolveNotionToken(headers({ [NOTION_TOKEN_HEADER]: 'not-a-token' }), {
allowAuthorizationFallback: false,
})
expect(result.status).toBe('invalid')
})

it('ignores Authorization when fallback is disabled (gateway auth in use)', () => {
const result = resolveNotionToken(headers({ authorization: `Bearer ${NTN}` }), {
allowAuthorizationFallback: false,
})
expect(result).toEqual({ status: 'absent' })
})

it('reads a Notion token from Authorization when fallback is enabled', () => {
const result = resolveNotionToken(headers({ authorization: `Bearer ${NTN}` }), {
allowAuthorizationFallback: true,
})
expect(result).toEqual({ status: 'ok', token: NTN })
})

it('ignores non-Notion Authorization bearer tokens', () => {
const result = resolveNotionToken(headers({ authorization: 'Bearer gateway-secret' }), {
allowAuthorizationFallback: true,
})
expect(result).toEqual({ status: 'absent' })
})

it('returns absent when no token headers are present', () => {
expect(resolveNotionToken(headers({}), { allowAuthorizationFallback: true })).toEqual({
status: 'absent',
})
})

it('prefers the dedicated header over Authorization', () => {
const result = resolveNotionToken(
headers({ [NOTION_TOKEN_HEADER]: NTN, authorization: `Bearer ${LEGACY}` }),
{ allowAuthorizationFallback: true },
)
expect(result).toEqual({ status: 'ok', token: NTN })
})

it('handles array-valued headers by using the first value', () => {
const result = resolveNotionToken(headers({ [NOTION_TOKEN_HEADER]: [NTN, LEGACY] }), {
allowAuthorizationFallback: false,
})
expect(result).toEqual({ status: 'ok', token: NTN })
})
})

describe('redactToken', () => {
it('keeps the prefix and masks the secret', () => {
const redacted = redactToken(NTN)
expect(redacted.startsWith('ntn_')).toBe(true)
expect(redacted).not.toContain('aaaa')
expect(redacted).toContain(String(NTN.length))
})
})
Loading