Skip to content
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ configuration chooses the family, and nothing is probed:

| Configuration | Family | Transport |
|---|---|---|
| `url: 'ws://…'` / `'wss://…'` | MCPL (MCP `2024-11-05`) | WebSocket |
| `url: 'ws://…'` / `'wss://…'` | MCPL (MCP `2024-11-05`) | WebSocket (`token` / `accessProvider` as `?token=`, or as bearer with `tokenTransport: 'header'`) |
| `url: 'http://…'` / `'https://…'` | modern MCP | Streamable HTTP (`token` / `accessProvider` as bearer) |
| `command` | MCPL | stdio |
| `command` + `protocol: 'modern'` | modern MCP | stdio |
Expand All @@ -323,6 +323,21 @@ configuration chooses the family, and nothing is probed:
A configuration with both `command` and `url` needs `transport` to say which
one is used; without it, it is refused rather than silently ignoring one.

A WebSocket server's credential, its `token` or what its `accessProvider`
returns for a dial, goes as a `token` query parameter by default
(`tokenTransport: 'query'`). With `tokenTransport: 'header'` it goes as an
`Authorization: Bearer` header on the upgrade instead, with no query
parameter, so it stays out of the url and of the access logs that record it;
the provider still runs at every dial and reconnect. Switch a server to
`'header'` once it reads the header. A header carries a credential as
configured only if it is printable ASCII with no space at either end, so
header mode refuses any other, without quoting it: a static `token` when the
server is added, and an access provider's at the dial. `tokenTransport` is
refused on an http(s):// server, which always sends the header, and on a
stdio server; `'header'` is refused with a url that carries a `token`
parameter or userinfo of its own, since whenever the Bearer header carries a
credential it replaces the userinfo's Basic one.

A modern server's tools get the same prefix, `enabledTools`/`disabledTools`,
class overrides and dispatch as an MCPL server's, and `listMcplServers()`
shows its `family`, `protocolVersion` and `transport`. Its config differs in
Expand Down Expand Up @@ -362,7 +377,7 @@ revisions the server says it supports ("none listed" for an empty list), or its
and when the list includes `2026-07-28` it says to configure the server with
`protocol: 'modern'` (stdio) or reach it over http(s).

