diff --git a/README.md b/README.md index f3eacc49..4e8899de 100644 --- a/README.md +++ b/README.md @@ -345,7 +345,7 @@ When using Streamable HTTP transport, the server will be available at `http://12 ##### Authentication -The Streamable HTTP transport requires bearer token authentication for security. You have three options: +The Streamable HTTP transport uses bearer token authentication by default for security. You have three options: ###### Option 1: Auto-generated token (only for development) @@ -381,13 +381,19 @@ You can disable bearer token authentication only with the explicit unsafe flag: npx @notionhq/notion-mcp-server --transport http --unsafe-disable-auth ``` +If you need to allow additional hosts for unauthenticated HTTP, add them with `--allowed-hosts`: + +```bash +npx @notionhq/notion-mcp-server --transport http --unsafe-disable-auth --allowed-hosts app.local,devbox.local +``` + WARNING: `--unsafe-disable-auth` is unsafe. The server may be reachable to pages you visit via DNS rebinding. Only use it on an isolated network. -When authentication is disabled, the server enables DNS rebinding protection by checking the `Host` and `Origin` headers against the configured local host and loopback hosts. The previous `--disable-auth` flag is still accepted as a deprecated alias, but it will print a warning. +When authentication is disabled, the server enables DNS rebinding protection by checking the `Host` and `Origin` headers against the configured bind host, loopback hosts, and any extra hosts supplied with `--allowed-hosts`. The previous `--disable-auth` flag is still accepted as a deprecated alias, but it will print a warning. ##### Making HTTP requests -All requests to the Streamable HTTP transport must include the bearer token in the Authorization header: +When HTTP authentication is enabled, requests to the Streamable HTTP transport must include the bearer token in the `Authorization` header: ```bash # Example request @@ -405,15 +411,15 @@ curl -H "Authorization: Bearer your-token-here" \ 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: +client supplies its own Notion integration token: ```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: +In the default stateful HTTP mode, clients send their Notion token on the +**initialize** request using the dedicated `Notion-Token` header: ```bash curl -H "Authorization: Bearer " \ @@ -423,7 +429,7 @@ curl -H "Authorization: Bearer " \ http://localhost:3000/mcp ``` -How the token is resolved for each connection, in order: +How the token is resolved for each stateful 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 @@ -445,6 +451,26 @@ Notes: prefer keeping the server's own bearer auth (`--auth-token`) enabled as a gateway in front of multi-tenant traffic. +##### Stateless HTTP mode + +If you want HTTP requests to be fully stateless, start the server with +`--stateless-http` (or `ENABLE_STATELESS_HTTP=true`): + +```bash +npx @notionhq/notion-mcp-server --transport http --stateless-http --enable-token-passthrough +``` + +In stateless mode: + +- the server does not persist MCP sessions +- only `POST /mcp` is accepted; `GET /mcp` and `DELETE /mcp` return `405` +- when token passthrough is enabled, the client must send its Notion token on + **every** request via `Notion-Token` (or `Authorization: Bearer ntn_****` + when `--unsafe-disable-auth` frees that header for Notion auth) + +This mode is useful behind stateless gateways or load balancers where clients +cannot rely on sticky in-memory MCP sessions. + ### Examples 1. Using the following instruction diff --git a/scripts/server-options.test.ts b/scripts/server-options.test.ts index cc515b9b..d65daada 100644 --- a/scripts/server-options.test.ts +++ b/scripts/server-options.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { DEFAULT_HTTP_HOST, getDnsRebindingProtectionOptions, + getHelpText, getHttpServerDisplayUrl, getUnsafeAuthWarnings, parseServerOptions, @@ -55,6 +56,32 @@ describe('server options', () => { expect(deprecatedOptions.usedDeprecatedDisableAuthFlag).toBe(true) }) + it('parses extra allowed hosts from a comma-separated flag', () => { + const options = parseServerOptions([ + ...argv, + '--transport', + 'http', + '--allowed-hosts', + ' app.local,devbox.local,, app.local ', + ]) + + expect(options.allowedHosts).toEqual(['app.local', 'devbox.local', 'app.local']) + }) + + it('appends extra allowed hosts across repeated flags', () => { + const options = parseServerOptions([ + ...argv, + '--transport', + 'http', + '--allowed-hosts', + 'app.local,devbox.local', + '--allowed-hosts', + 'admin.local', + ]) + + expect(options.allowedHosts).toEqual(['app.local', 'devbox.local', 'admin.local']) + }) + it('enables DNS rebinding protection when HTTP auth is disabled', () => { const options = parseServerOptions([ ...argv, @@ -79,12 +106,87 @@ describe('server options', () => { expect(dnsOptions.allowedOrigins).toContain('http://[::1]:4321') }) + it('adds extra hosts to DNS rebinding protection host and origin allowlists', () => { + const options = parseServerOptions([ + ...argv, + '--transport', + 'http', + '--port', + '4321', + '--unsafe-disable-auth', + '--allowed-hosts', + ' app.local,::1,app.local ', + ]) + + const dnsOptions = getDnsRebindingProtectionOptions(options) + if (!dnsOptions) { + throw new Error('Expected DNS rebinding protection options') + } + const { allowedHosts } = dnsOptions + if (!allowedHosts) { + throw new Error('Expected DNS rebinding protection allowed hosts') + } + + expect(allowedHosts).toContain('app.local') + expect(allowedHosts).toContain('app.local:4321') + expect(dnsOptions.allowedOrigins).toContain('http://app.local:4321') + expect(allowedHosts.filter((host) => host === '[::1]')).toHaveLength(1) + expect(allowedHosts.filter((host) => host === '[::1]:4321')).toHaveLength(1) + }) + it('keeps DNS rebinding protection off when HTTP auth is enabled', () => { const options = parseServerOptions([...argv, '--transport', 'http']) expect(getDnsRebindingProtectionOptions(options)).toBeUndefined() }) + it('does not enable DNS rebinding protection when allowed hosts are set but auth stays enabled', () => { + const options = parseServerOptions([ + ...argv, + '--transport', + 'http', + '--allowed-hosts', + 'app.local', + ]) + + expect(getDnsRebindingProtectionOptions(options)).toBeUndefined() + }) + + it('documents the allowed hosts flag in the help text', () => { + expect(getHelpText()).toContain('--allowed-hosts ') + }) + + it('parses stateless HTTP mode from the CLI flag', () => { + const options = parseServerOptions([ + ...argv, + '--transport', + 'http', + '--stateless-http', + ]) + + expect(options.enableStatelessHttp).toBe(true) + }) + + it('reads stateless HTTP mode from the environment', () => { + const original = process.env.ENABLE_STATELESS_HTTP + process.env.ENABLE_STATELESS_HTTP = 'true' + + try { + const options = parseServerOptions([...argv, '--transport', 'http']) + expect(options.enableStatelessHttp).toBe(true) + } finally { + if (original === undefined) { + delete process.env.ENABLE_STATELESS_HTTP + } else { + process.env.ENABLE_STATELESS_HTTP = original + } + } + }) + + it('documents stateless HTTP mode in the help text', () => { + expect(getHelpText()).toContain('--stateless-http') + }) + it('warns clearly for unsafe auth disabling', () => { const options = parseServerOptions([ ...argv, diff --git a/scripts/server-options.ts b/scripts/server-options.ts index 66431d16..940cad43 100644 --- a/scripts/server-options.ts +++ b/scripts/server-options.ts @@ -10,6 +10,8 @@ export type ServerOptions = { unsafeDisableAuth: boolean usedDeprecatedDisableAuthFlag: boolean enableTokenPassthrough: boolean + enableStatelessHttp: boolean + allowedHosts: string[] } type DnsRebindingProtectionOptions = Pick< @@ -26,6 +28,8 @@ export function parseServerOptions(argv: string[] = process.argv): ServerOptions let unsafeDisableAuth = false let usedDeprecatedDisableAuthFlag = false let enableTokenPassthrough = process.env.ENABLE_TOKEN_PASSTHROUGH === 'true' + let enableStatelessHttp = process.env.ENABLE_STATELESS_HTTP === 'true' + let allowedHosts: string[] = [] for (let i = 0; i < args.length; i++) { if (args[i] === '--transport' && i + 1 < args.length) { @@ -47,6 +51,16 @@ export function parseServerOptions(argv: string[] = process.argv): ServerOptions usedDeprecatedDisableAuthFlag = true } else if (args[i] === '--enable-token-passthrough') { enableTokenPassthrough = true + } else if (args[i] === '--stateless-http') { + enableStatelessHttp = true + } else if (args[i] === '--allowed-hosts' && i + 1 < args.length) { + allowedHosts.push( + ...args[i + 1] + .split(',') + .map((host) => host.trim()) + .filter(Boolean), + ) + i++ } else if (args[i] === '--help' || args[i] === '-h') { console.log(getHelpText()) process.exit(0) @@ -62,6 +76,8 @@ export function parseServerOptions(argv: string[] = process.argv): ServerOptions unsafeDisableAuth, usedDeprecatedDisableAuthFlag, enableTokenPassthrough, + enableStatelessHttp, + allowedHosts, } } @@ -76,9 +92,12 @@ 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 + --allowed-hosts Extra comma-separated hosts allowed by DNS rebinding protection when auth is disabled --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). + --stateless-http Disable MCP session tracking for HTTP transport. Each POST request + gets a fresh transport, and GET/DELETE /mcp are rejected (default: off). --help, -h Show this help message Environment Variables: @@ -86,6 +105,7 @@ Environment Variables: 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) + ENABLE_STATELESS_HTTP Set to 'true' to disable HTTP session tracking (alternative to --stateless-http) Examples: notion-mcp-server # Use stdio transport (default) @@ -97,6 +117,7 @@ Examples: 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 + notion-mcp-server --transport http --stateless-http # Stateless Streamable HTTP transport ` } @@ -133,8 +154,8 @@ export function getDnsRebindingProtectionOptions( return { enableDnsRebindingProtection: true, - allowedHosts: getAllowedHosts(options.host, options.port), - allowedOrigins: getAllowedOrigins(options.host, options.port), + allowedHosts: getAllowedHosts(options.host, options.port, options.allowedHosts), + allowedOrigins: getAllowedOrigins(options.host, options.port, options.allowedHosts), } } @@ -142,23 +163,31 @@ export function getHttpServerDisplayUrl(options: ServerOptions): string { return `http://${formatHostForUrl(displayHostForBinding(options.host))}:${options.port}` } -function getAllowedHosts(host: string, port: number): string[] { +function getAllowedHosts(host: string, port: number, extraHosts: string[]): string[] { const allowedHosts = new Set() - for (const allowedHost of ['localhost', '127.0.0.1', '[::1]', normalizeHostHeader(host)]) { + for (const allowedHost of getHostSources(host, extraHosts)) { allowedHosts.add(allowedHost) allowedHosts.add(`${allowedHost}:${port}`) } return [...allowedHosts] } -function getAllowedOrigins(host: string, port: number): string[] { +function getAllowedOrigins(host: string, port: number, extraHosts: string[]): string[] { const allowedOrigins = new Set() - for (const allowedHost of ['localhost', '127.0.0.1', '[::1]', normalizeHostHeader(host)]) { + for (const allowedHost of getHostSources(host, extraHosts)) { allowedOrigins.add(`http://${formatHostForUrl(allowedHost)}:${port}`) } return [...allowedOrigins] } +function getHostSources(host: string, extraHosts: string[]): string[] { + const normalizedHosts = new Set() + for (const allowedHost of ['localhost', '127.0.0.1', '[::1]', host, ...extraHosts]) { + normalizedHosts.add(normalizeHostHeader(allowedHost)) + } + return [...normalizedHosts] +} + function isLoopbackHost(host: string): boolean { return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]' } diff --git a/scripts/start-server.ts b/scripts/start-server.ts index 5225effe..0f82e25d 100644 --- a/scripts/start-server.ts +++ b/scripts/start-server.ts @@ -11,17 +11,144 @@ import express from 'express' import { initProxy, ValidationError } from '../src/init-server' import { NOTION_TOKEN_HEADER, - notionHeadersForToken, redactToken, - resolveNotionToken, + resolveNotionHeadersForRequest, } from '../src/openapi-mcp-server/mcp/token' import { getDnsRebindingProtectionOptions, getHttpServerDisplayUrl, getUnsafeAuthWarnings, + type ServerOptions, parseServerOptions, } from './server-options' +type SessionTransports = Record + +type HttpRequestContext = { + specPath: string + baseUrl: string | undefined + options: ServerOptions + enableTokenPassthrough: boolean + hasEnvNotionToken: boolean + dnsRebindingProtectionOptions: + | ReturnType + | undefined +} + +function sendJsonRpcError( + res: express.Response, + status: number, + code: number, + message: string, +): void { + res.status(status).json({ + jsonrpc: '2.0', + error: { code, message }, + id: null, + }) +} + +function resolveRequestHeaders( + req: express.Request, + res: express.Response, + context: HttpRequestContext, + requireExplicitToken: boolean, +): Record | undefined { + const requestHeaders = resolveNotionHeadersForRequest(req.headers, { + enableTokenPassthrough: context.enableTokenPassthrough, + allowAuthorizationFallback: context.options.unsafeDisableAuth, + hasEnvNotionToken: context.hasEnvNotionToken, + requireExplicitToken, + }) + + if (requestHeaders.status === 'error') { + sendJsonRpcError(res, 401, -32001, requestHeaders.message) + return undefined + } + + return requestHeaders.headers +} + +async function handleStatelessPostRequest( + req: express.Request, + res: express.Response, + context: HttpRequestContext, +): Promise { + const requestHeaders = resolveRequestHeaders( + req, + res, + context, + context.enableTokenPassthrough, + ) + if (!requestHeaders) { + return + } + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + ...(context.dnsRebindingProtectionOptions ?? {}), + }) + res.on('close', () => { + void transport.close() + }) + + const proxy = await initProxy(context.specPath, context.baseUrl, requestHeaders) + await proxy.connect(transport) + await transport.handleRequest(req, res, req.body) +} + +async function getStatefulTransport( + req: express.Request, + res: express.Response, + transports: SessionTransports, + context: HttpRequestContext, +): Promise { + const sessionId = req.headers['mcp-session-id'] as string | undefined + if (sessionId && transports[sessionId]) { + return transports[sessionId] + } + + if (sessionId || !isInitializeRequest(req.body)) { + sendJsonRpcError(res, 400, -32000, 'Bad Request: No valid session ID provided') + return undefined + } + + const requestHeaders = resolveRequestHeaders(req, res, context, false) + if (!requestHeaders) { + return undefined + } + + const tokenResolution = resolveNotionHeadersForRequest(req.headers, { + enableTokenPassthrough: context.enableTokenPassthrough, + allowAuthorizationFallback: context.options.unsafeDisableAuth, + hasEnvNotionToken: context.hasEnvNotionToken, + requireExplicitToken: false, + }) + if (tokenResolution.status === 'ok' && tokenResolution.token) { + console.log(`Initializing session with per-request Notion token ${redactToken(tokenResolution.token)}`) + } + + let transport!: StreamableHTTPServerTransport + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (initializedSessionId) => { + transports[initializedSessionId] = transport + }, + ...(context.dnsRebindingProtectionOptions ?? {}), + }) + + transport.onclose = () => { + if (transport.sessionId) { + delete transports[transport.sessionId] + } + } + + const proxy = await initProxy(context.specPath, context.baseUrl, requestHeaders) + await proxy.connect(transport) + + return transport +} + export async function startServer(args: string[] = process.argv) { const filename = fileURLToPath(import.meta.url) const directory = path.dirname(filename) @@ -110,110 +237,62 @@ export async function startServer(args: string[] = process.argv) { // Notion integrations: each connection brings its own token via a header // instead of everyone sharing the startup env token. const enableTokenPassthrough = options.enableTokenPassthrough + const enableStatelessHttp = options.enableStatelessHttp 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 transports: SessionTransports = {} const dnsRebindingProtectionOptions = getDnsRebindingProtectionOptions(options) + const requestContext: HttpRequestContext = { + specPath, + baseUrl, + options, + enableTokenPassthrough, + hasEnvNotionToken, + dnsRebindingProtectionOptions, + } // Handle POST requests for client-to-server communication app.post('/mcp', async (req, res) => { try { - // Check for existing session ID - const sessionId = req.headers['mcp-session-id'] as string | undefined - let transport: StreamableHTTPServerTransport - - if (sessionId && transports[sessionId]) { - // 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(), - onsessioninitialized: (sessionId) => { - // Store the transport by session ID - transports[sessionId] = transport - }, - ...(dnsRebindingProtectionOptions ?? {}), - }) - - // Clean up transport when closed - transport.onclose = () => { - if (transport.sessionId) { - delete transports[transport.sessionId] - } - } + if (enableStatelessHttp) { + await handleStatelessPostRequest(req, res, requestContext) + return + } - const proxy = await initProxy(specPath, baseUrl, perRequestHeaders) - await proxy.connect(transport) - } else { - // Invalid request - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: No valid session ID provided', - }, - id: null, - }) + const transport = await getStatefulTransport( + req, + res, + transports, + requestContext, + ) + if (!transport) { return } - // Handle the request await transport.handleRequest(req, res, req.body) } catch (error) { console.error('Error handling MCP request:', error) if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32603, - message: 'Internal server error', - }, - id: null, - }) + sendJsonRpcError(res, 500, -32603, 'Internal server error') } } }) // Handle GET requests for server-to-client notifications via Streamable HTTP app.get('/mcp', async (req, res) => { + if (enableStatelessHttp) { + res.status(405).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Method not allowed: stateless HTTP mode accepts POST /mcp only', + }, + id: null, + }) + return + } + const sessionId = req.headers['mcp-session-id'] as string | undefined if (!sessionId || !transports[sessionId]) { res.status(400).send('Invalid or missing session ID') @@ -226,6 +305,18 @@ export async function startServer(args: string[] = process.argv) { // Handle DELETE requests for session termination app.delete('/mcp', async (req, res) => { + if (enableStatelessHttp) { + res.status(405).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Method not allowed: stateless HTTP mode accepts POST /mcp only', + }, + id: null, + }) + return + } + const sessionId = req.headers['mcp-session-id'] as string | undefined if (!sessionId || !transports[sessionId]) { res.status(400).send('Invalid or missing session ID') @@ -256,6 +347,9 @@ export async function startServer(args: string[] = process.argv) { `Notion token passthrough: Enabled (clients may send their own token via the '${NOTION_TOKEN_HEADER}' header)`, ) } + if (enableStatelessHttp) { + console.log('HTTP mode: Stateless (POST /mcp only; no MCP sessions are persisted)') + } // 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/openapi-mcp-server/mcp/__tests__/token.test.ts b/src/openapi-mcp-server/mcp/__tests__/token.test.ts index 72de0722..56b23f8b 100644 --- a/src/openapi-mcp-server/mcp/__tests__/token.test.ts +++ b/src/openapi-mcp-server/mcp/__tests__/token.test.ts @@ -5,6 +5,7 @@ import { isNotionToken, notionHeadersForToken, redactToken, + resolveNotionHeadersForRequest, resolveNotionToken, } from '../token' @@ -111,3 +112,61 @@ describe('redactToken', () => { expect(redacted).toContain(String(NTN.length)) }) }) + +describe('resolveNotionHeadersForRequest', () => { + const headers = (h: IncomingHttpHeaders) => h + + it('uses the request token when passthrough is enabled', () => { + const result = resolveNotionHeadersForRequest(headers({ [NOTION_TOKEN_HEADER]: NTN }), { + enableTokenPassthrough: true, + allowAuthorizationFallback: false, + hasEnvNotionToken: true, + requireExplicitToken: false, + }) + + expect(result).toEqual({ + status: 'ok', + headers: { Authorization: `Bearer ${NTN}` }, + token: NTN, + }) + }) + + it('falls back to the env token in stateful passthrough mode when no request token is sent', () => { + const result = resolveNotionHeadersForRequest(headers({}), { + enableTokenPassthrough: true, + allowAuthorizationFallback: false, + hasEnvNotionToken: true, + requireExplicitToken: false, + }) + + expect(result).toEqual({ status: 'ok' }) + }) + + it('requires an explicit token in stateless passthrough mode', () => { + const result = resolveNotionHeadersForRequest(headers({}), { + enableTokenPassthrough: true, + allowAuthorizationFallback: false, + hasEnvNotionToken: true, + requireExplicitToken: true, + }) + + expect(result).toEqual({ + status: 'error', + message: `Unauthorized: missing Notion token. Provide one via the '${NOTION_TOKEN_HEADER}' header on every request.`, + }) + }) + + it('returns an error when passthrough is enabled and no token source exists', () => { + const result = resolveNotionHeadersForRequest(headers({}), { + enableTokenPassthrough: true, + allowAuthorizationFallback: false, + hasEnvNotionToken: false, + requireExplicitToken: false, + }) + + expect(result).toEqual({ + status: 'error', + message: `Unauthorized: missing Notion token. Provide one via the '${NOTION_TOKEN_HEADER}' header.`, + }) + }) +}) diff --git a/src/openapi-mcp-server/mcp/token.ts b/src/openapi-mcp-server/mcp/token.ts index 8ad91685..dfdcd6cb 100644 --- a/src/openapi-mcp-server/mcp/token.ts +++ b/src/openapi-mcp-server/mcp/token.ts @@ -65,6 +65,10 @@ export type TokenResolution = | { status: 'invalid'; reason: string } | { status: 'absent' } +export type RequestHeadersResolution = + | { status: 'ok'; headers?: Record; token?: string } + | { status: 'error'; message: string } + function firstHeaderValue(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value } @@ -113,6 +117,60 @@ export function resolveNotionToken( return { status: 'absent' } } +export function resolveNotionHeadersForRequest( + headers: IncomingHttpHeaders, + { + enableTokenPassthrough, + allowAuthorizationFallback, + hasEnvNotionToken, + requireExplicitToken, + }: { + enableTokenPassthrough: boolean + allowAuthorizationFallback: boolean + hasEnvNotionToken: boolean + requireExplicitToken: boolean + }, +): RequestHeadersResolution { + if (!enableTokenPassthrough) { + return { status: 'ok' } + } + + const resolution = resolveNotionToken(headers, { + allowAuthorizationFallback, + }) + + if (resolution.status === 'invalid') { + return { + status: 'error', + message: `Unauthorized: ${resolution.reason}`, + } + } + + if (resolution.status === 'ok') { + return { + status: 'ok', + headers: notionHeadersForToken(resolution.token), + token: resolution.token, + } + } + + if (requireExplicitToken) { + return { + status: 'error', + message: `Unauthorized: missing Notion token. Provide one via the '${NOTION_TOKEN_HEADER}' header on every request.`, + } + } + + if (!hasEnvNotionToken) { + return { + status: 'error', + message: `Unauthorized: missing Notion token. Provide one via the '${NOTION_TOKEN_HEADER}' header.`, + } + } + + return { status: 'ok' } +} + /** * Redact a token for safe logging: keep the recognizable prefix, mask the * secret. Never log the raw token.