mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +00:00
* 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>
397 lines
15 KiB
TypeScript
397 lines
15 KiB
TypeScript
/**
|
||
* 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}`);
|
||
}
|
||
}
|