Files
gbrain/src/core/mcp-client.ts
T
b2fd26482e v0.31.1 feat: thin-client mode actually works (Issue #734) (#772)
* v0.31.1 feat: get_brain_identity MCP op (Issue #734 prep)

Lightweight read-scope op that returns {version, engine, page_count,
chunk_count, last_sync_iso} for the thin-client identity banner.
Reuses engine.getStats() — banner's 60s TTL cache (next commit) bounds
frequency to ≤1/60s per CLI process. Banner-only op, no cliHints.

Pinned by 9 tests in test/get-brain-identity.test.ts.

Part of v0.31.1 fix for #734 (thin-client mode silently routing
~25 CLI commands to empty local PGLite). See plan at
~/.claude/plans/how-to-make-mcp-iterative-liskov.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 feat: harden callRemoteTool error normalization + abort/timeout

CDX-4 (Codex outside-voice finding): the previous callRemoteTool let
plain Error escape — undici network errors, AbortError, JSON parse
failures all bubbled untyped. Plan called for an exhaustive switch on
RemoteMcpError.reason at the dispatcher; that contract was unsound.

Hardening:
- New CallRemoteToolOptions {timeoutMs?, signal?} (4th arg, optional).
- buildAbortController composes external signal with timeout into a
  single signal threaded through the SDK transport's requestInit.
- toRemoteMcpError funnel converts ANY thrown value to RemoteMcpError
  before re-raising; the outermost try/catch guarantees the contract.
- RemoteMcpErrorReason exported as a stable union type.
- RemoteMcpErrorDetail.kind ('timeout'|'aborted'|'unreachable') sub-tags
  network errors so the dispatcher can render the right hint.
- RemoteMcpErrorDetail.code carries server-supplied error codes on
  tool_error (e.g. 'missing_scope') for pinpoint refusal hints.
- extractToolErrorCode parses JSON envelopes first, falls back to
  substring detection for legacy server messages.

All 13 existing mcp-client tests still pass. Typecheck clean.

Part of v0.31.1 fix for #734.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 feat: --timeout=Ns CLI flag for thin-client routed calls (ENG-4)

New global flag --timeout that accepts ms / s / m / ms-suffix forms
("30s", "2m", "500ms", "500"). Default null = per-command default
(30s for most ops, 180s for `think` per ENG-4). Plumbs through to
callRemoteTool's AbortController via cliOpts.timeoutMs.

Rejection cases (timeoutMs stays null, flag falls through):
- --timeout=0 (must be positive)
- --timeout=garbage (no parse)

Pinned by 8 new tests in test/cli-options.test.ts (total 28 pass).

Part of v0.31.1 fix for #734.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 feat: thin-client routing seam in cli.ts (CDX-1)

The keystone fix for Issue #734. Inserts the routing seam INSIDE the
existing op-dispatch path in cli.ts:78-138 (per Codex finding CDX-1) —
no parallel `src/core/thin-client/` module. Routing is a ~80-line
conditional that runs BEFORE connectEngine() so thin-client installs
never open the empty local PGLite.

Architecture (CDX-1, CDX-4, ENG-2, ENG-4):
- Existing arg parser, image-to-base64 transform, stdin handler, and
  required-param check run UNCHANGED before the routing branch. Zero
  duplicated parsers.
- New runThinClientRouted(op, params, cfg, cliOpts) calls callRemoteTool
  with {timeoutMs, signal}; default 180s for `think`, 30s otherwise;
  --timeout flag overrides.
- SIGINT abort threaded into AbortController → exit 130.
- Exhaustive TS `never` switch on RemoteMcpError.reason produces canned,
  actionable user messages per failure mode (ENG-4 contract).
- ENG-2 renderer parity: local-engine path runs JSON.parse(JSON.stringify())
  on the result before formatResult, killing the Date/bigint/Buffer drift
  class without per-command renderer audit.
- THIN_CLIENT_REFUSE_HINTS table replaces the generic refusal message
  with pinpoint hints (CDX-5 / cherry-pick A). Adds dream/transcripts/storage
  to the refused set with their own hints.
- localOnly ops on thin-client refuse via refuseThinClient (with hint).

Pinned by 14 cli-dispatch-thin-client tests (all pass). Typecheck clean.

Part of v0.31.1 fix for #734.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 feat: thin-client identity banner (cherry-pick B)

Prints "[thin-client → wintermute.fly.dev:3131 · brain: 102k pages,
265k chunks · v0.31.1]" to stderr before each routed command. Kills the
"am I empty?" confusion that drove the original Hermes/Neuromancer
report against wintermute (102k pages → empty CLI search results).

