@vanillagreen/pi-claude-bridge 1.9.0 → 3.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,6 +33,7 @@
33
33
 
34
34
  const CONNECTOR_NS_PREFIX = "mcp__claude_ai_";
35
35
  const DEFAULT_API_BASE = "https://api.anthropic.com";
36
+ const DEFAULT_PROXY_BASE = "https://mcp-proxy.anthropic.com/v1/mcp";
36
37
  // OAuth-token requests to the Anthropic API require this beta header; without it
37
38
  // endpoints reject the bearer credential.
38
39
  const OAUTH_BETA_HEADER = "oauth-2025-04-20";
@@ -43,6 +44,13 @@ export type ConnectorEntry = {
43
44
  installedServerId?: string;
44
45
  /** Catalog identity, shared across accounts that install the same connector. */
45
46
  directoryUuid?: string;
47
+ /**
48
+ * Account-side install state. `"connected"` marks the connectors the CLI
49
+ * actually attempts; everything else it never gives a `Starting connection`
50
+ * line at all. Verified live 2026-07-26: 7 `connected` / 20 `unknown` on the
51
+ * app account, and the CLI connected exactly those 7.
52
+ */
53
+ installState?: string;
46
54
  description?: string;
47
55
  isAuthless?: boolean;
48
56
  };
