Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,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-line 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. JSON-RPC tool answers, and the error a host's own connect raises, pass through as sent.
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.

#### Feature sets

Expand Down
45 changes: 45 additions & 0 deletions changelog.d/host-errors-carry-no-credentials.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
- An error a host's own call to agent-framework gets, thrown, rejected with
or in an error answer's data, no longer carries a credential the framework
replaces in its records, whoever wrote its words. Before:
- a server's words in an error built from them were cut to 200 code
points, with control characters replaced, before anything redacted
them. That covers an initialize error, a refusal of the protocol
revision, the revision a legacy server answered with, and a tool call's
error answer. A credential the cut split kept its start in the
framework's records and in an agent's tool result, and one holding a
control character survived changed. An error answer's words were also
trimmed first, so a credential ending in a space at their end survived
whole. The caller of a connection got the server's words, an error
answer's data, the revisions a refusal lists and a tools/list cursor in
its errors unredacted;
- a failed legacy handshake's `stderrTail` was the end of the server's
stderr as written, cut before anything redacted it;
- a modern connection's failed connect handed the host the SDK's error as
raised, with the url in its words, and under Bun in its `path`;
- a legacy stdio server that couldn't be started was refused with Node's
error as the `cause`, its command line in `spawnargs`, and an argument
Node refuses quoted in the words;
- `McplUnreapedLaunchError` printed its connection's configuration,
credentials included, wherever the error was inspected.