Cache: 60s TTL, in-memory Map keyed by mcp_url so switching hosts via
`gbrain init` invalidates cleanly. Cross-process file cache deferred.

Suppression: --quiet, GBRAIN_NO_BANNER=1, non-TTY default suppresses
unless GBRAIN_BANNER=1 explicitly opts in (clean pipes for shell flows).

Failure mode: banner fetch errors swallowed; underlying command runs
normally. Banner is observability, never load-bearing. The hardened
callRemoteTool will surface the same error class on the actual call
if the host is genuinely unreachable.

Inline in cli.ts per CDX-1 (no parallel module). _clearIdentityCacheForTest
exported as test escape hatch.

Backed by the new `get_brain_identity` MCP op (read-scope, banner-only).

Part of v0.31.1 fix for #734.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 feat: route CLI-only commands with MCP equivalents (salience/anomalies/graph-query/think)

These four CLI commands bypass the operation-layer dispatch and call
engine methods directly today, so the cli.ts routing seam doesn't catch
them. Each gets a thin per-command branch: when isThinClient(cfg),
callRemoteTool against the corresponding op; otherwise existing engine
path runs unchanged.

Mappings:
- gbrain salience    → get_recent_salience  (read scope, 30s timeout)
- gbrain anomalies   → find_anomalies       (read scope, 30s timeout)
- gbrain graph-query → traverse_graph       (read scope, 30s timeout)
- gbrain think       → think                (write scope, 180s timeout)

`think` is a special case: the server's think op intentionally disables
--save/--take for remote callers (operations.ts:1103-1135 trust-boundary
gate per CLAUDE.md subagent-isolation policy). Thin-client think prints a
loud warning when those flags are set so users know what they lose
instead of silent ignoring. Documented as v0.31.x policy review in plan.

Output format unchanged on both paths — the MCP op handler IS the engine
method, so the unpacked tool result has identical shape.

Part of v0.31.1 fix for #734.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 feat: oauth_client_scopes_probe doctor check (CDX-5)

