diff --git a/README.md b/README.md index d7c5d5e..f3eacc4 100644 --- a/README.md +++ b/README.md @@ -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 " \ + -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 diff --git a/scripts/server-options.ts b/scripts/server-options.ts index b0cd3f1..66431d1 100644 --- a/scripts/server-options.ts +++ b/scripts/server-options.ts @@ -9,6 +9,7 @@ export type ServerOptions = { authToken: string | undefined unsafeDisableAuth: boolean usedDeprecatedDisableAuthFlag: boolean + enableTokenPassthrough: boolean } type DnsRebindingProtectionOptions = Pick< @@ -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) { @@ -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) @@ -57,6 +61,7 @@ export function parseServerOptions(argv: string[] = process.argv): ServerOptions authToken, unsafeDisableAuth, usedDeprecatedDisableAuthFlag, + enableTokenPassthrough, } } @@ -71,12 +76,16 @@ Options: --auth-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) @@ -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 ` } diff --git a/scripts/start-server.ts b/scripts/start-server.ts index 8cb3851..5225eff 100644 --- a/scripts/start-server.ts +++ b/scripts/start-server.ts @@ -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, @@ -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) @@ -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 | 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(), @@ -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 @@ -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) { diff --git a/src/init-server.ts b/src/init-server.ts index d459fe7..e4fb479 100644 --- a/src/init-server.ts +++ b/src/init-server.ts @@ -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, +) { const openApiSpec = await loadOpenApiSpec(specPath, baseUrl) - const proxy = new MCPProxy('Notion API', openApiSpec) + const proxy = new MCPProxy('Notion API', openApiSpec, headers) return proxy } diff --git a/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts b/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts index 76bb8c7..65cd0a4 100644 --- a/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts +++ b/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts @@ -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 diff --git a/src/openapi-mcp-server/mcp/__tests__/token.test.ts b/src/openapi-mcp-server/mcp/__tests__/token.test.ts new file mode 100644 index 0000000..72de072 --- /dev/null +++ b/src/openapi-mcp-server/mcp/__tests__/token.test.ts @@ -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)) + }) +}) diff --git a/src/openapi-mcp-server/mcp/proxy.ts b/src/openapi-mcp-server/mcp/proxy.ts index 486c43b..7469f88 100644 --- a/src/openapi-mcp-server/mcp/proxy.ts +++ b/src/openapi-mcp-server/mcp/proxy.ts @@ -90,7 +90,13 @@ export class MCPProxy { private tools: Record private openApiLookup: Record - constructor(name: string, openApiSpec: OpenAPIV3.Document) { + /** + * @param headers Notion API headers to authenticate with. When omitted, the + * headers are resolved from the environment (`OPENAPI_MCP_HEADERS` / + * `NOTION_TOKEN`). The HTTP transport passes per-connection headers here so a + * single deployment can serve multiple Notion integrations. + */ + constructor(name: string, openApiSpec: OpenAPIV3.Document, headers?: Record) { this.server = new Server({ name, version: '1.0.0' }, { capabilities: { tools: {} } }) const baseUrl = openApiSpec.servers?.[0].url if (!baseUrl) { @@ -99,7 +105,7 @@ export class MCPProxy { this.httpClient = new HttpClient( { baseUrl, - headers: this.parseHeadersFromEnv(), + headers: headers ?? this.parseHeadersFromEnv(), }, openApiSpec, ) diff --git a/src/openapi-mcp-server/mcp/token.ts b/src/openapi-mcp-server/mcp/token.ts new file mode 100644 index 0000000..8ad9168 --- /dev/null +++ b/src/openapi-mcp-server/mcp/token.ts @@ -0,0 +1,124 @@ +import type { IncomingHttpHeaders } from 'node:http' + +/** + * Per-request Notion token handling for the Streamable HTTP transport. + * + * By default the server authenticates to Notion with a single token baked in at + * startup (`NOTION_TOKEN` / `OPENAPI_MCP_HEADERS`), which locks one deployment to + * one Notion integration. When token passthrough is enabled, each HTTP client + * instead supplies its own Notion integration token per connection, so a single + * deployment can serve many integrations. + */ + +/** + * Dedicated, unambiguous header for passing a Notion integration token. Lower + * case because Node normalizes incoming header names to lower case. + */ +export const NOTION_TOKEN_HEADER = 'notion-token' + +/** + * Notion integration tokens use stable, recognizable prefixes: + * - `ntn_` — current internal & OAuth integration tokens + * - `secret_` — legacy internal integration tokens + * + * Restricting to these prefixes lets us safely tell a Notion token apart from + * the server's own `--auth-token` gateway secret carried on `Authorization`. + */ +const NOTION_TOKEN_PREFIXES = ['ntn_', 'secret_'] + +// Generous but bounded sanity check; guards against absurd inputs without +// coupling to an exact server-side length we don't control. +const MIN_TOKEN_LENGTH = 8 +const MAX_TOKEN_LENGTH = 300 + +/** + * Whether a string looks like a Notion integration token. This is a cheap shape + * check, not a validity check — the Notion API is the source of truth and will + * reject bad tokens. We only need enough certainty to route the request. + */ +export function isNotionToken(value: string | undefined | null): value is string { + if (!value) { + return false + } + const token = value.trim() + if (token.length < MIN_TOKEN_LENGTH || token.length > MAX_TOKEN_LENGTH) { + return false + } + return NOTION_TOKEN_PREFIXES.some((prefix) => token.startsWith(prefix)) +} + +/** + * Build the Notion API headers for a raw integration token. + * + * Notion-Version is intentionally omitted: it is sourced per-operation from the + * OpenAPI spec by HttpClient, so each endpoint pins the version it needs (e.g. + * the page-markdown endpoints require 2026-03-11 while the rest stay 2025-09-03). + */ +export function notionHeadersForToken(token: string): Record { + return { + Authorization: `Bearer ${token}`, + } +} + +export type TokenResolution = + | { status: 'ok'; token: string } + | { status: 'invalid'; reason: string } + | { status: 'absent' } + +function firstHeaderValue(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value +} + +/** + * Resolve a per-request Notion token from incoming HTTP headers. + * + * Precedence: + * 1. `Notion-Token` — explicit and unambiguous. If present it MUST be a valid + * looking token, otherwise we surface an error rather than silently falling + * back (which would be confusing to debug). + * 2. `Authorization: Bearer ` — only consulted when + * `allowAuthorizationFallback` is set (i.e. the server's own gateway auth is + * disabled, so `Authorization` is free to carry the Notion token) and only + * when the value carries a Notion prefix. + * + * Returns `absent` when no token header is supplied so the caller can decide + * whether to fall back to the startup env token. + */ +export function resolveNotionToken( + headers: IncomingHttpHeaders, + { allowAuthorizationFallback }: { allowAuthorizationFallback: boolean }, +): TokenResolution { + const explicit = firstHeaderValue(headers[NOTION_TOKEN_HEADER]) + if (explicit !== undefined) { + const token = explicit.trim() + return isNotionToken(token) + ? { status: 'ok', token } + : { + status: 'invalid', + reason: `${NOTION_TOKEN_HEADER} header is present but is not a valid Notion integration token`, + } + } + + if (allowAuthorizationFallback) { + const authorization = firstHeaderValue(headers['authorization']) + if (authorization) { + const match = /^Bearer\s+(.+)$/i.exec(authorization.trim()) + const candidate = match?.[1]?.trim() + if (candidate && isNotionToken(candidate)) { + return { status: 'ok', token: candidate } + } + } + } + + return { status: 'absent' } +} + +/** + * Redact a token for safe logging: keep the recognizable prefix, mask the + * secret. Never log the raw token. + */ +export function redactToken(token: string): string { + const underscore = token.indexOf('_') + const prefix = underscore === -1 ? '' : token.slice(0, underscore + 1) + return `${prefix}…(${token.length} chars)` +}