Server and transport text the framework records has the server's configured credentials replaced, in both engines. That covers connection traces, console lines, stderr traces, ops alerts, and a tool call's failure as its caller, the model and the `tool:failed` trace get it. The credentials are those the configuration holds and those its dials sent: from the url as each dial sends it (a WebSocket dial sets the token in the query, which writes the whole query again, form-encoded, and an access provider's token can be in it), credential-like path segments and query names, credential-shaped or credential-named query values, and a WebSocket url's userinfo, in their encoded and decoded forms, replaced wherever they occur; every non-empty query parameter as its whole `name=value`, where no letter or digit adjoins it; the static `token`, and each token an access provider handed a dial, the newest sixteen, raw and url-encoded; and a stdio server's `env` values whose names have a part ending in a credential word, such as `GITHUB_TOKEN`, `PGPASSWORD` or `SESSION_COOKIE`, raw and url-encoded. A stdio server's command and arguments aren't derived. Standalone strings shorter than eight characters stay unchanged because they may be ordinary words; short query values are still removed with their parameter names. Treat a submitted configuration as immutable while the server uses it: apply changes with `restartMcplServer(id, newConfig)`. Secrets neither the configuration holds nor a dial sent remain outside this filtering. A JSON-RPC tool answer's result passes through as sent. No error a host's own call gets, whether thrown, rejected with or in an error answer's data, carries a credential this filtering replaces, whoever wrote its words: a server's words are redacted, then bounded, where an error is built from them, the strings in an error answer's data are redacted, and an error a library raised, such as a failed modern connect's, is handed on as its name and its redacted words alone. A stdio child that couldn't be started is refused with Node's words redacted, stopping before Node's quote of a value it refused at once, and a cause that keeps Node's error's name, those words and its `code`. With a configuration that can't be read, the server's words and data are withheld. A failed handshake's `stderrTail` is the end of the server's stderr, with every credential of up to 14,336 characters that reaches into it replaced whole. A number in an error answer's data is matched as JavaScript reads it, so one with more significant digits than a double holds has lost its last digits before it's matched. A close's or a cleanup's failure carries the transports' own words, a child's pid, its server's id and the wait it was given. A connection's events are inputs to the framework's records, which redact them.
Server and transport text the framework records has the server's configured credentials replaced, in both engines. That covers connection traces, console lines, stderr traces, ops alerts, and a tool call's failure as its caller, the model and the `tool:failed` trace get it. The credentials are those the configuration holds and those its dials sent: from the url as each dial sends it (a WebSocket dial sets the token in the query, which writes the whole query again, form-encoded, and an access provider's token can be in it; with `tokenTransport: 'header'` it sets none), credential-like path segments and query names, credential-shaped or credential-named query values, and a WebSocket url's userinfo, in their encoded and decoded forms, replaced wherever they occur; every non-empty query parameter as its whole `name=value`, where no letter or digit adjoins it; the static `token`, and each token an access provider handed a dial, the newest sixteen, raw and url-encoded; and a stdio server's `env` values whose names have a part ending in a credential word, such as `GITHUB_TOKEN`, `PGPASSWORD` or `SESSION_COOKIE`, raw and url-encoded. A stdio server's command and arguments aren't derived. Standalone strings shorter than eight characters stay unchanged because they may be ordinary words; short query values are still removed with their parameter names. Treat a submitted configuration as immutable while the server uses it: apply changes with `restartMcplServer(id, newConfig)`. Secrets neither the configuration holds nor a dial sent remain outside this filtering. A JSON-RPC tool answer's result passes through as sent. No error a host's own call gets, whether thrown, rejected with or in an error answer's data, carries a credential this filtering replaces, whoever wrote its words: a server's words are redacted, then bounded, where an error is built from them, the strings in an error answer's data are redacted, and an error a library raised, such as a failed modern connect's, is handed on as its name and its redacted words alone. A stdio child that couldn't be started is refused with Node's words redacted, stopping before Node's quote of a value it refused at once, and a cause that keeps Node's error's name, those words and its `code`. With a configuration that can't be read, the server's words and data are withheld. A failed handshake's `stderrTail` is the end of the server's stderr, with every credential of up to 14,336 characters that reaches into it replaced whole. A number in an error answer's data is matched as JavaScript reads it, so one with more significant digits than a double holds has lost its last digits before it's matched. A close's or a cleanup's failure carries the transports' own words, a child's pid, its server's id and the wait it was given. A connection's events are inputs to the framework's records, which redact them.

#### Feature sets

Expand Down
20 changes: 20 additions & 0 deletions changelog.d/ws-auth-header.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
- A WebSocket MCPL server takes `tokenTransport: 'header'`, which sends its
credential, the static `token` or what its `accessProvider` returns for
each dial, as an `Authorization: Bearer` header on the upgrade, with no
`token` query parameter. The credential then stays out of the url and of
the proxy and server access logs that record it. The default,
`tokenTransport: 'query'`, dials exactly as before, and `null` counts as
not set. A header carries a credential as configured only if it is
printable ASCII (U+0020 to U+007E) with no space at either end, so header
mode refuses any other, without quoting it: a static `token` as a
configuration error, and an access provider's at the dial. Also refused as
configuration errors (`serverConfigProblems`): a value other than
`'query'` or `'header'`, named by its kind and never quoted,
`tokenTransport` on an http(s):// or stdio server, and `'header'` with a
url that carries a `token` query parameter or userinfo of its own
(whenever the Bearer header carries a credential, it replaces the
userinfo's Basic one). A WebSocket dial refuses the first and the last
of these again, so a dial that skipped the configuration check can't
fall back to the query.
In header mode a dial puts nothing in the url's query, so the 0.23.0 note
on redacting the query a WebSocket dial writes applies only to query mode.
76 changes: 68 additions & 8 deletions src/mcpl/protocol-family.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { DENY_BY_DEFAULT, capabilityPatternMatches } from './capability-grant.js
import { DEFAULT_EAGER_FETCH_MAX_BYTES, DEFAULT_MAX_TOTAL_BYTES } from './reference-fetcher.js';
import { DEFAULT_MAX_INPUT_BYTES } from './tool-lifecycle.js';
import type { McplServerConfig, ScopeConfig } from './types.js';
import { kindOf } from '../config-checks.js';

/** The one revision the legacy engine offers and accepts. */
export const LEGACY_MCP_PROTOCOL_VERSION = '2024-11-05';
Expand Down Expand Up @@ -148,7 +149,7 @@ export function redactUrl(url: string | undefined): string {
* runs where a throw has nowhere to go, such as inside a transport's 'error'
* listener.
*/
export function redactServerText(text: unknown, config: Pick<McplServerConfig, 'url' | 'token' | 'env'>): string {
export function redactServerText(text: unknown, config: Pick<McplServerConfig, 'url' | 'token' | 'env' | 'tokenTransport'>): string {
return serverTextRedactor(config)(text);
}

Expand All @@ -175,7 +176,7 @@ const withheld: Redaction = () => WITHHELD_TEXT;
* line. A build that can't read the configuration withholds that call's
* text, and the next call builds again.
*/
export function serverTextRedactor(config: Pick<McplServerConfig, 'url' | 'token' | 'env'>): ServerTextRedactor {
export function serverTextRedactor(config: Pick<McplServerConfig, 'url' | 'token' | 'env' | 'tokenTransport'>): ServerTextRedactor {
let built = -1;
let redact!: Redaction;
const now = (): Redaction => {
Expand Down Expand Up @@ -316,10 +317,12 @@ export function noteDialSent(config: object, sent: { readonly url?: string; read
}

/**
* The url a dial sends. A WebSocket dial sets the token as the `token`
* parameter, and setting it makes URLSearchParams write the whole query
* again, form-encoded (a space as `+`), so the url as sent can differ from
* the url as configured. An http(s) dial sends the url as configured.
* The url a dial sends. A WebSocket dial that sends its token in the query
* sets it as the `token` parameter, and setting it makes URLSearchParams
* write the whole query again, form-encoded (a space as `+`), so the url as
* sent can differ from the url as configured. A header-mode WebSocket dial,
* which is given no token here, and an http(s) dial send the url as
* configured.
*/
export function urlAsDialed(url: URL, token: string | null | undefined): URL {
const dialed = new URL(url);
Expand All @@ -340,7 +343,7 @@ export interface CredentialPattern {
* that begins inside another configuration's, or shares its start, can't
* leave any of that one in place.
*/
export function serverCredentialPatterns(config: Pick<McplServerConfig, 'url' | 'token' | 'env'>): CredentialPattern[] {
export function serverCredentialPatterns(config: Pick<McplServerConfig, 'url' | 'token' | 'env' | 'tokenTransport'>): CredentialPattern[] {
// Strings replaced wherever they occur (after an escape or inside a longer
// token too), and name=value pairs, replaced only where no letter or digit
// adjoins them, so a pair such as v=2 leaves dev=2 alone. A pair's value is
Expand Down Expand Up @@ -437,7 +440,7 @@ export function serverCredentialPatterns(config: Pick<McplServerConfig, 'url' |
const configuredUrl = config.url ?? '';
let url: URL | undefined;
try { url = new URL(configuredUrl); } catch { /* Still remove the static token. */ }
if (url) addUrl(urlAsDialed(url, config.token));
if (url) addUrl(urlAsDialed(url, config.tokenTransport === 'header' ? null : config.token));
// What the dials sent beyond that: urls dialed with an access provider's
// token, and its tokens sent in a header.
const sent = dialsSent.get(config);
Expand Down Expand Up @@ -701,6 +704,22 @@ export function serverConfigProblems(config: McplServerConfig): string[] {
);
}
}
if (config.tokenTransport !== undefined && config.tokenTransport !== null) {
if (binding.transport !== 'websocket') {
problems.push(
`MCP server "${config.id}": "tokenTransport" applies only to a ws(s):// server; an http(s):// server always sends its credential as a header, and a stdio server sends none`,
);
} else {
const problem = tokenTransportProblem(config, new URL(config.url!));
if (problem) problems.push(problem);
// A static token known now that no dial could send as configured.
else if (config.tokenTransport === 'header' && config.token && !headerCarries(config.token)) {
problems.push(
`MCP server "${config.id}": with tokenTransport 'header', "token" must be printable ASCII (U+0020 to U+007E) with no space at either end, which is all a header carries as configured`,
);
}
}
}
if (config.protocol !== undefined) {
if (config.protocol !== 'legacy' && config.protocol !== 'modern') {
problems.push(`MCP server "${config.id}": protocol must be 'legacy' or 'modern', got ${JSON.stringify(config.protocol)}`);
Expand Down Expand Up @@ -738,6 +757,47 @@ export function serverConfigProblems(config: McplServerConfig): string[] {
return problems;
}

/**
* What's wrong with a WebSocket server's `tokenTransport`, as a message, or
* undefined: a value that is neither mode, or `'header'` with a url that
* carries a `token` query parameter of its own, which would still put a
* credential in the url, or userinfo, whose Basic credential the Bearer
* header replaces whenever it carries a credential, since a request has one
* Authorization header. null counts as not set, as config-checks.ts has it.
* The refusal names the value's kind, never the value, which could be a
* credential pasted into the wrong field, and quotes nothing of the url.
*/
export function tokenTransportProblem(config: Pick<McplServerConfig, 'id' | 'tokenTransport'>, url: URL): string | undefined {
// A configuration read from JSON can hold anything here.
const mode: unknown = config.tokenTransport;
if (mode === undefined || mode === null || mode === 'query') return undefined;
if (mode !== 'header') {
return `MCP server "${config.id}": tokenTransport must be 'query' or 'header', not ${typeof mode === 'string' ? 'another string' : kindOf(mode)}`;
}
if (url.searchParams.has('token')) {
return `MCP server "${config.id}": with tokenTransport 'header' the url must not carry a "token" query parameter, which would still put a credential in it; give it as "token" or "accessProvider"`;
}
if (url.username || url.password) {
return `MCP server "${config.id}": with tokenTransport 'header' the url must not carry userinfo (user:password@), since the Bearer header replaces the userinfo's Basic credential whenever it carries a credential; leave out "tokenTransport" to send the userinfo as before, or give the credential as "token" or "accessProvider"`;
}
return undefined;
}

/**
* Whether a header-mode credential can go in an `Authorization: Bearer`
* header as it is: printable ASCII (U+0020 to U+007E), with no space at
* either end. Node and Bun send exactly those bytes, so the server checks the
* credential that was configured, and the redactor's patterns match whatever
* it echoes. Outside that set a header can't carry it as configured: a space
* at either end is dropped (by Bun before sending, by an HTTP parser on
* arrival), a control character can't go in a header, and a character past
* U+007E goes out as different bytes from Node and from Bun, or is refused
* when the socket is built.
*/
export function headerCarries(credential: string): boolean {
return /^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?$/.test(credential);
}

/** A host a connection to which never leaves this machine, as the WHATWG URL
* parser gives it. The unspecified addresses count: a connection to 0.0.0.0
* or [::] reaches this host. So do 127.0.0.0/8 written as an IPv4-mapped
Expand Down
Loading
Loading