@vanillagreen/pi-claude-bridge 1.8.0 → 2.0.0
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 +78 -15
- package/bundle/connector-inventory.js +147 -0
- package/bundle/index.js +9367 -8348
- package/package.json +11 -7
- package/src/agents-md.ts +5 -7
- package/src/assistant-stream.ts +574 -0
- package/src/auth-presence.ts +6 -50
- package/src/bridge-state.ts +178 -0
- package/src/claude-executable.ts +264 -0
- package/src/config.ts +13 -7
- package/src/connector-audit.ts +203 -0
- package/src/connector-cache.ts +118 -0
- package/src/connector-inventory.ts +333 -0
- package/src/connectors.ts +500 -0
- package/src/convert.ts +14 -0
- package/src/debug.ts +80 -0
- package/src/index.ts +424 -1736
- package/src/models.ts +22 -1
- package/src/native-provider.ts +89 -0
- package/src/query-state.ts +260 -9
- package/src/query-teardown.ts +45 -0
- package/src/rate-limit.ts +95 -0
- package/src/session-persistence.ts +334 -0
- package/src/stream-idle-watchdog.ts +134 -0
- package/src/tool-mapping.ts +53 -0
- package/src/tool-pairing-audit.ts +48 -0
- package/src/typebox-to-zod.ts +9 -3
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Audit trail for connector calls the `claude` child executes itself.
|
|
2
|
+
//
|
|
3
|
+
// A claude.ai connector tool runs INSIDE the child, on the child's own MCP
|
|
4
|
+
// servers, and is deliberately never mirrored into the Pi stream (see
|
|
5
|
+
// isChildExecutedTool). That is the honest behaviour — mirroring wrote
|
|
6
|
+
// `Tool <name> not found` into the transcript for calls that had SUCCEEDED
|
|
7
|
+
// (drovr#311 / memsira#320) — but it leaves the Pi session with no record that
|
|
8
|
+
// the call happened at all, so "did it really look that up?" could only be
|
|
9
|
+
// answered from the child's own transcript.
|
|
10
|
+
//
|
|
11
|
+
// A pi `CustomEntry` closes that gap without reintroducing the bug: it is
|
|
12
|
+
// persisted in the session file, is NOT a content block, and is documented as
|
|
13
|
+
// "ignored by buildSessionContext", so Pi's agent loop can never dispatch it and
|
|
14
|
+
// `convertPiMessages` (which reads messages, not entries) can never project it
|
|
15
|
+
// back into the child's session. `CustomMessageEntry` is the sibling type that
|
|
16
|
+
// DOES enter context — using it here would recreate the whole problem.
|
|
17
|
+
//
|
|
18
|
+
// The payload is never recorded. A connector result is live account data (mail,
|
|
19
|
+
// messages, documents); the audit answers whether a call happened and what came
|
|
20
|
+
// back, not what it said.
|
|
21
|
+
|
|
22
|
+
import { extensionApi } from "./bridge-state.js";
|
|
23
|
+
import { debug } from "./debug.js";
|
|
24
|
+
import type { QueryContext, ToolCallDrainCause } from "./query-state.js";
|
|
25
|
+
|
|
26
|
+
export const CONNECTOR_CALL_CUSTOM_TYPE = "claude-bridge-connector-call";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What the bridge observed of a connector call.
|
|
30
|
+
*
|
|
31
|
+
* `unobserved` is the load-bearing one: the call was issued and the query ended
|
|
32
|
+
* before its result came back. Recording nothing for it would leave an answer in
|
|
33
|
+
* the transcript with no trace of the call behind it — indistinguishable from a
|
|
34
|
+
* turn where no call was ever made. Same reasoning as `interruptedToolCallResult`
|
|
35
|
+
* for Pi-side tools: a call that did not complete says so.
|
|
36
|
+
*/
|
|
37
|
+
export type ConnectorCallOutcome = "ok" | "error" | "unobserved";
|
|
38
|
+
|
|
39
|
+
export interface ConnectorCallAuditData {
|
|
40
|
+
/** Raw connector tool name as the child invoked it (`mcp__claude_ai_<Server>__<tool>`). */
|
|
41
|
+
name: string;
|
|
42
|
+
/** The child's own `tool_use` id — the join key to its transcript. */
|
|
43
|
+
toolUseId: string;
|
|
44
|
+
outcome: ConnectorCallOutcome;
|
|
45
|
+
/** UTF-8 byte size of the observed result payload. Absent when the result was
|
|
46
|
+
* never observed, or could not be measured — never a confident 0 for
|
|
47
|
+
* something that was not sized. */
|
|
48
|
+
byteSize?: number;
|
|
49
|
+
/** Claude Code session that executed the call. Absent when the SDK never
|
|
50
|
+
* reported one for this query. */
|
|
51
|
+
childSessionId?: string;
|
|
52
|
+
/** Why an `unobserved` call ended. Absent for observed ones. */
|
|
53
|
+
reason?: ToolCallDrainCause;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Approximate UTF-8 byte size of a child tool result's payload.
|
|
58
|
+
*
|
|
59
|
+
* A string payload is measured directly; any other shape is measured as its JSON
|
|
60
|
+
* serialization, so a structured or image result reports the size it really
|
|
61
|
+
* carried. (The debug line this replaces reported `content.length` for an array
|
|
62
|
+
* payload — a BLOCK count wearing a byte size's name.)
|
|
63
|
+
*
|
|
64
|
+
* Returns undefined when there is nothing to measure or the payload cannot be
|
|
65
|
+
* serialized, so the caller omits the field rather than recording a 0 it did not
|
|
66
|
+
* measure. The payload itself is never returned or logged.
|
|
67
|
+
*/
|
|
68
|
+
export function connectorResultByteSize(content: unknown): number | undefined {
|
|
69
|
+
if (content === undefined || content === null) return undefined;
|
|
70
|
+
if (typeof content === "string") return Buffer.byteLength(content, "utf8");
|
|
71
|
+
try {
|
|
72
|
+
const json = JSON.stringify(content);
|
|
73
|
+
return typeof json === "string" ? Buffer.byteLength(json, "utf8") : undefined;
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* An ADDITIONAL destination for connector-call records, for a host that embeds
|
|
81
|
+
* the bridge with no pi session to append to.
|
|
82
|
+
*
|
|
83
|
+
* Never throws is the contract on OUR side; a sink that throws anyway is caught
|
|
84
|
+
* and dropped, because an audit record must not be able to fail a turn.
|
|
85
|
+
*/
|
|
86
|
+
export type ConnectorCallAuditSink = (data: ConnectorCallAuditData) => void;
|
|
87
|
+
|
|
88
|
+
let auditSink: ConnectorCallAuditSink | undefined;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Install (or clear, with `undefined`) a host sink for connector-call records.
|
|
92
|
+
*
|
|
93
|
+
* **The sink ADDS a destination, it never replaces `appendEntry`.** A host that
|
|
94
|
+
* drives real `AgentSession`s gets the transcript-local entries for free, and a
|
|
95
|
+
* replacing sink would take those away and reopen the very audit gap this
|
|
96
|
+
* feature closes (memsira, 2026-07-28 — their `apps/sidecar/src/runtime.ts` is
|
|
97
|
+
* session-backed, and 122 of their app-chat session files carry the bridge's
|
|
98
|
+
* `claude-bridge-session` markers). A host with both a session and a sink has
|
|
99
|
+
* asked for both and gets both.
|
|
100
|
+
*
|
|
101
|
+
* It exists because the OTHER embedding shape gets nothing at all: drovr loads
|
|
102
|
+
* the bundle through a throwaway resource loader over
|
|
103
|
+
* `createAgentSessionServices` with no session, so `extensionApi` is undefined
|
|
104
|
+
* and every record it appended went nowhere (drovr #317, measured live). The
|
|
105
|
+
* sink is the seam such a host can reach without one.
|
|
106
|
+
*
|
|
107
|
+
* A callback rather than another `Symbol.for` global on purpose: the bundle
|
|
108
|
+
* already has one (`claude-bridge:activeStreamSimple`) and hosts document that
|
|
109
|
+
* coupling as a re-vendor hazard, so a second would be the wrong direction.
|
|
110
|
+
*
|
|
111
|
+
* Process-global, matching `setExtensionApi`: one bundle instance serves one
|
|
112
|
+
* host. A host that runs several conversations in ONE process must therefore
|
|
113
|
+
* route by `childSessionId` itself, or embed per conversation as drovr does.
|
|
114
|
+
*/
|
|
115
|
+
export function setConnectorCallAuditSink(sink: ConnectorCallAuditSink | undefined): void {
|
|
116
|
+
auditSink = sink;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Append one audit entry to every destination the host installed. Returns
|
|
121
|
+
* whether it reached AT LEAST ONE of them, so a caller can log the truth rather
|
|
122
|
+
* than assume — both are absent whenever the bridge runs outside a pi session
|
|
123
|
+
* and outside a sink-installing host (tests, the connector-inventory entry
|
|
124
|
+
* point).
|
|
125
|
+
*
|
|
126
|
+
* The two destinations are independent: one throwing must not cost the other
|
|
127
|
+
* its record. Never throws — an audit record must not be able to fail a turn.
|
|
128
|
+
*/
|
|
129
|
+
export function appendConnectorCallAudit(data: ConnectorCallAuditData): boolean {
|
|
130
|
+
let delivered = false;
|
|
131
|
+
if (extensionApi) {
|
|
132
|
+
try {
|
|
133
|
+
extensionApi.appendEntry(CONNECTOR_CALL_CUSTOM_TYPE, data);
|
|
134
|
+
delivered = true;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
debug("appendConnectorCallAudit failed:", error);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (auditSink) {
|
|
140
|
+
try {
|
|
141
|
+
// A COPY, because the sink is host code and `appendEntry` above holds a
|
|
142
|
+
// reference to the same object: a sink that mutated the record would be
|
|
143
|
+
// editing what the session already recorded.
|
|
144
|
+
auditSink({ ...data });
|
|
145
|
+
delivered = true;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
debug("connector call audit sink failed:", error);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return delivered;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Record the observed result of a child-executed connector call, once.
|
|
155
|
+
*
|
|
156
|
+
* Keyed on the tool_use id rather than on the call site: the SDK can re-yield a
|
|
157
|
+
* `user` message, and an audit trail that counts one call twice is worse than
|
|
158
|
+
* one that counts it not at all. Returns whether an entry was appended.
|
|
159
|
+
*/
|
|
160
|
+
export function recordConnectorCallResult(
|
|
161
|
+
queryCtx: QueryContext,
|
|
162
|
+
toolUseId: string,
|
|
163
|
+
name: string,
|
|
164
|
+
isError: boolean,
|
|
165
|
+
byteSize: number | undefined,
|
|
166
|
+
): boolean {
|
|
167
|
+
const pending = queryCtx.connectorCallAudit.get(toolUseId);
|
|
168
|
+
if (pending?.recorded) return false;
|
|
169
|
+
const childSessionId = pending?.childSessionId ?? queryCtx.childSessionId;
|
|
170
|
+
queryCtx.connectorCallAudit.set(toolUseId, { ...pending, name, childSessionId, recorded: true });
|
|
171
|
+
return appendConnectorCallAudit({
|
|
172
|
+
name,
|
|
173
|
+
toolUseId,
|
|
174
|
+
outcome: isError ? "error" : "ok",
|
|
175
|
+
...(byteSize !== undefined ? { byteSize } : {}),
|
|
176
|
+
...(childSessionId ? { childSessionId } : {}),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Record every connector call this query issued whose result never came back,
|
|
182
|
+
* naming the cause. Runs at query teardown, beside the Pi-side tool drain, and is
|
|
183
|
+
* idempotent — a call already recorded is skipped, and one recorded here is
|
|
184
|
+
* marked so a late arrival cannot record it again.
|
|
185
|
+
*
|
|
186
|
+
* Returns how many entries were appended.
|
|
187
|
+
*/
|
|
188
|
+
export function flushConnectorCallAudit(queryCtx: QueryContext, reason: ToolCallDrainCause): number {
|
|
189
|
+
let appended = 0;
|
|
190
|
+
for (const [toolUseId, state] of queryCtx.connectorCallAudit) {
|
|
191
|
+
if (state.recorded) continue;
|
|
192
|
+
queryCtx.connectorCallAudit.set(toolUseId, { ...state, recorded: true });
|
|
193
|
+
const childSessionId = state.childSessionId ?? queryCtx.childSessionId;
|
|
194
|
+
if (appendConnectorCallAudit({
|
|
195
|
+
name: state.name,
|
|
196
|
+
toolUseId,
|
|
197
|
+
outcome: "unobserved",
|
|
198
|
+
reason,
|
|
199
|
+
...(childSessionId ? { childSessionId } : {}),
|
|
200
|
+
})) appended++;
|
|
201
|
+
}
|
|
202
|
+
return appended;
|
|
203
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Cross-PROCESS cache of the connector inventory (vstack#870).
|
|
2
|
+
//
|
|
3
|
+
// #868 primes the inventory at provider registration, but the fetch takes ~1.5s
|
|
4
|
+
// while the first query is built at ~0.5-0.8s, so turn 1 of a cold sidecar goes
|
|
5
|
+
// out with no declarations and gets exactly the #832 bug it was meant to fix.
|
|
6
|
+
//
|
|
7
|
+
// An in-process cache cannot help the consumer that needs it most. drovr builds
|
|
8
|
+
// a sidecar lazily on the first bridge round and, since their sidecars are
|
|
9
|
+
// per-SESSION, that is a fresh process for every new chat — so their exposure is
|
|
10
|
+
// once per chat, indefinitely, and every one of those is a cold process. The
|
|
11
|
+
// cache therefore has to survive process boundaries.
|
|
12
|
+
//
|
|
13
|
+
// Keyed by credential scope, because that is what selects the account: the org
|
|
14
|
+
// UUID in the inventory request is ignored and only the credential decides whose
|
|
15
|
+
// connectors come back. Two accounts on one host must not share a cache entry.
|
|
16
|
+
//
|
|
17
|
+
// Everything here is best-effort. A missing, unreadable, corrupt, stale, or
|
|
18
|
+
// wrong-version cache returns undefined and the caller falls back to today's
|
|
19
|
+
// behaviour — the same fail-open contract as the inventory call itself.
|
|
20
|
+
//
|
|
21
|
+
// The ON-DISK FORMAT HAS AN EXTERNAL READER (vstack#892). drovr quarantines this
|
|
22
|
+
// bundle to its sidecar process, so rather than calling `listAccountConnectors`
|
|
23
|
+
// in-process it re-implements the reader half — path
|
|
24
|
+
// `<piUserDir()>/connector-cache/<sha256(CLAUDE_CONFIG_DIR).hex[0..16]>.json`,
|
|
25
|
+
// payload `{version, scope, savedAt, connectors}`, 7-day max age — as the
|
|
26
|
+
// "is this connector installed" half of its write gate.
|
|
27
|
+
//
|
|
28
|
+
// That coupling fails OPEN on drift by design, so a format change degrades them
|
|
29
|
+
// from two gates to one rather than breaking them. It is still worth making the
|
|
30
|
+
// change knowingly: bump CACHE_VERSION so their staleness check rejects rather
|
|
31
|
+
// than misreads, and say so in the changelog. `unit-connector-cache.mjs` pins
|
|
32
|
+
// the path shape and payload keys.
|
|
33
|
+
import { createHash } from "node:crypto";
|
|
34
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
35
|
+
import { dirname, join } from "node:path";
|
|
36
|
+
import { piUserDir } from "./config.js";
|
|
37
|
+
import type { ConnectorEntry } from "./connector-inventory.js";
|
|
38
|
+
|
|
39
|
+
const CACHE_VERSION = 1;
|
|
40
|
+
/** Long enough to be useful across a machine's lifetime, short enough that a
|
|
41
|
+
* removed connector stops being declared without needing a manual purge. A
|
|
42
|
+
* stale entry is not dangerous — a connector that no longer resolves simply
|
|
43
|
+
* fails to connect, which is the fail-open path — so this is hygiene, not a
|
|
44
|
+
* correctness boundary. */
|
|
45
|
+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
46
|
+
|
|
47
|
+
export function connectorCacheScopeKey(env: NodeJS.ProcessEnv = process.env): string {
|
|
48
|
+
return env.CLAUDE_CONFIG_DIR?.trim() || "<default>";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Our own state directory, not the Claude config dir. The credential directory
|
|
53
|
+
* belongs to the CLI; the scope is encoded in the filename instead so we never
|
|
54
|
+
* write into someone else's tree. Hashed rather than escaped because a config
|
|
55
|
+
* dir is an arbitrary absolute path.
|
|
56
|
+
*/
|
|
57
|
+
export function connectorCachePath(scopeKey: string = connectorCacheScopeKey()): string {
|
|
58
|
+
const digest = createHash("sha256").update(scopeKey).digest("hex").slice(0, 16);
|
|
59
|
+
return join(piUserDir(), "connector-cache", `${digest}.json`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Synchronous by design. The query path has no await boundary to hang a read on
|
|
64
|
+
* — `streamClaudeAgentSdk` returns its stream and claims the SDK query handle in
|
|
65
|
+
* the same tick — which is the whole reason the in-memory prime loses the race.
|
|
66
|
+
* A single small `readFileSync` is what makes turn 1 reachable at all.
|
|
67
|
+
*/
|
|
68
|
+
export function readCachedConnectors(
|
|
69
|
+
scopeKey: string = connectorCacheScopeKey(),
|
|
70
|
+
now: number = Date.now(),
|
|
71
|
+
): ConnectorEntry[] | undefined {
|
|
72
|
+
let raw: string;
|
|
73
|
+
try {
|
|
74
|
+
raw = readFileSync(connectorCachePath(scopeKey), "utf8");
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
let parsed: any;
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(raw);
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
if (parsed?.version !== CACHE_VERSION) return undefined;
|
|
85
|
+
// Scope is stored as well as hashed into the path: a hash collision or a
|
|
86
|
+
// hand-copied file would otherwise hand one account another's connectors,
|
|
87
|
+
// which is the exact failure the token-scoping note in connector-inventory.ts
|
|
88
|
+
// warns about.
|
|
89
|
+
if (parsed?.scope !== scopeKey) return undefined;
|
|
90
|
+
const savedAt = typeof parsed?.savedAt === "number" ? parsed.savedAt : 0;
|
|
91
|
+
if (!savedAt || now - savedAt > MAX_AGE_MS || savedAt > now) return undefined;
|
|
92
|
+
if (!Array.isArray(parsed?.connectors)) return undefined;
|
|
93
|
+
const connectors = parsed.connectors.filter(
|
|
94
|
+
(entry: any) => entry && typeof entry.name === "string" && entry.name.trim(),
|
|
95
|
+
);
|
|
96
|
+
return connectors.length > 0 ? (connectors as ConnectorEntry[]) : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Best-effort write; a failure here must never affect the turn. */
|
|
100
|
+
export function writeCachedConnectors(
|
|
101
|
+
connectors: ConnectorEntry[],
|
|
102
|
+
scopeKey: string = connectorCacheScopeKey(),
|
|
103
|
+
now: number = Date.now(),
|
|
104
|
+
): boolean {
|
|
105
|
+
if (!Array.isArray(connectors) || connectors.length === 0) return false;
|
|
106
|
+
const path = connectorCachePath(scopeKey);
|
|
107
|
+
try {
|
|
108
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
109
|
+
writeFileSync(
|
|
110
|
+
path,
|
|
111
|
+
JSON.stringify({ version: CACHE_VERSION, scope: scopeKey, savedAt: now, connectors }),
|
|
112
|
+
{ mode: 0o600 },
|
|
113
|
+
);
|
|
114
|
+
return true;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Deterministic enumeration of a Claude account's installed claude.ai connectors.
|
|
2
|
+
//
|
|
3
|
+
// The capability probe this replaces asked the MODEL to enumerate connectors via
|
|
4
|
+
// ToolSearch. A search returns what the search surfaced, which is a LOWER BOUND —
|
|
5
|
+
// nothing in the result distinguishes "these are the connectors" from "these are
|
|
6
|
+
// the connectors the search happened to return this time". Downstream then stored
|
|
7
|
+
// that lower bound as authoritative, so an account with Slack attached could
|
|
8
|
+
// report an inventory without Slack and no failure signal (vstack#838).
|
|
9
|
+
//
|
|
10
|
+
// This module asks the account instead of the model. Verified live against a
|
|
11
|
+
// personal claude_max org: the endpoint is POST (a GET returns 405) and each
|
|
12
|
+
// result carries BOTH `directoryUuid` (the catalog identity) and
|
|
13
|
+
// `installedServerId` (this account's installed instance). A marketplace catalog
|
|
14
|
+
// entry has no installed-server id, which is what establishes this as the
|
|
15
|
+
// INSTALLED set rather than the registry listing.
|
|
16
|
+
//
|
|
17
|
+
// INSTALLED IS NOT ATTACHED. This endpoint reports what the ACCOUNT has
|
|
18
|
+
// installed. It says nothing about whether a given connector's MCP server has
|
|
19
|
+
// finished attaching inside the `claude` child that is about to run a turn —
|
|
20
|
+
// this is a plain HTTPS call and does not consult that process at all. The two
|
|
21
|
+
// were previously conflated by accident: the ToolSearch probe could only report
|
|
22
|
+
// what was already attached, so an inventory implied availability (wrongly, but
|
|
23
|
+
// conservatively — it under-reported, which fails safe). They are now separately
|
|
24
|
+
// observable and can legitimately disagree: a correct `complete: true` inventory
|
|
25
|
+
// can name Slack while `mcp__claude_ai_Slack__*` is not yet callable in this
|
|
26
|
+
// process (vstack#832). Treat an inventory as NECESSARY BUT NOT SUFFICIENT for
|
|
27
|
+
// availability and keep an attach-time check on the call path; do not derive
|
|
28
|
+
// "can I call this tool right now" from this result.
|
|
29
|
+
//
|
|
30
|
+
// Because the answer comes from the account rather than a model turn, the result
|
|
31
|
+
// is complete by construction — hence `complete: true` on success, and no
|
|
32
|
+
// "partial" state. A failure is a failure, never an empty-but-successful list.
|
|
33
|
+
|
|
34
|
+
const CONNECTOR_NS_PREFIX = "mcp__claude_ai_";
|
|
35
|
+
const DEFAULT_API_BASE = "https://api.anthropic.com";
|
|
36
|
+
const DEFAULT_PROXY_BASE = "https://mcp-proxy.anthropic.com/v1/mcp";
|
|
37
|
+
// OAuth-token requests to the Anthropic API require this beta header; without it
|
|
38
|
+
// endpoints reject the bearer credential.
|
|
39
|
+
const OAUTH_BETA_HEADER = "oauth-2025-04-20";
|
|
40
|
+
|
|
41
|
+
export type ConnectorEntry = {
|
|
42
|
+
name: string;
|
|
43
|
+
/** This account's installed instance. Present on every live result observed. */
|
|
44
|
+
installedServerId?: string;
|
|
45
|
+
/** Catalog identity, shared across accounts that install the same connector. */
|
|
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;
|
|
54
|
+
description?: string;
|
|
55
|
+
isAuthless?: boolean;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Discriminated so a caller cannot read `connectors` without having checked `ok`.
|
|
59
|
+
// `complete` is carried explicitly rather than implied: the whole defect this
|
|
60
|
+
// fixes was a result that looked authoritative while being a lower bound.
|
|
61
|
+
// The absent side of each variant is declared as `?: undefined` rather than
|
|
62
|
+
// omitted: this package compiles with `strict: false`, where narrowing a union
|
|
63
|
+
// by a boolean discriminant does not reliably filter members, so a bare
|
|
64
|
+
// `{ok:true}|{ok:false}` pair makes `inventory.reason` a compile error at every
|
|
65
|
+
// call site. Spelling both sides keeps the union discriminated AND readable
|
|
66
|
+
// without depending on strictNullChecks-era narrowing.
|
|
67
|
+
export type ConnectorInventory =
|
|
68
|
+
| { ok: true; complete: true; connectors: ConnectorEntry[]; reason?: undefined }
|
|
69
|
+
| { ok: false; complete: false; connectors?: undefined; reason: string };
|
|
70
|
+
|
|
71
|
+
// SCOPING IS BY TOKEN, NOT BY ORG. Verified live: the org UUID in the path is
|
|
72
|
+
// ignored — an all-zero UUID and the literal string "not-a-uuid" both returned
|
|
73
|
+
// the bearer token's own account, identically to the real org. A multi-account
|
|
74
|
+
// host therefore CANNOT select an account by passing its organizationUuid; the
|
|
75
|
+
// only thing that selects an account is which credential the token came from
|
|
76
|
+
// (i.e. which CLAUDE_CONFIG_DIR was read). Getting that wrong yields a
|
|
77
|
+
// confident, well-formed answer for the WRONG account.
|
|
78
|
+
//
|
|
79
|
+
// The real UUID is still sent rather than a placeholder, so the call keeps
|
|
80
|
+
// working if the API starts enforcing it.
|
|
81
|
+
export type ClaudeOAuthCredentials = {
|
|
82
|
+
accessToken: string;
|
|
83
|
+
organizationUuid: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
type Json = Record<string, any>;
|
|
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
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Tool-namespace prefix for a connector, e.g. `Google Calendar` →
|
|
133
|
+
* `mcp__claude_ai_Google_Calendar__`. Connector servers are named after the
|
|
134
|
+
* connector with whitespace replaced by underscores; corroborated against the
|
|
135
|
+
* independently-authored CLAUDE_AI_CONNECTOR_TOOL_PATTERNS in connectors.ts,
|
|
136
|
+
* which was built from a live tool enumeration rather than from this rule.
|
|
137
|
+
*/
|
|
138
|
+
export function connectorServerNamespace(connectorName: string): string {
|
|
139
|
+
return `${CONNECTOR_NS_PREFIX}${connectorName.trim().replace(/\s+/g, "_")}__`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Candidate credential files, in precedence order. CLAUDE_CONFIG_DIR is set
|
|
143
|
+
// per-account by hosts that run one sidecar per Claude account, so it must win
|
|
144
|
+
// over the home-directory default or a multi-account host reads the wrong
|
|
145
|
+
// account's connectors. Both file names are probed under each root because the
|
|
146
|
+
// token and the org UUID do not reliably live in the same file across versions.
|
|
147
|
+
export function credentialCandidatePaths(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
148
|
+
const roots: string[] = [];
|
|
149
|
+
const configDir = env.CLAUDE_CONFIG_DIR?.trim();
|
|
150
|
+
if (configDir) roots.push(configDir);
|
|
151
|
+
const home = env.HOME?.trim();
|
|
152
|
+
if (home) roots.push(`${home}/.claude`, home);
|
|
153
|
+
const seen = new Set<string>();
|
|
154
|
+
const paths: string[] = [];
|
|
155
|
+
for (const root of roots) {
|
|
156
|
+
for (const name of [".credentials.json", ".claude.json"]) {
|
|
157
|
+
const p = `${root}/${name}`;
|
|
158
|
+
if (!seen.has(p)) { seen.add(p); paths.push(p); }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return paths;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Pull the OAuth access token and organization UUID out of the Claude config.
|
|
166
|
+
* They are scanned independently across all candidate files because they are not
|
|
167
|
+
* guaranteed to co-locate: on the machine this was verified against, the token
|
|
168
|
+
* lives in `.credentials.json` and the org UUID in `.claude.json`.
|
|
169
|
+
*
|
|
170
|
+
* `readFile` returns undefined for a missing/unreadable path. Parse failures are
|
|
171
|
+
* skipped rather than thrown — a corrupt file must not mask a good one later in
|
|
172
|
+
* the list.
|
|
173
|
+
*/
|
|
174
|
+
export function resolveClaudeOAuth(
|
|
175
|
+
readFile: (path: string) => string | undefined,
|
|
176
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
177
|
+
): ClaudeOAuthCredentials | undefined {
|
|
178
|
+
let accessToken: string | undefined;
|
|
179
|
+
let organizationUuid: string | undefined;
|
|
180
|
+
|
|
181
|
+
for (const path of credentialCandidatePaths(env)) {
|
|
182
|
+
const raw = readFile(path);
|
|
183
|
+
if (!raw) continue;
|
|
184
|
+
let parsed: Json;
|
|
185
|
+
try {
|
|
186
|
+
parsed = JSON.parse(raw) as Json;
|
|
187
|
+
} catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
accessToken ??= nonEmptyString(parsed?.claudeAiOauth?.accessToken);
|
|
191
|
+
organizationUuid ??= nonEmptyString(parsed?.oauthAccount?.organizationUuid);
|
|
192
|
+
if (accessToken && organizationUuid) break;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!accessToken || !organizationUuid) return undefined;
|
|
196
|
+
return { accessToken, organizationUuid };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function nonEmptyString(value: unknown): string | undefined {
|
|
200
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function connectorsListUrl(organizationUuid: string, apiBase: string = DEFAULT_API_BASE): string {
|
|
204
|
+
return `${trimTrailingSlashes(apiBase)}/api/oauth/organizations/${encodeURIComponent(organizationUuid)}/mcp/connectors/list`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Linear-time trailing-slash trim. This was `apiBase.replace(/\/+$/, "")`, which
|
|
208
|
+
// CodeQL correctly flags as a polynomial regex on uncontrolled input: `apiBase`
|
|
209
|
+
// is a caller-supplied parameter, and an anchored `+` backtracks on a long run
|
|
210
|
+
// of slashes. It only became reachable as library input once this module gained
|
|
211
|
+
// a real export surface, which is exactly the exposure the export was for.
|
|
212
|
+
function trimTrailingSlashes(value: string): string {
|
|
213
|
+
let end = value.length;
|
|
214
|
+
while (end > 0 && value.charCodeAt(end - 1) === 47 /* "/" */) end--;
|
|
215
|
+
return value.slice(0, end);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export type ListConnectorsDeps = {
|
|
219
|
+
credentials: ClaudeOAuthCredentials;
|
|
220
|
+
fetchImpl?: typeof fetch;
|
|
221
|
+
apiBase?: string;
|
|
222
|
+
signal?: AbortSignal;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Enumerate the account's installed connectors. Never throws: transport and
|
|
227
|
+
* protocol failures come back as `{ ok: false }` with a reason, so a caller can
|
|
228
|
+
* distinguish "this account has no connectors" (ok, empty list) from "we could
|
|
229
|
+
* not find out" — the distinction the search-driven probe could not express.
|
|
230
|
+
*
|
|
231
|
+
* The reason string is built only from the HTTP status and the API's own error
|
|
232
|
+
* message; the bearer token is never interpolated into it or logged.
|
|
233
|
+
*/
|
|
234
|
+
export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<ConnectorInventory> {
|
|
235
|
+
const { credentials, apiBase, signal } = deps;
|
|
236
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
237
|
+
const url = connectorsListUrl(credentials.organizationUuid, apiBase);
|
|
238
|
+
// Every failure return goes through this. Transport errors are the risk: a
|
|
239
|
+
// fetch/proxy layer is free to put the request headers — and therefore the
|
|
240
|
+
// bearer token — into the message it throws, and that message would otherwise
|
|
241
|
+
// land in a reason string that callers log.
|
|
242
|
+
const fail = (reason: string): ConnectorInventory =>
|
|
243
|
+
({ ok: false, complete: false, reason: redactSecret(reason, credentials.accessToken) });
|
|
244
|
+
|
|
245
|
+
let response: Response;
|
|
246
|
+
try {
|
|
247
|
+
response = await fetchImpl(url, {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: {
|
|
250
|
+
"Authorization": `Bearer ${credentials.accessToken}`,
|
|
251
|
+
"anthropic-beta": OAUTH_BETA_HEADER,
|
|
252
|
+
"Content-Type": "application/json",
|
|
253
|
+
},
|
|
254
|
+
body: "{}",
|
|
255
|
+
signal,
|
|
256
|
+
});
|
|
257
|
+
} catch (error) {
|
|
258
|
+
return fail(`connector list request failed: ${errorText(error)}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let bodyText: string;
|
|
262
|
+
try {
|
|
263
|
+
bodyText = await response.text();
|
|
264
|
+
} catch (error) {
|
|
265
|
+
return fail(`connector list response unreadable: ${errorText(error)}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!response.ok) {
|
|
269
|
+
return fail(`connector list returned HTTP ${response.status}${apiErrorSuffix(bodyText)}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let parsed: Json;
|
|
273
|
+
try {
|
|
274
|
+
parsed = JSON.parse(bodyText) as Json;
|
|
275
|
+
} catch {
|
|
276
|
+
return fail("connector list returned a non-JSON body");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// A missing/!Array `results` is a protocol change, not an empty account. Treat
|
|
280
|
+
// it as failure — reporting "no connectors" here would recreate exactly the
|
|
281
|
+
// silent-wrong-answer failure this module exists to remove.
|
|
282
|
+
if (!Array.isArray(parsed?.results)) {
|
|
283
|
+
return fail("connector list response had no results array");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const connectors: ConnectorEntry[] = [];
|
|
287
|
+
for (const raw of parsed.results as unknown[]) {
|
|
288
|
+
const entry = raw as Json;
|
|
289
|
+
const name = nonEmptyString(entry?.name);
|
|
290
|
+
// An unnamed entry cannot be matched to a tool namespace by any consumer,
|
|
291
|
+
// so silently keeping it would understate the inventory in a way the
|
|
292
|
+
// caller could not detect. Fail instead.
|
|
293
|
+
if (!name) {
|
|
294
|
+
return fail("connector list contained an entry with no name");
|
|
295
|
+
}
|
|
296
|
+
connectors.push({
|
|
297
|
+
name,
|
|
298
|
+
installedServerId: nonEmptyString(entry?.installedServerId),
|
|
299
|
+
directoryUuid: nonEmptyString(entry?.directoryUuid),
|
|
300
|
+
installState: nonEmptyString(entry?.installState),
|
|
301
|
+
description: nonEmptyString(entry?.description),
|
|
302
|
+
isAuthless: typeof entry?.isAuthless === "boolean" ? entry.isAuthless : undefined,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return { ok: true, complete: true, connectors };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function apiErrorSuffix(bodyText: string): string {
|
|
310
|
+
try {
|
|
311
|
+
const message = (JSON.parse(bodyText) as Json)?.error?.message;
|
|
312
|
+
return typeof message === "string" && message.trim() ? ` (${message.trim()})` : "";
|
|
313
|
+
} catch {
|
|
314
|
+
return "";
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Replace the bearer token wherever it appears in text headed for a caller.
|
|
319
|
+
// Also covers a URL-encoded rendering, since some transports encode headers into
|
|
320
|
+
// an error's message. Short/empty tokens are not substituted — an over-eager
|
|
321
|
+
// match would corrupt unrelated text.
|
|
322
|
+
function redactSecret(text: string, secret: string): string {
|
|
323
|
+
if (!secret || secret.length < 8) return text;
|
|
324
|
+
let out = text;
|
|
325
|
+
for (const form of new Set([secret, encodeURIComponent(secret)])) {
|
|
326
|
+
out = out.split(form).join("[redacted]");
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function errorText(error: unknown): string {
|
|
332
|
+
return error instanceof Error ? error.message : String(error);
|
|
333
|
+
}
|