@@ -77,6 +85,49 @@ export type ClaudeOAuthCredentials = {
77
85
 
78
86
  type Json = Record<string, any>;
79
87
 
88
+ /**
89
+ * The server name Claude Code itself registers a claude.ai connector under.
90
+ *
91
+ * This is the `mcpServers` KEY to use when declaring a connector explicitly, and
92
+ * it is load-bearing rather than cosmetic: the key IS the tool namespace. Keyed
93
+ * as anything else, the same connector appears twice — once from our
94
+ * declaration and once from the CLI's own loader — and consumers that pin
95
+ * fully-qualified tool names (memsira's executor hard-codes them into
96
+ * `--allowedTools` and its system prompt, and never globs a namespace) would
97
+ * be allowed to call neither copy reliably. Verified live: keyed as the CLI's
98
+ * name there is ONE server entry and one namespace (27 servers); keyed otherwise
99
+ * both appear (28 servers).
100
+ *
101
+ * What merges is the NAMESPACE, not the connection. Under the shared name the
102
+ * declaration and the CLI's own loader each still connect: across 40 cold runs
103
+ * the baseline logged 1 Slack connect (7 proxy connects total) and the declared
104
+ * arm logged 2 Slack connects (8 total), in 20 of 20 runs with no exceptions.
105
+ * Declaring N connectors therefore costs ~2N connections, not N. They run in
106
+ * parallel — slowest-connect per run moved from a 1192ms median to 1278ms, worst
107
+ * 1544ms, well inside the 5s cap — but that was measured with ONE declaration.
108
+ */
109
+ export function connectorServerName(connectorName: string): string {
110
+ return `claude.ai ${connectorName.trim()}`;
111
+ }
112
+
113
+ /**
114
+ * The claude.ai MCP proxy endpoint for one installed connector.
115
+ *
116
+ * The `url` field is REQUIRED by the runtime schema, but the CLI does not
117
+ * connect to it: for `type: "claudeai-proxy"` it derives the endpoint from `id`.
118
+ * Caught live — pointing `url` at a local server that never responds still
119
+ * logged `Using claude.ai proxy at …/mcpsrv_01Wcus…` and connected. So this
120
+ * builds the honest value for a required field; it is `id` that must be right.
121
+ *
122
+ * That also explains why both id forms work: the `mcpsrv_…` id from
123
+ * `GET /v1/mcp_servers` and the `installedServerId` UUID this module returns
124
+ * each connected and served identical tools with the CLI's own connector
125
+ * loading disabled. Both resolve at the proxy; neither depends on `url`.
126
+ */
127
+ export function connectorProxyUrl(installedServerId: string, proxyBase: string = DEFAULT_PROXY_BASE): string {
128
+ return `${trimTrailingSlashes(proxyBase)}/${encodeURIComponent(installedServerId)}`;
129
+ }
130
+
80
131
  /**
81
132
  * Tool-namespace prefix for a connector, e.g. `Google Calendar` →
82
133
  * `mcp__claude_ai_Google_Calendar__`. Connector servers are named after the
@@ -89,16 +140,23 @@ export function connectorServerNamespace(connectorName: string): string {
89
140
  }
90
141
 
91
142
  // Candidate credential files, in precedence order. CLAUDE_CONFIG_DIR is set
92
- // per-account by hosts that run one sidecar per Claude account, so it must win
93
- // over the home-directory default or a multi-account host reads the wrong
94
- // account's connectors. Both file names are probed under each root because the
95
- // token and the org UUID do not reliably live in the same file across versions.
143
+ // per-account by hosts that run one sidecar per Claude account, so when it is
144
+ // set that root is probed EXCLUSIVELY: falling through to `~/.claude`/$HOME
145
+ // would silently borrow the DEFAULT account's token for a managed profile
146
+ // whose own `.credentials.json` is missing a confident, well-formed answer
147
+ // for the wrong account (see the token-scoping note above). Only the
148
+ // no-config-dir default probes the home locations. Both file names are probed
149
+ // under each root because the token and the org UUID do not reliably live in
150
+ // the same file across versions.
96
151
  export function credentialCandidatePaths(env: NodeJS.ProcessEnv = process.env): string[] {
97
152
  const roots: string[] = [];
98
153
  const configDir = env.CLAUDE_CONFIG_DIR?.trim();
99
- if (configDir) roots.push(configDir);
100
- const home = env.HOME?.trim();
101
- if (home) roots.push(`${home}/.claude`, home);
154
+ if (configDir) {
155
+ roots.push(configDir);
156
+ } else {
157
+ const home = env.HOME?.trim();
158
+ if (home) roots.push(`${home}/.claude`, home);
159
+ }
102
160
  const seen = new Set<string>();
103
161
  const paths: string[] = [];
104
162
  for (const root of roots) {
@@ -246,6 +304,7 @@ export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<C
246
304
  name,
247
305
  installedServerId: nonEmptyString(entry?.installedServerId),
248
306
  directoryUuid: nonEmptyString(entry?.directoryUuid),
307
+ installState: nonEmptyString(entry?.installState),
249
308
  description: nonEmptyString(entry?.description),
250
309
  isAuthless: typeof entry?.isAuthless === "boolean" ? entry.isAuthless : undefined,
251
310
  });
@@ -0,0 +1,158 @@
1
+ // Connector prime/snapshot host: the per-credential-scope inventory cache the
2
+ // query path reads synchronously (vstack#832/#870). Extracted from index.ts —
3
+ // this is process-lifetime runtime state, not provider streaming logic.
4
+ //
5
+ // Connector declarations for the query path, cached per credential scope. The
6
+ // inventory is one HTTPS round trip; doing it per TURN would add that latency
7
+ // to every message, and an account's connector set does not change
8
+ // mid-session. Keyed by CLAUDE_CONFIG_DIR because that is what selects the
9
+ // account — the org UUID in the request path is ignored, so two accounts on
10
+ // one host differ only by which credential directory was read.
11
+ //
12
+ // FAILS OPEN. If credentials or the inventory call fail we return no
13
+ // declarations and the turn proceeds exactly as it does today: connectors may
14
+ // race, which is the bug, but a network blip must not break the turn outright.
15
+
16
+ import { readFileSync as nodeReadFileSync } from "node:fs";
17
+ import { readCachedConnectors, scopeKeyFor, writeCachedConnectors } from "./connector-cache.js";
18
+ import { listAccountConnectors, resolveClaudeOAuth } from "./connector-inventory.js";
19
+ import { connectorMcpServers } from "./connectors.js";
20
+ import { debug } from "./debug.js";
21
+
22
+ // Read a credential file, treating any read error as "absent" — a missing or
23
+ // unreadable candidate must fall through to the next one, not abort resolution.
24
+ export function readCredentialFile(path: string): string | undefined {
25
+ try {
26
+ return nodeReadFileSync(path, "utf8");
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ const connectorServerCache = new Map<string, Record<string, unknown>>();
33
+ const connectorServerPending = new Set<string>();
34
+ // Epoch ms of the last FAILED inventory attempt per scope, so a persistently
35
+ // failing account cools down instead of issuing one new HTTPS request per turn
36
+ // (VST-14). Missing credentials are exempt: that check is a local file read
37
+ // with no request to bound, and a just-completed `claude login` must take
38
+ // effect on the next turn.
39
+ const connectorServerFailureAt = new Map<string, number>();
40
+
41
+ // Deadline on the inventory round trip. Without one, a hung claude.ai request
42
+ // held the pending flag forever — same budget as the account-host probe's
43
+ // ACCOUNT_PROBE_DEADLINE_MS (VST-14).
44
+ const CONNECTOR_PRIME_TIMEOUT_MS = 10_000;
45
+ const CONNECTOR_PRIME_FAILURE_COOLDOWN_MS = 60_000;
46
+
47
+ /** Overrides for tests; production callers use the defaults. */
48
+ export type PrimeConnectorOverrides = {
49
+ timeoutMs?: number;
50
+ failureCooldownMs?: number;
51
+ now?: () => number;
52
+ };
53
+
54
+ function connectorScopeKey(claudeConfigDir: string | undefined = process.env.CLAUDE_CONFIG_DIR): string {
55
+ // One rule for every scope key — the on-disk cache and this in-memory cache
56
+ // must name the same scopes (see scopeKeyFor).
57
+ return scopeKeyFor(claudeConfigDir);
58
+ }
59
+
60
+ // Credential resolution env for a selected scope: managed requests always pass
61
+ // their RESOLVED dir (accountSessionScope), so the parent's CLAUDE_CONFIG_DIR
62
+ // never leaks into another profile's lookup; legacy passes undefined and keeps
63
+ // the process-env rule.
64
+ export function connectorCredentialEnv(claudeConfigDir: string | undefined = process.env.CLAUDE_CONFIG_DIR): NodeJS.ProcessEnv {
65
+ const env = { ...process.env };
66
+ if (claudeConfigDir?.trim()) env.CLAUDE_CONFIG_DIR = claudeConfigDir.trim();
67
+ else delete env.CLAUDE_CONFIG_DIR;
68
+ return env;
69
+ }
70
+
71
+ // Kick off the inventory fetch for the current credential scope. Fire and
72
+ // forget: the query path can only read a SYNCHRONOUS snapshot, because
73
+ // streamClaudeAgentSdk returns a stream and claims the SDK query handle in the
74
+ // same tick — there is no await boundary to hang a fetch on without
75
+ // restructuring abort handling.
76
+ //
77
+ // Primed at provider registration so the result is in hand well before the
78
+ // first turn (the call measured ~400ms against app startup). If a turn arrives
79
+ // first it declares nothing and behaves exactly as it does today — the race is
80
+ // back for that one turn, which is the bug, but never worse than the status quo.
81
+ //
82
+ // FAILS OPEN throughout: no credentials, a failed inventory, or a thrown call
83
+ // all resolve to "declare nothing" rather than breaking the turn. Failures are
84
+ // NOT cached — a transient blip at registration used to pin `{}` for the
85
+ // process lifetime, keeping every later turn undeclared and the disk-cache
86
+ // fallback unreachable. Leaving the key unset makes the next snapshot retry;
87
+ // the pending set dedupes concurrent fetches, and a failed inventory attempt
88
+ // stamps a per-scope cooldown so a persistently failing account backs off
89
+ // instead of re-priming on every turn (VST-14).
90
+ export function primeConnectorServers(claudeConfigDir?: string, overrides: PrimeConnectorOverrides = {}): void {
91
+ const key = connectorScopeKey(claudeConfigDir);
92
+ if (connectorServerCache.has(key) || connectorServerPending.has(key)) return;
93
+ const now = overrides.now ?? Date.now;
94
+ const cooldownMs = overrides.failureCooldownMs ?? CONNECTOR_PRIME_FAILURE_COOLDOWN_MS;
95
+ const failedAt = connectorServerFailureAt.get(key);
96
+ if (failedAt !== undefined && now() - failedAt < cooldownMs) {
97
+ debug("connectors: prime cooling down after failure; declaring none until retry window opens");
98
+ return;
99
+ }
100
+ connectorServerPending.add(key);
101
+ void (async () => {
102
+ let failed = false;
103
+ try {
104
+ const credentials = resolveClaudeOAuth(readCredentialFile, connectorCredentialEnv(claudeConfigDir));
105
+ if (!credentials) {
106
+ debug("connectors: no OAuth credentials; declaring none (will retry)");
107
+ return;
108
+ }
109
+ const inventory = await listAccountConnectors({
110
+ credentials,
111
+ // A hung inventory request must not outlive the deadline: the abort
112
+ // surfaces as `ok: false` and takes the cooldown path below.
113
+ signal: AbortSignal.timeout(overrides.timeoutMs ?? CONNECTOR_PRIME_TIMEOUT_MS),
114
+ });
115
+ if (!inventory.ok) {
116
+ failed = true;
117
+ debug(`connectors: inventory failed (${inventory.reason}); declaring none (will retry after cooldown)`);
118
+ return;
119
+ }
120
+ const servers = connectorMcpServers(inventory);
121
+ debug(`connectors: declaring ${Object.keys(servers).length} of ${inventory.connectors.length} installed`,
122
+ Object.keys(servers).join(", ") || "none");
123
+ connectorServerCache.set(key, servers);
124
+ connectorServerFailureAt.delete(key);
125
+ // Persist so the NEXT cold process has this synchronously. Priming always
126
+ // loses the race against turn 1 in its own process; a cache written by an
127
+ // earlier run is the only thing turn 1 can read in time (vstack#870).
128
+ if (writeCachedConnectors(inventory.connectors, key)) {
129
+ debug(`connectors: cached ${inventory.connectors.length} entries`);
130
+ }
131
+ } catch (error) {
132
+ failed = true;
133
+ debug("connectors: declaration lookup threw; declaring none (will retry after cooldown)", error);
134
+ } finally {
135
+ if (failed) connectorServerFailureAt.set(key, now());
136
+ connectorServerPending.delete(key);
137
+ }
138
+ })();
139
+ }
140
+
141
+ /** Synchronous snapshot for the query path; `{}` until priming resolves. */
142
+ export function connectorServersSnapshot(claudeConfigDir?: string): Record<string, unknown> {
143
+ const key = connectorScopeKey(claudeConfigDir);
144
+ const ready = connectorServerCache.get(key);
145
+ if (ready) return ready;
146
+ // Always start (or continue) the live fetch — the cache is a head start, not
147
+ // a replacement, and the refresh keeps the next process current.
148
+ primeConnectorServers(claudeConfigDir);
149
+ // Fall back to the previous run's inventory, read synchronously. This is the
150
+ // only thing that can populate turn 1 of a cold process, because priming
151
+ // cannot finish before the first query is built (vstack#870).
152
+ const cached = readCachedConnectors(key);
153
+ if (!cached) return {};
154
+ const servers = connectorMcpServers({ ok: true, complete: true, connectors: cached });
155
+ if (Object.keys(servers).length === 0) return {};
156
+ debug(`connectors: turn-1 declarations from cache — ${Object.keys(servers).join(", ")}`);
157
+ return servers;
158
+ }