Skip to content
Open
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: 33 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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 <server-auth-token>" \
Expand All @@ -423,7 +429,7 @@ curl -H "Authorization: Bearer <server-auth-token>" \
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
Expand All @@ -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
Expand Down
102 changes: 102 additions & 0 deletions scripts/server-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import {
DEFAULT_HTTP_HOST,
getDnsRebindingProtectionOptions,
getHelpText,
getHttpServerDisplayUrl,
getUnsafeAuthWarnings,
parseServerOptions,
Expand Down Expand Up @@ -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,
Expand All @@ -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 <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,
Expand Down
41 changes: 35 additions & 6 deletions scripts/server-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export type ServerOptions = {
unsafeDisableAuth: boolean
usedDeprecatedDisableAuthFlag: boolean
enableTokenPassthrough: boolean
enableStatelessHttp: boolean
allowedHosts: string[]
}

type DnsRebindingProtectionOptions = Pick<
Expand All @@ -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) {
Expand All @@ -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)
Expand All @@ -62,6 +76,8 @@ export function parseServerOptions(argv: string[] = process.argv): ServerOptions
unsafeDisableAuth,
usedDeprecatedDisableAuthFlag,
enableTokenPassthrough,
enableStatelessHttp,
allowedHosts,
}
}

Expand All @@ -76,16 +92,20 @@ 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
--allowed-hosts <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:
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)
ENABLE_STATELESS_HTTP Set to 'true' to disable HTTP session tracking (alternative to --stateless-http)

Examples:
notion-mcp-server # Use stdio transport (default)
Expand All @@ -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
`
}

Expand Down Expand Up @@ -133,32 +154,40 @@ 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),
}
}

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<string>()
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<string>()
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<string>()
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]'
}
Expand Down
Loading