@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.
- package/README.md +43 -125
- package/bundle/connector-inventory.js +16 -3
- package/bundle/index.js +3743 -1810
- package/package.json +14 -23
- package/src/account-host.ts +112 -0
- package/src/account-router.ts +272 -0
- package/src/agents-md.ts +54 -10
- package/src/assistant-stream.ts +472 -66
- package/src/auth-presence.ts +6 -50
- package/src/bridge-commands.ts +86 -0
- package/src/bridge-state.ts +200 -15
- package/src/config.ts +170 -20
- package/src/connector-audit.ts +203 -0
- package/src/connector-cache.ts +148 -0
- package/src/connector-inventory.ts +66 -7
- package/src/connector-runtime.ts +158 -0
- package/src/connectors.ts +406 -19
- package/src/consume-query.ts +312 -0
- package/src/convert.ts +20 -10
- package/src/debug.ts +64 -6
- package/src/index.ts +901 -676
- package/src/models.ts +0 -7
- package/src/native-provider.ts +94 -0
- package/src/prompt-context.ts +5 -1
- package/src/query-options.ts +183 -0
- package/src/query-state.ts +490 -25
- package/src/query-teardown.ts +45 -0
- package/src/rate-limit.ts +48 -13
- package/src/request-lane.ts +36 -0
- package/src/sdk-query.ts +16 -0
- package/src/session-persistence.ts +370 -50
- package/src/tool-pairing-audit.ts +117 -0
- package/src/typebox-to-zod.ts +9 -3
|
@@ -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
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
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)
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
}
|