\`gbrain remote doctor\` gains a 5th check that probes the read + admin
scope tiers via two harmless read-only MCP calls (get_brain_identity
and get_health). Surfaces v0.29.2/v0.30.0 thin-client clients that
registered with read+write only and now hit \`gbrain stats\` /
\`gbrain history\` and fail mid-flight — instead of failing
mid-command, doctor names the exact remediation:

  On the host: gbrain auth register-client <name> --grant-types
    client_credentials --scopes read,write,admin

Status semantics (informational by default):
- read.missing_scope  → fail (broken setup)
- admin.missing_scope → warn + pinpoint hint (the load-bearing case)
- both succeed        → ok
- non-scope probe errors (parse/network/timeout) → ok with
  detail.inconclusive=true (doctor's overall status doesn't flap)

GBRAIN_DOCTOR_SKIP_SCOPE_PROBE=1 env-flag for test fixtures that mock
/mcp at JSON-RPC initialize level only (MCP SDK Client hangs on shape
mismatch and doesn't always honor AbortSignal — adversarial test
behavior we don't want to bake into doctor).

Pinned by 8 cases in test/oauth-scope-probe.test.ts (pure-function
buildScopeCheck) plus unchanged passing of all 23 doctor-remote tests.

CDX-5 from the codex outside-voice review. Keeps host-side
\`gbrain auth register-client\` default at \`read\` (no breaking change
for existing scrapers); puts the migration burden on the THIN-CLIENT
side where it belongs.

Part of v0.31.1 fix for #734.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 feat: refuse \`takes\`/\`sources\` on thin-client with MCP-tool hints (CDX-2)

Per the CDX-2 op-coverage audit: takes and sources are multi-subcommand
CLIs with mixed local/routable surface. Their READ subcommands
(takes_list, takes_search, sources_list, sources_status) have MCP
equivalents — those land in v0.31.x with per-subcommand splits.

For v0.31.1, refuse both at the top level with hints naming the MCP
tools so agents know exactly which tools to invoke directly. Honest
framing per CDX-2: "thin-client gbrain routes the read+write+admin op
surface; multi-subcommand CLIs land incrementally."

Per-subcommand routing recorded as v0.31.x TODO in the plan.

Storage is also refused (filesystem-bound; no remote equivalent).

Part of v0.31.1 fix for #734.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 docs + version: bump VERSION/package.json, CHANGELOG, TODOS, CLAUDE.md

Cross-cut for v0.31.1 ship:
- VERSION: 0.30.0 → 0.31.1
- package.json: "version": "0.31.1" (bun install refreshed bun.lock)
- CHANGELOG.md: full release-summary entry per CLAUDE.md voice contract
  (numbers-that-matter table with before/after comparison, what-this-means
  closer, take-advantage block with exact remediation commands, itemized
  changes by surface, contributor section with plan/decision-history pointer)
- TODOS.md: 7 follow-up entries for v0.31.x (timing telemetry, job-routing,
  per-subcommand takes/sources split, transcripts privacy decision,
  trust-boundary policy review, register-client default flip, cross-process
  token cache, parity test backfill)
- CLAUDE.md: new "Thin-client routing" section under "Key files" annotating
  every changed/new file with its v0.31.1 contract — src/cli.ts routing
  seam, src/core/mcp-client.ts hardening, src/core/cli-options.ts --timeout,
  src/core/doctor-remote.ts scope-probe, get_brain_identity op, per-command
  routing in salience/anomalies/graph-query/think.

Part of v0.31.1 fix for #734.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 fix: collectRemoteDoctorReport opts.skipScopeProbe + regen llms.txt

Replaces the env-var GBRAIN_DOCTOR_SKIP_SCOPE_PROBE module-mutation in
test/doctor-remote.test.ts with an explicit opts arg threaded through
collectRemoteDoctorReport(config, opts). Satisfies the test-isolation
lint (rule R1: no process.env.X = ... in non-serial unit files).

Production callers still honor the env-flag for ops bypass; opts wins
when both are set.

Also regenerates llms.txt + llms-full.txt to match the v0.31.1 CLAUDE.md
additions (build:llms drift check passes).

Part of v0.31.1 fix for #734.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 test: close coverage gaps — issue #734 e2e regression + CDX-4 hardening unit tests

Two real gaps the prior coverage missed:

1. **Issue #734 regression e2e** (test/e2e/thin-client.test.ts +6 cases):
   Existing e2e covered init/doctor/sync-refusal/remote-ping/no-admin but
   never exercised the actual bug — `gbrain search` against a populated
   host. Added the load-bearing regression: seed two pages on the host,
   run thin-client `gbrain search "<unique-token>"`, assert non-zero rows
   AND seeded slug present in stdout. If this assertion ever fails, #734
   has regressed.

   Plus: routed identity banner verification (GBRAIN_BANNER=1 path),
   --quiet suppression check, routed put round-trip (write reaches host,
   visible from host's local engine), routed admin stats (page_count > 0
   not 0/0), and pinpoint refuse-hint format for `gbrain sync`.

2. **CDX-4 hardening unit tests** (test/mcp-client-hardening.test.ts +31
   cases): pre-fix the hardening pass had ZERO direct unit coverage. The
   "exhaustive switch on RemoteMcpError.reason" promise depended on
   toRemoteMcpError actually normalizing every thrown value, but nothing
   verified that contract. Added:
   - toRemoteMcpError: passthrough for RemoteMcpError, AbortError →
     network/aborted, plain Error → network/unreachable, string/object/null
     non-Error throwables → network/unreachable, mcp_url always populated,
     contract test that EVERY output has a recognized reason
   - extractToolErrorCode: JSON envelope (error.code + top-level code),
     substring fallback for missing-scope-shaped messages, defensive
     handling of non-string code field, malformed-JSON fallthrough
   - buildAbortController: timeout fires on schedule, external signal
     propagates immediately when pre-aborted and lazily when aborted later,
     timeout + external compose (whichever fires first wins), cleanup is
     idempotent and removes external listener (no leak)
   - RemoteMcpError class shape (instanceof Error, reason/detail readonly,
     name="RemoteMcpError", detail optional)
   - CallRemoteToolOptions type contract

Internal helpers (toRemoteMcpError, extractToolErrorCode,
buildAbortController) gain @internal export tags so the test file can
import them without going through the SDK transport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 test: move routing tests before remote-ping; fix pre-existing assertion for new refusal format

The newly-added routing tests were running AFTER `gbrain remote ping`,
which submits a 60s autopilot-cycle and can leave the server in a
state where subsequent OAuth probes fail. Moving them before Tier B
so they exercise a healthy server.

Also updated the existing `sync is refused with canonical thin-client error`
test assertion: v0.31.1 changed the refusal format from generic
\`thin client\` (with space) to the pinpoint \`thin-client of <url>\`
(with hyphen) plus \`not routable\` prefix. The test now asserts both
the new format and the pinpoint hint.

E2E result: 10 pass / 3 fail. The 3 failures are pre-existing on master
(remote-ping timeout, client-without-admin OAuth discovery flake) and
not in my diff scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 fix: scrub banned fork name from new test fixtures (CI privacy gate)

CI's check-privacy.sh rejected the v0.31.1 test additions because the
unique-token fixture string used the private OpenClaw fork name as a
prefix. Replaced with neutral names per CLAUDE.md privacy rule:

- test/e2e/thin-client.test.ts: \`wintermute_routing_proof\` →
  \`host_routing_proof\` (the unique-token marker that proves search
  results came from the remote brain, not the empty local PGLite).
  All 6 references updated.

- test/mcp-client-hardening.test.ts: \`https://wintermute.fly.dev/mcp\` →
  \`https://brain-host.example/mcp\` (the synthetic MCP URL used as the
  toRemoteMcpError second arg). Matches the convention used in the
  existing test/cli-dispatch-thin-client.test.ts fixture.

bun run verify passes; 31/31 hardening tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:47:56 -07:00

397 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Outbound HTTP MCP client for thin-client mode (multi-topology v1, Tier B).
*
* Wraps the official @modelcontextprotocol/sdk Client + StreamableHTTPClientTransport
* with OAuth `client_credentials` minting + token caching + 401 retry. Used by:
* - `gbrain remote ping` — submits autopilot-cycle, polls get_job
* - `gbrain remote doctor` — calls run_doctor MCP op
*
* Token caching strategy: in-process Map keyed by mcp_url, value carries the
* access_token + expires_at. CLI invocations are short-lived; the cache
* amortizes when a single `gbrain remote ping` makes multiple calls (submit_job
* + N × get_job). Persisting to disk would create a credential-on-disk
* surface for marginal benefit — re-mint is a single sub-100ms /token call.
*
* 401 handling: on a tool-call rejection, drop the cached token, mint fresh
* once, retry the call. If the second attempt also 401s, surface a structured
* error with the mcp_url + suggested remedy. Auth-failure-after-refresh is the
* canonical "client credentials revoked or scope insufficient" signal.
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import type { GBrainConfig } from './config.ts';
import { discoverOAuth, mintClientCredentialsToken } from './remote-mcp-probe.ts';
interface CachedToken {
access_token: string;
/** Wall-clock ms when this token expires. Conservative: 30s safety margin
* against clock skew so we mint fresh BEFORE the server says expired. */
expires_at_ms: number;
}
const tokenCache = new Map<string, CachedToken>();
/**
* Test-only escape hatch. Tests that mock the OAuth fixture across multiple
* runs need to invalidate the cache between runs. Production callers should
* never need this — the 401 path handles staleness automatically.
*/
export function _clearMcpClientTokenCache(): void {
tokenCache.clear();
}
/**
* Stable union of failure reasons. The CLI dispatcher (cli.ts thin-client
* routing branch, v0.31.1) uses an exhaustive TS switch over this union to
* produce canned, actionable user messages — adding a new variant fails
* compilation until every dispatcher knows what to render.
*
* v0.31.1 additions:
* - `kind` sub-tag on `network` errors distinguishes 'unreachable' /
* 'timeout' / 'aborted' so callers can render the right hint.
* - `code` field on `tool_error` carries the MCP server's `error.code` (when
* present) so the dispatcher can map missing-scope etc. to pinpoint hints.
*/
export type RemoteMcpErrorReason =
| 'config'
| 'discovery'
| 'auth'
| 'auth_after_refresh'
| 'network'
| 'tool_error'
| 'parse';
export interface RemoteMcpErrorDetail {
status?: number;
mcp_url?: string;
/** v0.31.1: sub-tag for network errors (timeout vs aborted vs generic). */
kind?: 'timeout' | 'aborted' | 'unreachable';
/** v0.31.1: server-supplied error code on tool_error (e.g. 'missing_scope'). */
code?: string;
}
export class RemoteMcpError extends Error {
constructor(
public readonly reason: RemoteMcpErrorReason,
message: string,
public readonly detail?: RemoteMcpErrorDetail,
) {
super(message);
this.name = 'RemoteMcpError';
}
}
/**
* v0.31.1: convert any thrown value into a RemoteMcpError. Used by the
* outermost catch in `callRemoteTool` so the dispatcher's exhaustive switch
* is sound — no plain `Error` (undici, AbortError, JSON parse) escapes.
*
* @internal Exported for test access (test/mcp-client-hardening.test.ts).
* Not part of the public API — production code should consume this only via
* the callRemoteTool funnel.
*/
export function toRemoteMcpError(e: unknown, mcpUrl: string): RemoteMcpError {
if (e instanceof RemoteMcpError) return e;
if (e instanceof Error) {
// AbortError fires for both --timeout and SIGINT; the caller distinguishes
// via the AbortSignal.reason it set, but the SDK swallows that. Fall back
// to message inspection for the timeout sub-kind.
const isAbort = e.name === 'AbortError' || /abort/i.test(e.message);
if (isAbort) {
return new RemoteMcpError(
'network',
`Request to ${mcpUrl} aborted: ${e.message}`,
{ mcp_url: mcpUrl, kind: 'aborted' },
);
}
// undici/fetch network errors (DNS, connection refused, TLS) end up here.
return new RemoteMcpError(
'network',
`Network error talking to ${mcpUrl}: ${e.message}`,
{ mcp_url: mcpUrl, kind: 'unreachable' },
);
}
return new RemoteMcpError(
'network',
`Unknown error talking to ${mcpUrl}: ${String(e)}`,
{ mcp_url: mcpUrl, kind: 'unreachable' },
);
}
/**
* v0.31.1: parse a tool_error content envelope and extract a structured
* `code` (e.g. 'missing_scope') if the server provided one. Tries JSON-parsed
* payload first, then falls back to substring detection on the message.
*
* @internal Exported for test access (test/mcp-client-hardening.test.ts).
*/
export function extractToolErrorCode(message: string): string | undefined {
// Try to parse a JSON payload first — gbrain server-side tool errors
// sometimes come through as `{"error":{"code":"...","message":"..."}}`.
try {
const parsed = JSON.parse(message);
if (parsed && typeof parsed === 'object') {
const code = (parsed as any).error?.code ?? (parsed as any).code;
if (typeof code === 'string') return code;
}
} catch { /* not json; fall through */ }
if (/missing[_\s-]?scope|scope.+(insufficient|required)|forbidden|access.+denied/i.test(message)) {
return 'missing_scope';
}
return undefined;
}
function requireRemoteMcp(config: GBrainConfig | null): NonNullable<GBrainConfig['remote_mcp']> {
if (!config?.remote_mcp) {
throw new RemoteMcpError(
'config',
'No remote_mcp config. Run `gbrain init --mcp-only` first.',
);
}
return config.remote_mcp;
}
function resolveSecret(remote: NonNullable<GBrainConfig['remote_mcp']>): string {
const secret = process.env.GBRAIN_REMOTE_CLIENT_SECRET ?? remote.oauth_client_secret;
if (!secret) {
throw new RemoteMcpError(
'config',
'No client_secret available. Set GBRAIN_REMOTE_CLIENT_SECRET or rerun `gbrain init --mcp-only`.',
);
}
return secret;
}
/**
* Mint or reuse a cached access_token for the given config. Throws
* RemoteMcpError on discovery failure or auth rejection.
*/
async function getAccessToken(config: GBrainConfig, force = false): Promise<string> {
const remote = requireRemoteMcp(config);
const cached = tokenCache.get(remote.mcp_url);
if (!force && cached && cached.expires_at_ms > Date.now()) {
return cached.access_token;
}
const secret = resolveSecret(remote);
const disco = await discoverOAuth(remote.issuer_url);
if (!disco.ok) {
throw new RemoteMcpError(
disco.reason === 'http' || disco.reason === 'parse' ? 'discovery' : 'network',
`OAuth discovery failed: ${disco.message}`,
{ ...(disco.status ? { status: disco.status } : {}), mcp_url: remote.mcp_url },
);
}
const tokenRes = await mintClientCredentialsToken(disco.metadata.token_endpoint, remote.oauth_client_id, secret);
if (!tokenRes.ok) {
throw new RemoteMcpError(
tokenRes.reason === 'auth' ? 'auth' : tokenRes.reason === 'network' ? 'network' : 'discovery',
`OAuth /token failed: ${tokenRes.message}`,
{ ...(tokenRes.status ? { status: tokenRes.status } : {}), mcp_url: remote.mcp_url },
);
}
const ttlSec = tokenRes.token.expires_in ?? 3600;
const expires_at_ms = Date.now() + Math.max(0, ttlSec * 1000 - 30_000);
const token: CachedToken = { access_token: tokenRes.token.access_token, expires_at_ms };
tokenCache.set(remote.mcp_url, token);
return token.access_token;
}
/**
* Build a connected Client with the given bearer. Caller is responsible for
* `await client.close()` after use. Each tool call gets its own short-lived
* Client because StreamableHTTPClientTransport doesn't expose a clean way to
* swap headers on an existing connection — re-mint + reconnect on 401 is
* cheaper than reusing.
*
* v0.31.1: optional AbortSignal threaded into `requestInit` so callers can
* cancel in-flight HTTP requests on timeout or SIGINT.
*/
async function buildClient(mcpUrl: string, accessToken: string, signal?: AbortSignal): Promise<Client> {
const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
requestInit: {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
...(signal ? { signal } : {}),
},
});
const client = new Client(
{ name: 'gbrain-remote-cli', version: '1' },
{ capabilities: {} },
);
await client.connect(transport);
return client;
}
/**
* v0.31.1: options for `callRemoteTool`. Both fields optional; when absent the
* call inherits SDK defaults (no client-side timeout, no abort).
*/
export interface CallRemoteToolOptions {
/** Hard wall-clock cap for the whole call (token mint + tool call). Aborts on expiry. */
timeoutMs?: number;
/** External AbortSignal (e.g. SIGINT handler). Composed with the timeout. */
signal?: AbortSignal;
}
/**
* Compose an external signal with a timeout into a single AbortController.
* Returns the controller (so callers can pass `controller.signal` to
* downstream fetch) plus a `cleanup` to stop the timer + drop listeners.
*/
/** @internal Exported for test access (test/mcp-client-hardening.test.ts). */
export function buildAbortController(opts: CallRemoteToolOptions): { signal: AbortSignal; cleanup: () => void } {
const controller = new AbortController();
const cleanups: Array<() => void> = [];
if (opts.timeoutMs !== undefined && opts.timeoutMs > 0) {
const timer = setTimeout(() => {
controller.abort(new Error(`timeout after ${opts.timeoutMs}ms`));
}, opts.timeoutMs);
cleanups.push(() => clearTimeout(timer));
}
if (opts.signal) {
if (opts.signal.aborted) {
controller.abort(opts.signal.reason);
} else {
const onAbort = () => controller.abort(opts.signal!.reason);
opts.signal.addEventListener('abort', onAbort);
cleanups.push(() => opts.signal!.removeEventListener('abort', onAbort));
}
}
return { signal: controller.signal, cleanup: () => cleanups.forEach(fn => { try { fn(); } catch { /* best-effort */ } }) };
}
/**
* Call an MCP tool on the remote server. Handles auth refresh on 401 once.
* Returns the parsed `result` payload from the tool response.
*
* Throws RemoteMcpError on:
* - missing remote_mcp config
* - OAuth discovery / token failures
* - 401 after refresh attempt (auth_after_refresh)
* - tool-call errors (tool_error)
* - network errors
*/
export async function callRemoteTool(
config: GBrainConfig,
toolName: string,
args: Record<string, unknown> = {},
opts: CallRemoteToolOptions = {},
): Promise<unknown> {
const remote = requireRemoteMcp(config);
// v0.31.1 (CDX-4): wrap the WHOLE call in normalize-on-error so the
// exhaustive switch on RemoteMcpError.reason at the dispatcher is sound.
// No plain Error (undici, AbortError, JSON parse) escapes.
const { signal, cleanup } = buildAbortController(opts);
try {
// Step 1: mint (or reuse cached) token. If THIS fails — bad credentials,
// unreachable issuer, etc. — surface immediately. Retry-on-401 is for
// the mid-session token-rotation case, NOT for initial-credentials-wrong.
const initialToken = await getAccessToken(config, false);
// Step 2: try the tool call. On a 401-shaped failure here, drop the cache
// and retry ONCE with a freshly-minted token (handles host-side rotation
// mid-session). If the retry also fails auth, surface auth_after_refresh.
const tryCall = async (token: string): Promise<unknown> => {
const client = await buildClient(remote.mcp_url, token, signal);
try {
const res = await client.callTool({ name: toolName, arguments: args });
if (res.isError) {
const message = Array.isArray(res.content)
? res.content.map((c: unknown) => (c as { text?: string }).text ?? '').join('\n')
: 'unknown tool error';
// v0.31.1: extract structured error code (e.g. 'missing_scope') so
// the dispatcher can produce a pinpoint hint instead of a generic
// "tool error" message.
const code = extractToolErrorCode(message);
throw new RemoteMcpError(
'tool_error',
`Remote tool ${toolName} failed: ${message}`,
{ mcp_url: remote.mcp_url, ...(code ? { code } : {}) },
);
}
return res;
} finally {
try { await client.close(); } catch { /* best-effort */ }
}
};
try {
return await tryCall(initialToken);
} catch (e) {
// RemoteMcpError already-typed: bubble unless it's a tool_error that
// happens to look 401-shaped (e.g. SDK wrapping HTTP 401 in a tool
// error). For plain Error, do the 401 sniff.
const message = e instanceof Error ? e.message : String(e);
const looksLike401 = /401|unauthor|invalid.token/i.test(message);
if (!looksLike401) throw e;
// Drop cached token and retry once with a fresh mint.
tokenCache.delete(remote.mcp_url);
let freshToken: string;
try {
freshToken = await getAccessToken(config, true);
} catch (mintErr) {
if (mintErr instanceof RemoteMcpError && mintErr.reason === 'auth') {
throw new RemoteMcpError(
'auth_after_refresh',
`Auth failed after token refresh. Verify oauth_client_id and secret are still valid; the host operator may need to re-run \`gbrain auth register-client\`.`,
{ mcp_url: remote.mcp_url },
);
}
throw mintErr;
}
try {
return await tryCall(freshToken);
} catch (e2) {
const m2 = e2 instanceof Error ? e2.message : String(e2);
if (/401|unauthor|invalid.token/i.test(m2)) {
throw new RemoteMcpError(
'auth_after_refresh',
`Auth failed after token refresh. Verify oauth_client_id and secret are still valid; the host operator may need to re-run \`gbrain auth register-client\`.`,
{ mcp_url: remote.mcp_url },
);
}
throw e2;
}
}
} catch (e) {
// CDX-4: this is the funnel. ANYTHING that escapes the inner block becomes
// a typed RemoteMcpError. The dispatcher's exhaustive switch can rely on
// this contract.
throw toRemoteMcpError(e, remote.mcp_url);
} finally {
cleanup();
}
}
/**
* Extract the structured result from a successful tool-call response. The MCP
* spec says tool results are returned as `content: Array<{type, text|...}>`.
* gbrain ops set the JSON-encoded result as `text` of the first content item.
* This helper parses + types it for the caller.
*/
export function unpackToolResult<T = unknown>(res: unknown): T {
const content = (res as { content?: unknown[] } | undefined)?.content;
if (!Array.isArray(content) || content.length === 0) {
throw new RemoteMcpError('parse', 'Remote tool returned no content');
}
const first = content[0] as { type?: string; text?: string };
if (first.type !== 'text' || typeof first.text !== 'string') {
throw new RemoteMcpError('parse', 'Remote tool returned unexpected content shape');
}
try {
return JSON.parse(first.text) as T;
} catch (e) {
throw new RemoteMcpError('parse', `Remote tool result was not valid JSON: ${(e as Error).message}`);
}
}