Skip to content

Commit 604eef9

Browse files
ksinderclaude
andauthored
Add per-request Notion token passthrough for HTTP transport (#315)
In Streamable HTTP mode the Notion token was fixed at startup (NOTION_TOKEN / OPENAPI_MCP_HEADERS), locking one deployment to a single Notion integration. This adds an opt-in mode where each client supplies its own Notion integration token per connection, so one deployment can serve many integrations. - New `--enable-token-passthrough` flag / `ENABLE_TOKEN_PASSTHROUGH=true` env (default off; existing single-token behavior unchanged). - Token resolved per init request: dedicated `Notion-Token` header first, then `Authorization: Bearer ntn_...` when the server's own gateway auth is disabled, then the startup env token as fallback. - Token shape validated; bound per MCP session; never logged (redacted prefix only). Malformed explicit token returns 401. - MCPProxy/initProxy now accept explicit headers; env resolution is the default when none are passed. New token helper module is unit-tested; proxy tests cover the explicit header override. Full suite green; build passes. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9a8a94c commit 604eef9

8 files changed

Lines changed: 401 additions & 5 deletions

File tree

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,51 @@ curl -H "Authorization: Bearer your-token-here" \
400400

401401
**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.
402402

403+
##### Serving multiple integrations (per-request token passthrough)
404+
405+
By default the server authenticates to Notion with a single token baked in at
406+
startup, which locks one deployment to one Notion integration. To let a single
407+
deployment serve **multiple** integrations, enable token passthrough so each
408+
client supplies its own Notion integration token per connection:
409+
410+
```bash
411+
# Enable per-request Notion tokens (flag or ENABLE_TOKEN_PASSTHROUGH=true)
412+
npx @notionhq/notion-mcp-server --transport http --enable-token-passthrough
413+
```
414+
415+
Clients then send their Notion token on the **initialize** request using the
416+
dedicated `Notion-Token` header:
417+
418+
```bash
419+
curl -H "Authorization: Bearer <server-auth-token>" \
420+
-H "Notion-Token: ntn_****" \
421+
-H "Content-Type: application/json" \
422+
-d '{"jsonrpc": "2.0", "method": "initialize", "params": {}, "id": 1}' \
423+
http://localhost:3000/mcp
424+
```
425+
426+
How the token is resolved for each connection, in order:
427+
428+
1. The `Notion-Token` header (preferred — unambiguous, and works alongside the
429+
server's own `Authorization` gateway auth). If present it must be a valid
430+
Notion token, otherwise the request is rejected with `401`.
431+
2. `Authorization: Bearer ntn_****` — only when the server's own bearer auth is
432+
turned off (`--unsafe-disable-auth`), so the header is free to carry the
433+
Notion token directly.
434+
3. Otherwise the startup env token (`NOTION_TOKEN` / `OPENAPI_MCP_HEADERS`), if
435+
set, so passthrough and a default integration can coexist on one deployment.
436+
437+
Notes:
438+
439+
- Only values with a Notion token prefix (`ntn_`, legacy `secret_`) are treated
440+
as Notion tokens, so the server's gateway secret and a tenant's Notion token
441+
never collide.
442+
- Each token is bound to its MCP session; tokens are never logged (only a
443+
redacted prefix is emitted).
444+
- This is a deliberate token-passthrough setup. Always deploy it over TLS, and
445+
prefer keeping the server's own bearer auth (`--auth-token`) enabled as a
446+
gateway in front of multi-tenant traffic.
447+
403448
### Examples
404449

405450
1. Using the following instruction

scripts/server-options.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type ServerOptions = {
99
authToken: string | undefined
1010
unsafeDisableAuth: boolean
1111
usedDeprecatedDisableAuthFlag: boolean
12+
enableTokenPassthrough: boolean
1213
}
1314

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

2830
for (let i = 0; i < args.length; i++) {
2931
if (args[i] === '--transport' && i + 1 < args.length) {
@@ -43,6 +45,8 @@ export function parseServerOptions(argv: string[] = process.argv): ServerOptions
4345
} else if (args[i] === '--disable-auth') {
4446
unsafeDisableAuth = true
4547
usedDeprecatedDisableAuthFlag = true
48+
} else if (args[i] === '--enable-token-passthrough') {
49+
enableTokenPassthrough = true
4650
} else if (args[i] === '--help' || args[i] === '-h') {
4751
console.log(getHelpText())
4852
process.exit(0)
@@ -57,6 +61,7 @@ export function parseServerOptions(argv: string[] = process.argv): ServerOptions
5761
authToken,
5862
unsafeDisableAuth,
5963
usedDeprecatedDisableAuthFlag,
64+
enableTokenPassthrough,
6065
}
6166
}
6267

@@ -71,12 +76,16 @@ Options:
7176
--auth-token <token> Bearer token for HTTP transport authentication (auto-generated if not provided)
7277
--unsafe-disable-auth Disable bearer token authentication for HTTP transport. Unsafe; use only on isolated networks.
7378
--disable-auth Deprecated alias for --unsafe-disable-auth
79+
--enable-token-passthrough Let each HTTP client supply its own Notion token per request
80+
via the 'Notion-Token' header, so one deployment can serve
81+
multiple Notion integrations (default: off).
7482
--help, -h Show this help message
7583
7684
Environment Variables:
7785
NOTION_TOKEN Notion integration token (recommended)
7886
OPENAPI_MCP_HEADERS JSON string with Notion API headers (alternative)
7987
AUTH_TOKEN Bearer token for HTTP transport authentication (alternative to --auth-token)
88+
ENABLE_TOKEN_PASSTHROUGH Set to 'true' to enable per-request Notion tokens (alternative to --enable-token-passthrough)
8089
8190
Examples:
8291
notion-mcp-server # Use stdio transport (default)
@@ -87,6 +96,7 @@ Examples:
8796
notion-mcp-server --transport http --auth-token mytoken # Use Streamable HTTP transport with custom auth token
8897
notion-mcp-server --transport http --unsafe-disable-auth # Use Streamable HTTP transport without authentication
8998
AUTH_TOKEN=mytoken notion-mcp-server --transport http # Use Streamable HTTP transport with auth token from env var
99+
notion-mcp-server --transport http --enable-token-passthrough # Per-request Notion token via the Notion-Token header
90100
`
91101
}
92102

scripts/start-server.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import os from 'node:os'
99
import express from 'express'
1010

1111
import { initProxy, ValidationError } from '../src/init-server'
12+
import {
13+
NOTION_TOKEN_HEADER,
14+
notionHeadersForToken,
15+
redactToken,
16+
resolveNotionToken,
17+
} from '../src/openapi-mcp-server/mcp/token'
1218
import {
1319
getDnsRebindingProtectionOptions,
1420
getHttpServerDisplayUrl,
@@ -100,6 +106,12 @@ export async function startServer(args: string[] = process.argv) {
100106
}
101107
}
102108

109+
// Per-request Notion token passthrough lets one deployment serve multiple
110+
// Notion integrations: each connection brings its own token via a header
111+
// instead of everyone sharing the startup env token.
112+
const enableTokenPassthrough = options.enableTokenPassthrough
113+
const hasEnvNotionToken = Boolean(process.env.NOTION_TOKEN || process.env.OPENAPI_MCP_HEADERS)
114+
103115
// Map to store transports by session ID
104116
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}
105117
const dnsRebindingProtectionOptions = getDnsRebindingProtectionOptions(options)
@@ -115,6 +127,42 @@ export async function startServer(args: string[] = process.argv) {
115127
// Reuse existing transport
116128
transport = transports[sessionId]
117129
} else if (!sessionId && isInitializeRequest(req.body)) {
130+
// Resolve which Notion token this connection should authenticate with.
131+
// When passthrough is off we leave this undefined so the proxy uses the
132+
// startup env token (the original, single-integration behavior).
133+
let perRequestHeaders: Record<string, string> | undefined
134+
if (enableTokenPassthrough) {
135+
const resolution = resolveNotionToken(req.headers, {
136+
// Only mine the Authorization header for a Notion token when it
137+
// isn't already reserved for the server's own gateway auth.
138+
allowAuthorizationFallback: options.unsafeDisableAuth,
139+
})
140+
if (resolution.status === 'invalid') {
141+
res.status(401).json({
142+
jsonrpc: '2.0',
143+
error: { code: -32001, message: `Unauthorized: ${resolution.reason}` },
144+
id: null,
145+
})
146+
return
147+
}
148+
if (resolution.status === 'ok') {
149+
perRequestHeaders = notionHeadersForToken(resolution.token)
150+
console.log(`Initializing session with per-request Notion token ${redactToken(resolution.token)}`)
151+
} else if (!hasEnvNotionToken) {
152+
// Passthrough is on, no token was supplied, and there is no env
153+
// token to fall back to — fail clearly instead of 401-ing later.
154+
res.status(401).json({
155+
jsonrpc: '2.0',
156+
error: {
157+
code: -32001,
158+
message: `Unauthorized: missing Notion token. Provide one via the '${NOTION_TOKEN_HEADER}' header.`,
159+
},
160+
id: null,
161+
})
162+
return
163+
}
164+
}
165+
118166
// New initialization request
119167
transport = new StreamableHTTPServerTransport({
120168
sessionIdGenerator: () => randomUUID(),
@@ -132,7 +180,7 @@ export async function startServer(args: string[] = process.argv) {
132180
}
133181
}
134182

135-
const proxy = await initProxy(specPath, baseUrl)
183+
const proxy = await initProxy(specPath, baseUrl, perRequestHeaders)
136184
await proxy.connect(transport)
137185
} else {
138186
// Invalid request
@@ -203,6 +251,11 @@ export async function startServer(args: string[] = process.argv) {
203251
console.log(`Read your auth token from: ${authTokenFilePath}`)
204252
}
205253
}
254+
if (enableTokenPassthrough) {
255+
console.log(
256+
`Notion token passthrough: Enabled (clients may send their own token via the '${NOTION_TOKEN_HEADER}' header)`,
257+
)
258+
}
206259
// Try to resolve the Notion integration link so users can manage their token
207260
const notionToken = process.env.NOTION_TOKEN
208261
if (notionToken) {

src/init-server.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,13 @@ async function loadOpenApiSpec(specPath: string, baseUrl: string | undefined): P
4242
}
4343
}
4444

45-
export async function initProxy(specPath: string, baseUrl: string |undefined) {
45+
export async function initProxy(
46+
specPath: string,
47+
baseUrl: string | undefined,
48+
headers?: Record<string, string>,
49+
) {
4650
const openApiSpec = await loadOpenApiSpec(specPath, baseUrl)
47-
const proxy = new MCPProxy('Notion API', openApiSpec)
51+
const proxy = new MCPProxy('Notion API', openApiSpec, headers)
4852

4953
return proxy
5054
}

src/openapi-mcp-server/mcp/__tests__/proxy.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,47 @@ describe('MCPProxy', () => {
324324
)
325325
})
326326
})
327+
describe('explicit headers (per-request token passthrough)', () => {
328+
const originalEnv = process.env
329+
330+
beforeEach(() => {
331+
process.env = { ...originalEnv }
332+
})
333+
334+
afterEach(() => {
335+
process.env = originalEnv
336+
})
337+
338+
it('uses explicit headers instead of the environment when provided', () => {
339+
process.env.NOTION_TOKEN = 'ntn_env_token_should_be_ignored'
340+
341+
const headers = {
342+
Authorization: 'Bearer ntn_per_request_token',
343+
'Notion-Version': '2025-09-03',
344+
}
345+
const proxy = new MCPProxy('test-proxy', mockOpenApiSpec, headers)
346+
expect(HttpClient).toHaveBeenCalledWith(
347+
expect.objectContaining({ headers }),
348+
expect.anything(),
349+
)
350+
})
351+
352+
it('falls back to the environment when headers are omitted', () => {
353+
process.env.NOTION_TOKEN = 'ntn_env_token_123'
354+
delete process.env.OPENAPI_MCP_HEADERS
355+
356+
const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
357+
expect(HttpClient).toHaveBeenCalledWith(
358+
expect.objectContaining({
359+
headers: {
360+
Authorization: 'Bearer ntn_env_token_123',
361+
},
362+
}),
363+
expect.anything(),
364+
)
365+
})
366+
})
367+
327368
describe('connect', () => {
328369
it('should connect to transport', async () => {
329370
const mockTransport = {} as Transport
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, expect, it } from 'vitest'
2+
import type { IncomingHttpHeaders } from 'node:http'
3+
import {
4+
NOTION_TOKEN_HEADER,
5+
isNotionToken,
6+
notionHeadersForToken,
7+
redactToken,
8+
resolveNotionToken,
9+
} from '../token'
10+
11+
const NTN = `ntn_${'a'.repeat(40)}`
12+
const LEGACY = `secret_${'b'.repeat(40)}`
13+
14+
describe('isNotionToken', () => {
15+
it('accepts current and legacy Notion token prefixes', () => {
16+
expect(isNotionToken(NTN)).toBe(true)
17+
expect(isNotionToken(LEGACY)).toBe(true)
18+
})
19+
20+
it('rejects values without a Notion prefix', () => {
21+
expect(isNotionToken('Bearer-abc')).toBe(false)
22+
expect(isNotionToken('some-gateway-secret')).toBe(false)
23+
})
24+
25+
it('rejects empty, too-short, and too-long values', () => {
26+
expect(isNotionToken('')).toBe(false)
27+
expect(isNotionToken(undefined)).toBe(false)
28+
expect(isNotionToken(null)).toBe(false)
29+
expect(isNotionToken('ntn_')).toBe(false)
30+
expect(isNotionToken(`ntn_${'x'.repeat(400)}`)).toBe(false)
31+
})
32+
33+
it('trims surrounding whitespace', () => {
34+
expect(isNotionToken(` ${NTN} `)).toBe(true)
35+
})
36+
})
37+
38+
describe('notionHeadersForToken', () => {
39+
it('builds an Authorization header (Notion-Version is sourced per-operation from the spec)', () => {
40+
expect(notionHeadersForToken(NTN)).toEqual({
41+
Authorization: `Bearer ${NTN}`,
42+
})
43+
})
44+
})
45+
46+
describe('resolveNotionToken', () => {
47+
const headers = (h: IncomingHttpHeaders) => h
48+
49+
it('reads a valid token from the dedicated header', () => {
50+
const result = resolveNotionToken(headers({ [NOTION_TOKEN_HEADER]: NTN }), {
51+
allowAuthorizationFallback: false,
52+
})
53+
expect(result).toEqual({ status: 'ok', token: NTN })
54+
})
55+
56+
it('errors when the dedicated header is present but malformed', () => {
57+
const result = resolveNotionToken(headers({ [NOTION_TOKEN_HEADER]: 'not-a-token' }), {
58+
allowAuthorizationFallback: false,
59+
})
60+
expect(result.status).toBe('invalid')
61+
})
62+
63+
it('ignores Authorization when fallback is disabled (gateway auth in use)', () => {
64+
const result = resolveNotionToken(headers({ authorization: `Bearer ${NTN}` }), {
65+
allowAuthorizationFallback: false,
66+
})
67+
expect(result).toEqual({ status: 'absent' })
68+
})
69+
70+
it('reads a Notion token from Authorization when fallback is enabled', () => {
71+
const result = resolveNotionToken(headers({ authorization: `Bearer ${NTN}` }), {
72+
allowAuthorizationFallback: true,
73+
})
74+
expect(result).toEqual({ status: 'ok', token: NTN })
75+
})
76+
77+
it('ignores non-Notion Authorization bearer tokens', () => {
78+
const result = resolveNotionToken(headers({ authorization: 'Bearer gateway-secret' }), {
79+
allowAuthorizationFallback: true,
80+
})
81+
expect(result).toEqual({ status: 'absent' })
82+
})
83+
84+
it('returns absent when no token headers are present', () => {
85+
expect(resolveNotionToken(headers({}), { allowAuthorizationFallback: true })).toEqual({
86+
status: 'absent',
87+
})
88+
})
89+
90+
it('prefers the dedicated header over Authorization', () => {
91+
const result = resolveNotionToken(
92+
headers({ [NOTION_TOKEN_HEADER]: NTN, authorization: `Bearer ${LEGACY}` }),
93+
{ allowAuthorizationFallback: true },
94+
)
95+
expect(result).toEqual({ status: 'ok', token: NTN })
96+
})
97+
98+
it('handles array-valued headers by using the first value', () => {
99+
const result = resolveNotionToken(headers({ [NOTION_TOKEN_HEADER]: [NTN, LEGACY] }), {
100+
allowAuthorizationFallback: false,
101+
})
102+
expect(result).toEqual({ status: 'ok', token: NTN })
103+
})
104+
})
105+
106+
describe('redactToken', () => {
107+
it('keeps the prefix and masks the secret', () => {
108+
const redacted = redactToken(NTN)
109+
expect(redacted.startsWith('ntn_')).toBe(true)
110+
expect(redacted).not.toContain('aaaa')
111+
expect(redacted).toContain(String(NTN.length))
112+
})
113+
})

0 commit comments

Comments
 (0)