/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import os from "node:os"; import { CONTROL_CHAR_RE, stripLineSeparators } from "../lib/control-chars.js"; import { AuthError, classifyCause, type RetryHint, SchemaError, UserInputError, } from "../lib/errors.js"; import { graphitiHome, schemaDir } from "../lib/introspect.js"; import { SchemaRefreshError } from "../lib/prime-schema.js"; import { MutationContextError } from "../lib/walker.js"; /** * Shared MCP tool adapter (W-22697673). Wraps an intent invocation so that any * throw becomes a sanitized, category-prefixed error envelope instead of leaking * a raw stack/file path to the MCP host. Categories (message prefix): * - `UserInput:` — bad agent input / spec violations (FR-8.3/8.4, GraphQL Name, * walker navigation). * - `Auth:` — credential resolution failures. * - `Schema:` — introspection / priming / schema-build failures. * - `Internal:` — everything else; message + a truncated stack (the full error * is logged to stderr, off the stdio channel). * * Auth/Schema/UserInput are carried by typed markers (AuthError, SchemaError, * SchemaRefreshError, UserInputError, MutationContextError) classified by * `instanceof`; the message-shape heuristics below are a secondary fallback for * untyped throws. The sanitized message is returned for EVERY category — not * just Internal — because typed Auth/Schema errors embed cache/lock paths and * wrapped jsforce/@salesforce/core causes embed `~/.sfdx/...` paths. * * Retryability hint (W-23148365): a `Schema:` error — and ONLY a `Schema:` error — * additionally ends with a closed-set token telling the host whether to retry: * ` [retry=now]` (retry immediately; no live org round-trip occurred — a priming- * lock timeout, or a refresh where a usable cached schema survives), ` [retry=backoff]` * (the introspection request failed transiently AND already exhausted the * connection layer's one built-in retry — wait briefly), or NO token (permanent: * 4xx, malformed/absent `__schema`, GraphQL errors in the body, no cached schema — * don't retry; fix the request, re-auth, or re-prime). The `: ` prefix is * unchanged. The disposition comes from the typed error's stamped `retry` field, * with a defensive `cause`-chain fallback (`classifyCause`) for untyped throws. */ export type ErrorCategory = "UserInput" | "Auth" | "Schema" | "Internal"; interface ToolTextResult { // Index signature mirrors the MCP SDK's CallToolResult so this is assignable // to a registerTool handler's return type. [key: string]: unknown; content: { type: "text"; text: string }[]; isError?: boolean; } // UserInput failures thrown by the intent/lib layer that aren't already carried // by a typed error. Each alternative is anchored to (or contextualized by) the // actual emitting message so a generic Node/library error does NOT get // mislabeled UserInput (which would also hide it from the Internal stderr log). // New UserInput sites should prefer throwing `UserInputError` over extending this. const USER_INPUT_RE = /(^build[A-Z]\w*:|^sf_gql_discover |^command \d+ \(|^empty command\b|^unknown command\b|^(?:select|set|var)\b.*\brequires\b|^set: usage is|is not a valid GraphQL Name|not found on (?:type|input type|field|any member of union)|not found on "|not found in schema|Cannot select field|Cannot apply an inline fragment|Cannot navigate into|Cannot index into non-list type|^Argument "|Empty field name|not supported in v1|Invalid org alias or username)/; // Defensive fallbacks in case a schema/auth failure ever reaches the adapter // untyped (the typed AuthError/SchemaError markers are the primary signal). const SCHEMA_RE = /(No cached schema|Introspection query|Schema has no |did not return a __schema|Schema priming)/; const AUTH_RE = /(Failed to get org info|Missing accessToken or instanceUrl|sf org login)/; /** * Markers substituted for redacted local paths. Exported as the single source of * truth so tests assert against these constants instead of re-typing the literals * (closes the drift between the sanitizer and its tests). */ export const PATH_MARKERS = { schemaCache: "", graphitiHome: "", home: "~", redacted: "", } as const; // `stripLineSeparators` (imported) removes U+2028/U+2029 from every host-visible // envelope below (they trip a Claude.AI 408 \u2014 see `lib/control-chars.ts`). Shared // with the enum-rejection path (W-23336443) so the two sinks cannot drift. // // The Cc/Cf class this escaper acts on is the shared CONTROL_CHAR_RE (see // `lib/control-chars.ts` for the full rationale — why bidi/zero-width/tag chars // are dangerous and why a Unicode property class is used over a hand-enumerated // range). Imported, not re-declared, so this ERROR-envelope escaper and the // enum-rejection stripper (W-23336443) cannot drift apart. // SUCCESS-ENVELOPE SCOPE (W-23148363): the success path (runTool below) emits // JSON.stringify(output), which escapes ONLY the C0 range (U+0000-U+001F). DEL // (U+007F) and the ENTIRE Cf class (bidi overrides, zero-width, BOM, tag block) // survive raw inside quoted JSON string fields, so caller-supplied values (e.g. // sf_gql_list scope/filter/orderBy) reflected into the success envelope are NOT // neutralized. That residual is accepted, not fixed here: it is self-targeting // (values originate from the same host's own tool-call args -- no privilege // boundary crossed) and cannot forge envelope structure (the C0 line/escape // introducers that would fabricate a fake "SYSTEM:" line ARE escaped by // JSON.stringify; the surviving Cf can only reorder/hide the caller's own text). // Value-level neutralization is tracked as a follow-up WI -- note it must emit // GraphQL-valid `\uXXXX` (never `\xNN`) since the success `query` is a live // GraphQL document, so the whole-envelope neutralizer here cannot be reused as-is. // CONTROL_CHAR_RE carries the `g` + `u` flags, required by the `.replace` below. /** * Escape (not strip) control / format characters in host-visible error text so * reflected caller input cannot inject newlines, ANSI escapes, or bidi overrides * (W-23148363). Escaping mirrors the SUCCESS envelope's JSON.stringify semantics * (`\n` -> `\\x0a`, U+202E -> `\\u202e`) -- the byte stays visible for debugging but * inert. Ordinary Unicode (accented names, CJK labels) is untouched. Astral code * points (e.g. the U+E0000-E007F tag block) render as `\\u{...}`; `CONTROL_CHAR_RE` * carries the `u` flag so they match (and escape) as a single code point. */ export function neutralizeControlChars(s: string): string { return s.replace(CONTROL_CHAR_RE, (c) => { const cp = c.codePointAt(0) ?? 0; if (cp <= 0xff) return `\\x${cp.toString(16).padStart(2, "0")}`; if (cp <= 0xffff) return `\\u${cp.toString(16).padStart(4, "0")}`; return `\\u{${cp.toString(16)}}`; }); } /** * Redact local filesystem layout from error text so the MCP host never sees the * developer's home dir, OS username, repo checkout location, or schema-cache * path (W-22697673 info-disclosure). Applied to every category's message. Also * neutralizes Cc/Cf control chars (via `neutralizeControlChars`, LAST) so it is * the single primitive the CLI mirror's error path reuses to sanitize a raw * `err.message`/stack before emitting it (W-23336442, N2). */ export function sanitizePaths(s: string): string { let out = stripLineSeparators(s).replace(/file:\/\//g, ""); // 1) Repo-internal: drop any absolute prefix before packages/ or node_modules/ // so a frame relativizes to `packages/graphiti/...` regardless of checkout. out = out.replace(/(?:\/[^\s/]+)+\/(?=packages\/|node_modules\/)/g, ""); // 2) Relativize known graphiti roots to stable, non-sensitive markers. Longest // first: schemaDir() is under graphitiHome() is (usually) under homedir(). // Relativizing against schemaDir()/graphitiHome() — not just homedir() — // closes the leak when GRAPHITI_HOME lives outside the home dir (CI/shared mounts). const roots: [string, string][] = ( [ [schemaDir(), PATH_MARKERS.schemaCache], [graphitiHome(), PATH_MARKERS.graphitiHome], [os.homedir(), PATH_MARKERS.home], ] as [string, string][] ) .filter(([root]) => root.length > 1) .sort((a, b) => b[0].length - a[0].length); // Relativize each root under BOTH separator styles. `path.join` normalizes to // the platform separator (`\` on Windows), yet the same graphiti path can reach // this text with the OTHER separator — a POSIX-style GRAPHITI_HOME (`/srv/...`) // on Windows keeps `/` in graphitiHome() while schemaDir() switches to `\`, and // wrapped-library errors may carry forward slashes regardless of platform. A // separator-naive exact match would then miss the more-specific root and either // leak the prefix or (as in the priming-lock path) apply the wrong marker. // Flipping `/`<->`\` within a specific absolute root only matches that same path // written the other way, so redaction never broadens. for (const [root, marker] of roots) { for (const variant of [root, root.replace(/\//g, "\\"), root.replace(/\\/g, "/")]) { out = out.split(variant).join(marker); } } // 3) Redact any remaining absolute path (POSIX or Windows) at a token boundary // — /tmp, /var/folders, a foreign user's home, etc. The leading-boundary // guard avoids mangling URLs, whose path follows a non-boundary char (":"/host). out = out.replace( /(^|[\s("'=])(?:[A-Za-z]:)?(?:[/\\][^\s:)"',\\]+){2,}/g, (_m, pre: string) => `${pre}${PATH_MARKERS.redacted}`, ); // Neutralize LAST, after path redaction: escaping emits `\xNN` / `\uNNNN` // (backslash sequences), and the path regex above treats `\` as a separator — // so neutralizing first could let `\x0a\x0a`-style runs be misread as a path // and redacted, eating the debuggable bytes. Running raw control chars through // the path pass is safe (whitespace controls terminate segments; others lack a // leading slash), then we escape what remains. return neutralizeControlChars(out); } function truncatedStack(e: unknown): string[] { if (!(e instanceof Error) || !e.stack) return []; return e.stack .split("\n") .slice(1, 4) // first ~3 frames after the message line .map((line) => sanitizePaths(line.trim())); } function categoryOf(e: unknown, message: string): ErrorCategory { if (e instanceof SchemaRefreshError || e instanceof SchemaError) return "Schema"; if (e instanceof AuthError) return "Auth"; if (e instanceof UserInputError || e instanceof MutationContextError) return "UserInput"; if (USER_INPUT_RE.test(message)) return "UserInput"; if (AUTH_RE.test(message)) return "Auth"; if (SCHEMA_RE.test(message)) return "Schema"; return "Internal"; } /** * Retryability disposition for a Schema-category error (W-23148365). The typed * SchemaError/SchemaRefreshError carry an authoritative `retry` stamped at the * throw site; for an untyped throw that reached the Schema bucket via the * SCHEMA_RE heuristic we fall back to inspecting its `cause`. Only ever called * for the Schema category — Auth/UserInput/Internal are uniformly `"no"`. */ function retryHintFor(e: unknown): RetryHint { if (e instanceof SchemaError || e instanceof SchemaRefreshError) return e.retry; if (typeof e === "object" && e !== null && "cause" in e) { return classifyCause((e as { cause?: unknown }).cause); } return "no"; } /** * Classify a thrown error into a category, a Schema-only retry hint, and a * sanitized message text. `retry` is `"no"` for every non-Schema category. */ export function classifyError(e: unknown): { category: ErrorCategory; retry: RetryHint; text: string; } { const message = e instanceof Error ? e.message : String(e); const category = categoryOf(e, message); const retry = category === "Schema" ? retryHintFor(e) : "no"; const safeMessage = sanitizePaths(message); if (category !== "Internal") return { category, retry, text: safeMessage }; // Internal: unexpected. Attach a truncated, path-stripped stack for the host. const frames = truncatedStack(e); const text = frames.length ? `${safeMessage}\n${frames.map((f) => ` ${f}`).join("\n")}` : safeMessage; return { category, retry, text }; } /** * Run an MCP tool's intent invocation. Returns the success envelope * (`JSON.stringify(output)`) or a category-prefixed `{ isError: true }` envelope. * Internal (unexpected) errors are additionally logged in full to stderr, which * is separate from the stdio JSON-RPC channel, so operators keep the real stack. */ export async function runTool(fn: () => Promise): Promise { try { const output = await fn(); // Strip U+2028/2029 from the success envelope too: codegen/GraphQL output can // carry them and JSON.stringify won't escape them (MCP TS SDK #2155). return { content: [{ type: "text", text: stripLineSeparators(JSON.stringify(output)) }] }; } catch (e) { const { category, retry, text } = classifyError(e); if (category === "Internal") { console.error("[graphiti-mcp] Internal tool error:", e); } // Append the retry hint as an end-anchored token (W-23148365). Only a // Schema error ever carries a non-"no" hint; the `: ` prefix is // unchanged so existing host parses (startsWith / split) still work. const suffix = retry === "no" ? "" : ` [retry=${retry}]`; return { isError: true, content: [{ type: "text", text: stripLineSeparators(`${category}: ${text}${suffix}`) }], }; } }