Now each error redacts the server's words where it's built, then trims and
bounds them, and redacts the strings in an error answer's data. In that
data, a number whose digits hold a credential, as JavaScript reads the
number, becomes a string, its digits with the credential replaced; a
number with more significant digits than a double holds has lost its last
ones before that. A redacted key that comes out the same as another key is
numbered (`redacted (2)`), and data nested past 64 levels reads
`[withheld: nested too deep to redact]`. A failed handshake's `stderrTail`
is the same end of the server's stderr, with every credential of up to
14,336 characters that reaches into it replaced whole. A library's error
is handed on as its name and its redacted words alone, with a launch
failure's `code`: a legacy stdio server that couldn't be started is
refused with its words redacted and a `cause` that keeps Node's error's
name, those words and its `code`, and nothing else of it; when Node
refuses an argument or an env value at once, the words leave the value
out. `McplUnreapedLaunchError.connection` isn't enumerable. With a
configuration that can't be read, the server's words are withheld, as the
records already withhold them, and an error answer's data is withheld
whole.
- The record of a tools/call or push/render answer that came after its
deadline is redacted as an on-time answer's record is.
9 changes: 6 additions & 3 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24781,10 +24781,13 @@ export class AgentFramework {
error?: { code: unknown; message: unknown };
}) => {
// A late JSON-RPC error is recorded as the connection words a live one,
// `[code] message`, bounded and with its control characters replaced,
// since the server wrote it.
// `[code] message`: redacted as the server's text, as an on-time
// answer's record is, and then bounded with its control characters
// replaced, since the server wrote it. Redacting first keeps a
// credential the cut or a replaced character would split whole for the
// redactor.
const error = info.error === undefined ? {} : {
error: boundedForLog(`[${describeCause(info.error.code)}] ${describeCause(info.error.message)}`, 500),
error: boundedForLog(recorded(`[${describeCause(info.error.code)}] ${describeCause(info.error.message)}`), 500),
};
// A push/render answered after its timeout, when the batch's fallback
// was already materialized: RFC-006's audit records the discarded
Expand Down
6 changes: 4 additions & 2 deletions src/mcpl/channel-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,8 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
// ============================================================================

/** Bound connector values for notices, logs and an outcome's reason. A
* PublishOutcome keeps the connector's structured data (`detail`) whole. */
* PublishOutcome keeps the connector's structured data (`detail`) as the
* connection gives it, its strings redacted (redactedData). */
export function connectorText(value: unknown): string {
let text: string;
try {
Expand Down Expand Up @@ -515,7 +516,8 @@ export interface PublishOutcome {
/** Why it failed, or why its outcome is unknown — for a connector error,
* its message, bounded (which may say what was already posted). */
reason?: string;
/** The connector error's structured `data`, verbatim, when it sent one. */
/** The connector error's structured `data`, with the strings in it
* redacted (redactedData), when it sent one. */
detail?: unknown;
/** When the outcome was established (epoch ms). */
at: number;
Expand Down
9 changes: 9 additions & 0 deletions src/mcpl/log-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ export function logValueRedacted(value: unknown, redact: (text: string) => strin
return printed(redact(textOf(value)), FIELD);
}

/**
* A JSON-RPC error answer's code as a log line prints it: a number, as
* JSON-RPC says a code is, as logValue prints it; any other JSON value a
* server sends there redacted (logValueRedacted), since the server wrote it.
*/
export function loggedCode(code: unknown, redact: (text: string) => string): string {
return typeof code === 'number' ? logValue(code) : logValueRedacted(code, redact);
}

/**
* The host's own message, as the free text that ends a log line after its
* fields: `reason=coalesce-invalid field=payload.content: "content must be an
Expand Down
63 changes: 45 additions & 18 deletions src/mcpl/modern-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ import {

import { StdioTransport, type McplTransport } from './transport.js';
import { McplProtocolVersionError, McplRequestError, McplUnreapedLaunchError } from './server-connection.js';
import { LEGACY_MCP_PROTOCOL_VERSION, MAX_TIMER_MS, MODERN_MCP_PROTOCOL_VERSION, checkServerConfig, describeCredentialError, noteDialSent, redactServerText, serverTextRedactor } from './protocol-family.js';
import { LEGACY_MCP_PROTOCOL_VERSION, MAX_TIMER_MS, MODERN_MCP_PROTOCOL_VERSION, checkServerConfig, describeCredentialError, maskedError, noteDialSent, redactServerText, redactedData, serverTextRedactor, type ServerTextRedactor } from './protocol-family.js';
import type { McplServerConfig, McpToolDefinition } from './types.js';
import { boundedForLog } from './errors.js';
import { logDetail, logValue, logValueRedacted } from './log-value.js';
import { logDetail, logValue, logValueRedacted, loggedCode } from './log-value.js';

const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
/** Bound on establishing the connection (`server/discover`), like the legacy
Expand Down Expand Up @@ -452,6 +452,13 @@ export class ModernMcpConnection extends EventEmitter {
* connect() returns), delivered to the first listener. Bounded: the
* newest lines are kept. */
private stderrBacklog: string[] = [];
private redactor?: ServerTextRedactor;

/** The server's text with its configuration's credentials replaced, for
* the errors this connection hands its caller and its records. */
private get recorded(): ServerTextRedactor {
return (this.redactor ??= serverTextRedactor(this.config));
}

private constructor(private readonly config: McplServerConfig) {
super();
Expand Down Expand Up @@ -585,10 +592,16 @@ export class ModernMcpConnection extends EventEmitter {
this.reconnectEnabled = false;
throw error;
}
// Any other failure is the SDK's or the transport's: it goes on as its
// words alone, redacted, since its message can quote the url and its
// other properties, such as Bun's fetch error's `path` or a wrapper's
// `cause`, can carry it whole (maskedError). The protocol refusal
// above was built from redacted words.
const masked = maskedError(error, this.recorded);
// A launch that couldn't be reaped halts reconnecting, so no retry
// will come: the first connect fails, as one without reconnect does.
if (!this.reconnectEnabled || this.holdsUnreapedChild) throw error;
console.error(`MCP server "${this.id}" initial connect failed, will retry:`, redactServerText((error as Error).message, this.config));
if (!this.reconnectEnabled || this.holdsUnreapedChild) throw masked;
console.error(`MCP server "${this.id}" initial connect failed, will retry:`, masked.message);
this.reconnectAttempts = 1;
this.emit('connect-failed', { error: (error as Error).message, attempt: 0 });
this.scheduleReconnect();
Expand Down Expand Up @@ -726,7 +739,11 @@ export class ModernMcpConnection extends EventEmitter {
*/
private versionVerdict(error: unknown): McplProtocolVersionError | null {
if (!(error instanceof UnsupportedProtocolVersionError)) return null;
const data = (error as { data?: unknown }).data as { supported?: unknown } | undefined;
// The server's words and data, redacted before they're bounded or kept:
// the error goes to the host. One build for all of them, so a
// configuration that can't be read is read once.
const redact = this.recorded.now();
const data = redactedData((error as { data?: unknown }).data, redact) as { supported?: unknown } | undefined;
const supported = Array.isArray(data?.supported) && data.supported.every((v) => typeof v === 'string')
? (data.supported as string[])
: null;
Expand All @@ -740,13 +757,13 @@ export class ModernMcpConnection extends EventEmitter {
: ` It speaks only legacy MCP, which this client reaches over stdio or WebSocket (ws://, wss://), not http(s).`;
return new McplProtocolVersionError(
`MCP server "${this.id}" does not support MCP ${MODERN_MCP_PROTOCOL_VERSION}` +
(supported ? ` (it supports: ${supported.join(', ') || 'none listed'}).` : ` (${error.message}).`) +
(supported ? ` (it supports: ${boundedForLog(supported.join(', ')) || 'none listed'}).` : ` (${boundedForLog(redact(error.message))}).`) +
legacyHint,
'rejected',
null,
supported,
(error as { code?: unknown }).code === undefined ? undefined : Number((error as { code?: unknown }).code),
(error as { data?: unknown }).data,
data,
MODERN_MCP_PROTOCOL_VERSION,
);
}
Expand Down Expand Up @@ -1116,7 +1133,10 @@ export class ModernMcpConnection extends EventEmitter {
private requestFailure(what: string, record: WireRecord, error: unknown, timeoutMs?: number, lost = false): Error {
const server = `the "${this.id}" server`;
const Server = `The "${this.id}" server`;
const message = redactServerText(error instanceof Error ? error.message : String(error), this.config);
// One build of the redactor for all of this failure's texts, so a
// configuration that can't be read is read once.
const redact = this.recorded.now();
const message = redact(error instanceof Error ? error.message : String(error));
const log = (line: string) => console.error(`MCP server "${this.id}" ${line}`);
const listing = record.method === 'tools/list';
// What a failure whose effect is in doubt says: for a tool call, that the
Expand All @@ -1140,18 +1160,21 @@ export class ModernMcpConnection extends EventEmitter {
}
if (record.answer === 'error' && record.error) {
const { code, message: wireMessage, data } = record.error;
// A JSON-RPC answer reaches the caller unredacted (redactServerText's
// scope). The log is a record of the server's text, so the message is
// redacted there, whatever JSON value it is, as every other line this
// connection writes is.
log(`answered ${what} with error ${logValue(code)}: ${logValueRedacted(wireMessage, (text) => redactServerText(text, this.config))}`);
// The server's own words, bounded as errors.ts says. Only a string is
// words: a message of any other JSON value reads as none.
const said = typeof wireMessage === 'string' ? boundedForLog(wireMessage.trim()) : '';
// The log is a record of the server's text, so the code (loggedCode)
// and the message are redacted there, whatever JSON values they are,
// as every other line this connection writes is.
log(`answered ${what} with error ${loggedCode(code, redact)}: ${logValueRedacted(wireMessage, redact)}`);
// The server's own words, redacted and then bounded as errors.ts says,
// since they're in an error the caller gets (redactServerText's
// scope), and its data's strings redacted too. It's trimmed after it's
// redacted, so a credential that ends in a space is whole when it's
// matched. Only a string is words: a message of any other JSON value
// reads as none.
const said = typeof wireMessage === 'string' ? boundedForLog(redact(wireMessage).trim()) : '';
return new McplRequestError(
said ? `${Server} answered with an error: ${said}` : unknown(`${Server} answered with an error without saying what went wrong`),
'error-response',
data,
redactedData(data, redact),
);
}
if (record.answer === 'complete') {
Expand Down Expand Up @@ -1197,10 +1220,14 @@ export class ModernMcpConnection extends EventEmitter {
// The response body (the SDK's `text`) is the server's text and can
// echo the request's url, so it's left out of the data; the status stays.
const { text: _body, ...data } = (error instanceof SdkHttpError && error.data && typeof error.data === 'object' ? error.data : {}) as Record<string, unknown>;
const redacted = redactedData(data, redact);
return new McplRequestError(
`${Server} refused this host access, so the request didn't run. If you need it, ask whoever runs this host.`,
'error-response',
{ ...data, httpStatus: refusedWith },
// The status is this host's own, so it's added after the server's
// data is redacted, and it stays when a configuration that can't be
// read withholds that data.
{ ...(redacted !== null && typeof redacted === 'object' ? redacted : {}), httpStatus: refusedWith },
);
}
if (deadline) {
Expand Down
Loading
Loading