@compilr-dev/sdk 0.29.2 → 0.29.3

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/dist/index.d.ts CHANGED
@@ -109,8 +109,10 @@ export { FileEpisodeStore, EpisodeRecorder, isSignificantWork, extractAffectedFi
109
109
  export type { FileEpisodeStoreOptions, EpisodeRecorderConfig, WorkSummaryAnchorConfig, PendingToolSignal, EpisodeFile, } from './episodes/index.js';
110
110
  export { resolveNext, evaluateBranchCondition, walkPastBranches, lookupLabel, applyBacktrack, } from './flow-runner/index.js';
111
111
  export type { BacktrackResult } from './flow-runner/index.js';
112
- export { readMCPConfigFile, writeMCPConfigFile, resolveServerEntry, loadMCPServers, saveMCPServerEntry, deleteMCPServerEntry, getServerNames, } from './mcp-config.js';
113
- export type { MCPServerEntry, MCPConfigFile, ResolvedMCPServer } from './mcp-config.js';
112
+ export { readMCPConfigFile, writeMCPConfigFile, resolveServerEntry, loadMCPServers, saveMCPServerEntry, deleteMCPServerEntry, getServerNames, readMCPConfig, assertWritable, canConnect, } from './mcp-config.js';
113
+ export type { MCPServerEntry, MCPConfigFile, ResolvedMCPServer, MCPConfigRead, ConfigValue, } from './mcp-config.js';
114
+ export { serverHealth, needsAttention, describeHealth, estimateToolTokens, estimateServerCost, formatTokens, disabledTools, isToolEnabled, setToolEnabled, filterMcpToolsByEnablement, isSecretRef, secretRefFor, parseSecretRef, looksLikeSecret, resolveValues, describeMissing, planSecretStorage, toStoredValues, secretRefsOf, } from './mcp/index.js';
115
+ export type { MCPServerHealth, MCPServerState, MCPConnectionStatus, HealthInput, MCPToolShape, ServerCost, SecretRef, ResolveResult, } from './mcp/index.js';
114
116
  export { generateProject, isGitConfigured, generateCompilrMd, generateConfigJson, generateReadmeMd, generateCodingStandardsMd, generatePackageJson, generateTsconfig, generateGitignore, generateCompilrMdForImport, detectProjectInfo, detectGitInfo, prettifyName, getLanguageLabel, getFrameworkLabel, validateImportPath, isValidProjectName, projectExists, TECH_STACK_LABELS, CODING_STANDARDS_LABELS, REPO_PATTERN_LABELS, WORKFLOW_VERSION, } from './project-generator/index.js';
115
117
  export type { TechStack, CodingStandards, GeneratorRepoPattern, ProjectConfig, GenerationResult, CompilrConfig, DetectedProject, GitInfo, ImportProjectConfig, } from './project-generator/index.js';
116
118
  export { readFileTool, writeFileTool, createBashTool, bashTool, bashOutputTool, killShellTool, grepTool, globTool, editTool, todoWriteTool, todoReadTool, createTodoTools, getDefaultTodoStore, TodoStore, webFetchTool, suggestTool, } from '@compilr-dev/agents';
package/dist/index.js CHANGED
@@ -232,7 +232,9 @@ export { resolveNext, evaluateBranchCondition, walkPastBranches, lookupLabel, ap
232
232
  // =============================================================================
233
233
  // Shared MCP Configuration
234
234
  // =============================================================================
235
- export { readMCPConfigFile, writeMCPConfigFile, resolveServerEntry, loadMCPServers, saveMCPServerEntry, deleteMCPServerEntry, getServerNames, } from './mcp-config.js';
235
+ export { readMCPConfigFile, writeMCPConfigFile, resolveServerEntry, loadMCPServers, saveMCPServerEntry, deleteMCPServerEntry, getServerNames, readMCPConfig, assertWritable, canConnect, } from './mcp-config.js';
236
+ // MCP — health, per-tool enablement, secrets, cost (mcp-restyling Phase 1)
237
+ export { serverHealth, needsAttention, describeHealth, estimateToolTokens, estimateServerCost, formatTokens, disabledTools, isToolEnabled, setToolEnabled, filterMcpToolsByEnablement, isSecretRef, secretRefFor, parseSecretRef, looksLikeSecret, resolveValues, describeMissing, planSecretStorage, toStoredValues, secretRefsOf, } from './mcp/index.js';
236
238
  // =============================================================================
237
239
  // Project Generator (templates, scaffolding, detection)
238
240
  // =============================================================================
@@ -0,0 +1,50 @@
1
+ /**
2
+ * What a server costs to have configured.
3
+ *
4
+ * ⚠️ THE FIGURE THE WHOLE DESIGN TURNS ON. A server's tool descriptions are sent to the model on
5
+ * every turn whether the tools are called or not — `filesystem` alone is roughly 3.8k tokens of
6
+ * every agent's context, including a duplicate the server itself marks deprecated. Nothing in the
7
+ * app has ever said so.
8
+ *
9
+ * ⚠️ IT IS AN ESTIMATE AND MUST READ AS ONE. We do not run the provider's tokenizer, and the real
10
+ * number differs per model. Callers render `~3.0k`, never `3,024`: a figure with no decimals is a
11
+ * claim about precision we cannot support.
12
+ */
13
+ export interface MCPToolShape {
14
+ name: string;
15
+ description?: string;
16
+ /** JSON Schema for the tool's arguments. Reaches the model too, so it counts. */
17
+ inputSchema?: unknown;
18
+ }
19
+ /**
20
+ * Roughly how many tokens describing this tool costs.
21
+ *
22
+ * ⚠️ THE SCHEMA COUNTS. An early version measured the description alone and under-reported badly:
23
+ * a tool with a one-line summary and fifteen parameters is mostly schema. The name counts too —
24
+ * it is sent prefixed, as `mcp_<server>_<tool>`.
25
+ */
26
+ export declare function estimateToolTokens(tool: MCPToolShape, serverName?: string): number;
27
+ export interface ServerCost {
28
+ /** Tokens for the tools that are actually described to the model. */
29
+ enabledTokens: number;
30
+ /** Tokens if every tool were on — the baseline the saving is measured against. */
31
+ totalTokens: number;
32
+ enabledCount: number;
33
+ totalCount: number;
34
+ }
35
+ /**
36
+ * What this server adds to every agent turn, given which of its tools are on.
37
+ *
38
+ * ⚠️ ONE SOURCE, COMPUTED (D-3). The header stat, the row's TOOLS cell, the panel badge and the
39
+ * document's rail all read this — none of them counts for itself, and no figure is written into
40
+ * copy. Three of the four defects found while designing this were a number stated in one place
41
+ * while the truth lived in another.
42
+ */
43
+ export declare function estimateServerCost(tools: readonly MCPToolShape[], isEnabled: (toolName: string) => boolean, serverName?: string): ServerCost;
44
+ /**
45
+ * `~3.0k`, `~840`, `—`.
46
+ *
47
+ * The dash is for genuinely nothing — a tool that is off, or a server we cannot ask. It is not the
48
+ * same as `~0`, which would claim we measured and found zero.
49
+ */
50
+ export declare function formatTokens(tokens: number | null): string;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * What a server costs to have configured.
3
+ *
4
+ * ⚠️ THE FIGURE THE WHOLE DESIGN TURNS ON. A server's tool descriptions are sent to the model on
5
+ * every turn whether the tools are called or not — `filesystem` alone is roughly 3.8k tokens of
6
+ * every agent's context, including a duplicate the server itself marks deprecated. Nothing in the
7
+ * app has ever said so.
8
+ *
9
+ * ⚠️ IT IS AN ESTIMATE AND MUST READ AS ONE. We do not run the provider's tokenizer, and the real
10
+ * number differs per model. Callers render `~3.0k`, never `3,024`: a figure with no decimals is a
11
+ * claim about precision we cannot support.
12
+ */
13
+ /** Chars per token. Four is the long-standing rule of thumb for English prose and code. */
14
+ const CHARS_PER_TOKEN = 4;
15
+ /**
16
+ * Roughly how many tokens describing this tool costs.
17
+ *
18
+ * ⚠️ THE SCHEMA COUNTS. An early version measured the description alone and under-reported badly:
19
+ * a tool with a one-line summary and fifteen parameters is mostly schema. The name counts too —
20
+ * it is sent prefixed, as `mcp_<server>_<tool>`.
21
+ */
22
+ export function estimateToolTokens(tool, serverName = '') {
23
+ const prefixed = serverName === '' ? tool.name : `mcp_${serverName}_${tool.name}`;
24
+ let chars = prefixed.length + (tool.description ?? '').length;
25
+ if (tool.inputSchema !== undefined) {
26
+ try {
27
+ chars += JSON.stringify(tool.inputSchema).length;
28
+ }
29
+ catch {
30
+ /* circular or unserialisable — the rest of the estimate still stands */
31
+ }
32
+ }
33
+ return Math.ceil(chars / CHARS_PER_TOKEN);
34
+ }
35
+ /**
36
+ * What this server adds to every agent turn, given which of its tools are on.
37
+ *
38
+ * ⚠️ ONE SOURCE, COMPUTED (D-3). The header stat, the row's TOOLS cell, the panel badge and the
39
+ * document's rail all read this — none of them counts for itself, and no figure is written into
40
+ * copy. Three of the four defects found while designing this were a number stated in one place
41
+ * while the truth lived in another.
42
+ */
43
+ export function estimateServerCost(tools, isEnabled, serverName = '') {
44
+ let enabledTokens = 0;
45
+ let totalTokens = 0;
46
+ let enabledCount = 0;
47
+ for (const tool of tools) {
48
+ const cost = estimateToolTokens(tool, serverName);
49
+ totalTokens += cost;
50
+ if (isEnabled(tool.name)) {
51
+ enabledTokens += cost;
52
+ enabledCount++;
53
+ }
54
+ }
55
+ return { enabledTokens, totalTokens, enabledCount, totalCount: tools.length };
56
+ }
57
+ /**
58
+ * `~3.0k`, `~840`, `—`.
59
+ *
60
+ * The dash is for genuinely nothing — a tool that is off, or a server we cannot ask. It is not the
61
+ * same as `~0`, which would claim we measured and found zero.
62
+ */
63
+ export function formatTokens(tokens) {
64
+ if (tokens === null)
65
+ return '—';
66
+ if (tokens < 1000)
67
+ return `~${String(tokens)}`;
68
+ return `~${(tokens / 1000).toFixed(1)}k`;
69
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * What state an MCP server is actually in.
3
+ *
4
+ * ⚠️ THE DEFECT THIS MODULE EXISTS TO END. Desktop rendered a green dot and counted a server as
5
+ * "active" from `entry.disabled` — a CONFIGURATION flag. Whether a server is switched on and
6
+ * whether it answers are different questions, and the app was answering the second with the first.
7
+ * A server whose token had expired looked identical to a working one until an agent called it and
8
+ * the error surfaced mid-conversation.
9
+ *
10
+ * So every state here names what it was derived from, and there is a state for **not knowing**.
11
+ * Guessing green is how we got here (D-1).
12
+ */
13
+ /** What the connection layer reports. Mirrors `MCPClient.status` in @compilr-dev/agents. */
14
+ export type MCPConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
15
+ export type MCPServerState =
16
+ /** The user switched it off. Config, not connection. */
17
+ 'off'
18
+ /** We hold a live client. */
19
+ | 'connected'
20
+ /** We tried and it did not work. Carries a reason. */
21
+ | 'failing'
22
+ /** We have not tried yet — no client exists. NEVER rendered as connected. */
23
+ | 'unknown';
24
+ export interface MCPServerHealth {
25
+ state: MCPServerState;
26
+ /** Why it is failing, in the server's own words where we have them. */
27
+ reason: string | null;
28
+ /**
29
+ * ISO timestamp of the last successful connection, or null if it has never connected.
30
+ *
31
+ * ⚠️ ONE TIMESTAMP, NOT A HISTORY (D-6). The design asked for "every call has failed for 3 days";
32
+ * that is a count nothing records, and writing it anyway would be a claim with no measurement
33
+ * behind it — the same defect as the green dot, in prose.
34
+ */
35
+ lastConnectedAt: string | null;
36
+ }
37
+ export interface HealthInput {
38
+ /** `entry.disabled` from mcp.json. */
39
+ disabled?: boolean;
40
+ /** The live client's status, or null when no client exists for this server. */
41
+ status?: MCPConnectionStatus | null;
42
+ /** The last error we captured for it. */
43
+ error?: string | null;
44
+ lastConnectedAt?: string | null;
45
+ }
46
+ /**
47
+ * Derive a server's health from what we observed.
48
+ *
49
+ * `disabled` wins: a server the user switched off is `off` whatever a stale client says, because
50
+ * that is the state they chose. Everything else follows the connection.
51
+ */
52
+ export declare function serverHealth(input: HealthInput): MCPServerHealth;
53
+ /** Does this state need the user's attention? Drives the amber row and the panel icon. */
54
+ export declare function needsAttention(health: MCPServerHealth): boolean;
55
+ /**
56
+ * One line for the health strip and the list row.
57
+ *
58
+ * ⚠️ SAYS ONLY WHAT WE STORED. No counts, no durations — we keep one timestamp (D-6). Callers
59
+ * render the date; formatting is presentation and differs between a terminal and a window.
60
+ */
61
+ export declare function describeHealth(health: MCPServerHealth): string;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * What state an MCP server is actually in.
3
+ *
4
+ * ⚠️ THE DEFECT THIS MODULE EXISTS TO END. Desktop rendered a green dot and counted a server as
5
+ * "active" from `entry.disabled` — a CONFIGURATION flag. Whether a server is switched on and
6
+ * whether it answers are different questions, and the app was answering the second with the first.
7
+ * A server whose token had expired looked identical to a working one until an agent called it and
8
+ * the error surfaced mid-conversation.
9
+ *
10
+ * So every state here names what it was derived from, and there is a state for **not knowing**.
11
+ * Guessing green is how we got here (D-1).
12
+ */
13
+ /**
14
+ * Derive a server's health from what we observed.
15
+ *
16
+ * `disabled` wins: a server the user switched off is `off` whatever a stale client says, because
17
+ * that is the state they chose. Everything else follows the connection.
18
+ */
19
+ export function serverHealth(input) {
20
+ const lastConnectedAt = input.lastConnectedAt ?? null;
21
+ const reason = input.error ?? null;
22
+ if (input.disabled === true)
23
+ return { state: 'off', reason: null, lastConnectedAt };
24
+ switch (input.status) {
25
+ case 'connected':
26
+ return { state: 'connected', reason: null, lastConnectedAt };
27
+ case 'error':
28
+ return { state: 'failing', reason, lastConnectedAt };
29
+ case 'connecting':
30
+ case 'disconnected':
31
+ case null:
32
+ case undefined:
33
+ /*
34
+ ⚠️ `disconnected` IS NOT `off`. It means we have a client that is not currently connected —
35
+ we do not know whether the server would answer. The user did not ask for this state and
36
+ must not be told the server is fine.
37
+ */
38
+ return { state: 'unknown', reason, lastConnectedAt };
39
+ }
40
+ }
41
+ /** Does this state need the user's attention? Drives the amber row and the panel icon. */
42
+ export function needsAttention(health) {
43
+ return health.state === 'failing';
44
+ }
45
+ /**
46
+ * One line for the health strip and the list row.
47
+ *
48
+ * ⚠️ SAYS ONLY WHAT WE STORED. No counts, no durations — we keep one timestamp (D-6). Callers
49
+ * render the date; formatting is presentation and differs between a terminal and a window.
50
+ */
51
+ export function describeHealth(health) {
52
+ switch (health.state) {
53
+ case 'off':
54
+ return 'Switched off. Its tools are not offered to any agent.';
55
+ case 'connected':
56
+ return 'Connected.';
57
+ case 'failing':
58
+ return health.reason ?? 'The last connection attempt failed.';
59
+ case 'unknown':
60
+ return health.lastConnectedAt === null
61
+ ? 'Not connected yet — it starts when an agent first needs it.'
62
+ : 'Not connected right now.';
63
+ }
64
+ }
@@ -0,0 +1,5 @@
1
+ /** MCP: health, per-tool enablement, secret references and context cost. */
2
+ export { serverHealth, needsAttention, describeHealth, type MCPServerHealth, type MCPServerState, type MCPConnectionStatus, type HealthInput, } from './health.js';
3
+ export { estimateToolTokens, estimateServerCost, formatTokens, type MCPToolShape, type ServerCost, } from './cost.js';
4
+ export { disabledTools, isToolEnabled, setToolEnabled, filterMcpToolsByEnablement, } from './tools.js';
5
+ export { isSecretRef, secretRefFor, parseSecretRef, looksLikeSecret, resolveValues, describeMissing, planSecretStorage, toStoredValues, secretRefsOf, type SecretRef, type ConfigValue, type ResolveResult, } from './secrets.js';
@@ -0,0 +1,5 @@
1
+ /** MCP: health, per-tool enablement, secret references and context cost. */
2
+ export { serverHealth, needsAttention, describeHealth, } from './health.js';
3
+ export { estimateToolTokens, estimateServerCost, formatTokens, } from './cost.js';
4
+ export { disabledTools, isToolEnabled, setToolEnabled, filterMcpToolsByEnablement, } from './tools.js';
5
+ export { isSecretRef, secretRefFor, parseSecretRef, looksLikeSecret, resolveValues, describeMissing, planSecretStorage, toStoredValues, secretRefsOf, } from './secrets.js';
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Keeping credentials out of mcp.json.
3
+ *
4
+ * ⚠️ THE APP CURRENTLY TEACHES THE OPPOSITE. The environment field's own placeholder is
5
+ * `{"GITHUB_TOKEN": "ghp_..."}` — so the UI instructs you to paste a live token into a plain file
6
+ * that is shared with @compilr-dev/cli and sits in a directory people copy between machines and
7
+ * occasionally paste into a gist.
8
+ *
9
+ * A value becomes a REFERENCE in the file; the secret itself goes to the host's encrypted store
10
+ * (Desktop already has one: Electron safeStorage with an AES-256-GCM fallback, shipping for API
11
+ * keys). Non-secret settings stay as plain strings where they can be read and diffed.
12
+ *
13
+ * ⚠️ THE FILE IS SHARED, SO THIS MUST DEGRADE LOUDLY. A host that cannot resolve a reference must
14
+ * say which variable and where it lives — never pass an empty string to the server, which produces
15
+ * a 401 the user cannot explain and we cannot distinguish from a genuinely bad token.
16
+ */
17
+ import type { MCPServerEntry } from '../mcp-config.js';
18
+ /** What sits in mcp.json in place of a secret. */
19
+ export interface SecretRef {
20
+ secret: string;
21
+ }
22
+ export type ConfigValue = string | SecretRef;
23
+ export declare function isSecretRef(value: unknown): value is SecretRef;
24
+ /**
25
+ * The key a secret is stored under: `mcp:<server>:<variable>`.
26
+ *
27
+ * Namespaced by server so two servers may each hold a `GITHUB_TOKEN` without collision — which is
28
+ * the normal case, not an edge one.
29
+ */
30
+ export declare function secretRefFor(server: string, variable: string): SecretRef;
31
+ /** Parse a reference back into its parts, or null if it is not one of ours. */
32
+ export declare function parseSecretRef(ref: string): {
33
+ server: string;
34
+ variable: string;
35
+ } | null;
36
+ /**
37
+ * Does this look like something that should not sit in a plain file?
38
+ *
39
+ * Deliberately conservative and advisory only — it drives an OFFER ("store this in the keychain?"),
40
+ * never an automatic rewrite. Guessing wrong in the cautious direction costs a click; guessing
41
+ * wrong the other way writes a token to disk.
42
+ */
43
+ export declare function looksLikeSecret(variable: string, value: string): boolean;
44
+ export interface ResolveResult {
45
+ /** Ready to hand to the server. Only present when `missing` is empty. */
46
+ values: Record<string, string>;
47
+ /** References we could not resolve, by variable name. */
48
+ missing: {
49
+ variable: string;
50
+ ref: string;
51
+ }[];
52
+ }
53
+ /**
54
+ * Turn a stored map into real values, resolving any references through the host's store.
55
+ *
56
+ * ⚠️ MISSING SECRETS ARE REPORTED, NEVER SUBSTITUTED. Returning `''` for an unresolvable reference
57
+ * is what turns a configuration problem into an authentication error three layers away.
58
+ */
59
+ export declare function resolveValues(stored: Record<string, ConfigValue> | undefined, lookup: (ref: string) => string | null): ResolveResult;
60
+ /** One sentence per unresolvable reference, naming the variable and where the value belongs. */
61
+ export declare function describeMissing(server: string, missing: ResolveResult['missing']): string[];
62
+ /**
63
+ * Split a pasted plain-text map into what should be stored where.
64
+ *
65
+ * Used by the paste-config route to tell the user what will happen BEFORE anything is written.
66
+ */
67
+ export declare function planSecretStorage(server: string, plain: Record<string, string>): {
68
+ variable: string;
69
+ value: string;
70
+ toKeychain: boolean;
71
+ ref: SecretRef | null;
72
+ }[];
73
+ /** The map as it should be WRITTEN to mcp.json: secrets as references, the rest verbatim. */
74
+ export declare function toStoredValues(plan: ReturnType<typeof planSecretStorage>): Record<string, ConfigValue>;
75
+ /** Every secret reference an entry holds, for cleanup when a server is deleted. */
76
+ export declare function secretRefsOf(entry: Pick<MCPServerEntry, 'env' | 'headers'>): string[];
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Keeping credentials out of mcp.json.
3
+ *
4
+ * ⚠️ THE APP CURRENTLY TEACHES THE OPPOSITE. The environment field's own placeholder is
5
+ * `{"GITHUB_TOKEN": "ghp_..."}` — so the UI instructs you to paste a live token into a plain file
6
+ * that is shared with @compilr-dev/cli and sits in a directory people copy between machines and
7
+ * occasionally paste into a gist.
8
+ *
9
+ * A value becomes a REFERENCE in the file; the secret itself goes to the host's encrypted store
10
+ * (Desktop already has one: Electron safeStorage with an AES-256-GCM fallback, shipping for API
11
+ * keys). Non-secret settings stay as plain strings where they can be read and diffed.
12
+ *
13
+ * ⚠️ THE FILE IS SHARED, SO THIS MUST DEGRADE LOUDLY. A host that cannot resolve a reference must
14
+ * say which variable and where it lives — never pass an empty string to the server, which produces
15
+ * a 401 the user cannot explain and we cannot distinguish from a genuinely bad token.
16
+ */
17
+ export function isSecretRef(value) {
18
+ return (typeof value === 'object' &&
19
+ value !== null &&
20
+ 'secret' in value &&
21
+ typeof value.secret === 'string');
22
+ }
23
+ /**
24
+ * The key a secret is stored under: `mcp:<server>:<variable>`.
25
+ *
26
+ * Namespaced by server so two servers may each hold a `GITHUB_TOKEN` without collision — which is
27
+ * the normal case, not an edge one.
28
+ */
29
+ export function secretRefFor(server, variable) {
30
+ return { secret: `mcp:${server}:${variable}` };
31
+ }
32
+ /** Parse a reference back into its parts, or null if it is not one of ours. */
33
+ export function parseSecretRef(ref) {
34
+ const parts = ref.split(':');
35
+ if (parts.length !== 3 || parts[0] !== 'mcp')
36
+ return null;
37
+ if (parts[1] === '' || parts[2] === '')
38
+ return null;
39
+ return { server: parts[1], variable: parts[2] };
40
+ }
41
+ /**
42
+ * Does this look like something that should not sit in a plain file?
43
+ *
44
+ * Deliberately conservative and advisory only — it drives an OFFER ("store this in the keychain?"),
45
+ * never an automatic rewrite. Guessing wrong in the cautious direction costs a click; guessing
46
+ * wrong the other way writes a token to disk.
47
+ */
48
+ export function looksLikeSecret(variable, value) {
49
+ if (/^(ghp|gho|ghs|ghu|github_pat|sk-|xoxb-|xoxp-|AKIA|glpat-|pat_)/i.test(value))
50
+ return true;
51
+ if (/^Bearer\s+\S+/i.test(value))
52
+ return true;
53
+ if (/(token|secret|password|passwd|api[-_]?key|credential|auth)/i.test(variable)) {
54
+ // A name that sounds secret plus a value long enough to be one. `TOKEN_PATH=/tmp/t` is not.
55
+ return value.length >= 16 && !value.startsWith('/') && !value.startsWith('~');
56
+ }
57
+ // A long opaque run of base64/hex with no spaces or path separators.
58
+ return value.length >= 32 && /^[A-Za-z0-9_\-.+/=]+$/.test(value) && !value.includes('/');
59
+ }
60
+ /**
61
+ * Turn a stored map into real values, resolving any references through the host's store.
62
+ *
63
+ * ⚠️ MISSING SECRETS ARE REPORTED, NEVER SUBSTITUTED. Returning `''` for an unresolvable reference
64
+ * is what turns a configuration problem into an authentication error three layers away.
65
+ */
66
+ export function resolveValues(stored, lookup) {
67
+ const values = {};
68
+ const missing = [];
69
+ for (const [variable, value] of Object.entries(stored ?? {})) {
70
+ if (typeof value === 'string') {
71
+ values[variable] = value;
72
+ continue;
73
+ }
74
+ const found = lookup(value.secret);
75
+ if (found === null)
76
+ missing.push({ variable, ref: value.secret });
77
+ else
78
+ values[variable] = found;
79
+ }
80
+ return { values, missing };
81
+ }
82
+ /** One sentence per unresolvable reference, naming the variable and where the value belongs. */
83
+ export function describeMissing(server, missing) {
84
+ return missing.map((m) => `${m.variable} for ${server} is stored as a reference (${m.ref}) but no value was found. Open the server and set it again — it is kept in this machine's keychain, not in mcp.json, so it does not travel with the file.`);
85
+ }
86
+ /**
87
+ * Split a pasted plain-text map into what should be stored where.
88
+ *
89
+ * Used by the paste-config route to tell the user what will happen BEFORE anything is written.
90
+ */
91
+ export function planSecretStorage(server, plain) {
92
+ return Object.entries(plain).map(([variable, value]) => {
93
+ const toKeychain = looksLikeSecret(variable, value);
94
+ return {
95
+ variable,
96
+ value,
97
+ toKeychain,
98
+ ref: toKeychain ? secretRefFor(server, variable) : null,
99
+ };
100
+ });
101
+ }
102
+ /** The map as it should be WRITTEN to mcp.json: secrets as references, the rest verbatim. */
103
+ export function toStoredValues(plan) {
104
+ const out = {};
105
+ for (const item of plan)
106
+ out[item.variable] = item.ref ?? item.value;
107
+ return out;
108
+ }
109
+ /** Every secret reference an entry holds, for cleanup when a server is deleted. */
110
+ export function secretRefsOf(entry) {
111
+ const refs = [];
112
+ for (const map of [entry.env, entry.headers]) {
113
+ for (const value of Object.values(map ?? {})) {
114
+ if (isSecretRef(value))
115
+ refs.push(value.secret);
116
+ }
117
+ }
118
+ return refs;
119
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Which of a server's tools are described to the model.
3
+ *
4
+ * ⚠️ OFF MEANS NOT DESCRIBED — NEVER REMOVED (D-7). The tool stays on the server and stays callable
5
+ * by anything else pointed at it. We are choosing what goes into the model's context, and the copy
6
+ * must not imply we uninstalled anything.
7
+ *
8
+ * Stored as an OPT-OUT list, deliberately: a server that gains a tool in a later release has it
9
+ * enabled by default, which is what someone who never touched this screen expects. An opt-in list
10
+ * would silently hide every new tool and look like the server had stopped offering them.
11
+ */
12
+ import type { MCPServerEntry } from '../mcp-config.js';
13
+ /** Tools explicitly switched off for this server. Absent means "all of them are on". */
14
+ export declare function disabledTools(entry: Pick<MCPServerEntry, 'disabledTools'>): readonly string[];
15
+ export declare function isToolEnabled(entry: Pick<MCPServerEntry, 'disabledTools'>, toolName: string): boolean;
16
+ /**
17
+ * The entry with one tool switched on or off.
18
+ *
19
+ * Returns a NEW entry; the caller writes it. Switching the last one back on removes the key
20
+ * entirely rather than leaving `"disabledTools": []` behind, so a file nobody has customised stays
21
+ * clean and diffs stay small.
22
+ */
23
+ export declare function setToolEnabled(entry: MCPServerEntry, toolName: string, enabled: boolean): MCPServerEntry;
24
+ /**
25
+ * Drop the tools this server has switched off.
26
+ *
27
+ * ⚠️ MUST BE APPLIED ON EVERY PATH THAT REGISTERS MCP TOOLS. There are two in Desktop — the agent
28
+ * factory, and the post-connect catch-up for agents built before MCP finished connecting. The same
29
+ * split already bit MCP grants: filtering one path leaves the feature working except during
30
+ * startup, which is the same as not working.
31
+ *
32
+ * Shaped like `filterMcpToolsByGrant` so the two compose over the same array.
33
+ */
34
+ export declare function filterMcpToolsByEnablement<T extends {
35
+ definition: {
36
+ name: string;
37
+ };
38
+ }>(tools: readonly T[], serverOf: (toolName: string) => string | null, entryOf: (server: string) => Pick<MCPServerEntry, 'disabledTools'> | undefined,
39
+ /** Strip the `mcp_<server>_` prefix to get the name the server itself uses. */
40
+ bareName: (toolName: string, server: string) => string): T[];
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Which of a server's tools are described to the model.
3
+ *
4
+ * ⚠️ OFF MEANS NOT DESCRIBED — NEVER REMOVED (D-7). The tool stays on the server and stays callable
5
+ * by anything else pointed at it. We are choosing what goes into the model's context, and the copy
6
+ * must not imply we uninstalled anything.
7
+ *
8
+ * Stored as an OPT-OUT list, deliberately: a server that gains a tool in a later release has it
9
+ * enabled by default, which is what someone who never touched this screen expects. An opt-in list
10
+ * would silently hide every new tool and look like the server had stopped offering them.
11
+ */
12
+ /** Tools explicitly switched off for this server. Absent means "all of them are on". */
13
+ export function disabledTools(entry) {
14
+ return entry.disabledTools ?? [];
15
+ }
16
+ export function isToolEnabled(entry, toolName) {
17
+ return !disabledTools(entry).includes(toolName);
18
+ }
19
+ /**
20
+ * The entry with one tool switched on or off.
21
+ *
22
+ * Returns a NEW entry; the caller writes it. Switching the last one back on removes the key
23
+ * entirely rather than leaving `"disabledTools": []` behind, so a file nobody has customised stays
24
+ * clean and diffs stay small.
25
+ */
26
+ export function setToolEnabled(entry, toolName, enabled) {
27
+ const current = new Set(disabledTools(entry));
28
+ if (enabled)
29
+ current.delete(toolName);
30
+ else
31
+ current.add(toolName);
32
+ const next = { ...entry };
33
+ if (current.size === 0)
34
+ delete next.disabledTools;
35
+ else
36
+ next.disabledTools = [...current].sort();
37
+ return next;
38
+ }
39
+ /**
40
+ * Drop the tools this server has switched off.
41
+ *
42
+ * ⚠️ MUST BE APPLIED ON EVERY PATH THAT REGISTERS MCP TOOLS. There are two in Desktop — the agent
43
+ * factory, and the post-connect catch-up for agents built before MCP finished connecting. The same
44
+ * split already bit MCP grants: filtering one path leaves the feature working except during
45
+ * startup, which is the same as not working.
46
+ *
47
+ * Shaped like `filterMcpToolsByGrant` so the two compose over the same array.
48
+ */
49
+ export function filterMcpToolsByEnablement(tools, serverOf, entryOf,
50
+ /** Strip the `mcp_<server>_` prefix to get the name the server itself uses. */
51
+ bareName) {
52
+ return tools.filter((tool) => {
53
+ const server = serverOf(tool.definition.name);
54
+ if (server === null)
55
+ return true;
56
+ const entry = entryOf(server);
57
+ if (!entry)
58
+ return true;
59
+ return isToolEnabled(entry, bareName(tool.definition.name, server));
60
+ });
61
+ }
@@ -7,6 +7,8 @@
7
7
  * File structure:
8
8
  * { "mcpServers": { "name": { command?, url?, ... } } }
9
9
  */
10
+ import { type ConfigValue } from './mcp/secrets.js';
11
+ export type { ConfigValue };
10
12
  /**
11
13
  * Single MCP server entry in the config file.
12
14
  * If `command` is present → stdio transport.
@@ -15,12 +17,28 @@
15
17
  export interface MCPServerEntry {
16
18
  command?: string;
17
19
  args?: string[];
18
- env?: Record<string, string>;
20
+ /**
21
+ * Passed to the process on start.
22
+ *
23
+ * ⚠️ A VALUE MAY BE A REFERENCE, NOT A STRING. `{ secret: 'mcp:github:GITHUB_TOKEN' }` means the
24
+ * real value lives in the host's encrypted store and never in this file — see `src/mcp/secrets`.
25
+ * Resolve with {@link resolveServerEntry} before handing anything to a server.
26
+ */
27
+ env?: Record<string, ConfigValue>;
19
28
  cwd?: string;
20
29
  url?: string;
21
- headers?: Record<string, string>;
30
+ /** Same reference rule as {@link MCPServerEntry.env}. */
31
+ headers?: Record<string, ConfigValue>;
22
32
  disabled?: boolean;
23
33
  timeout?: number;
34
+ /**
35
+ * Tools NOT described to the model, by the server's own name for them.
36
+ *
37
+ * ⚠️ AN OPT-OUT LIST, SO A SERVER THAT GAINS A TOOL HAS IT ON. An opt-in list would silently hide
38
+ * every tool added by a later release. Absent means all tools are on. The tools stay on the
39
+ * server either way — this only decides what reaches the model's context.
40
+ */
41
+ disabledTools?: string[];
24
42
  }
25
43
  /**
26
44
  * Shape of the mcp.json config file.
@@ -41,11 +59,52 @@ export interface ResolvedMCPServer {
41
59
  url?: string;
42
60
  headers?: Record<string, string>;
43
61
  timeout?: number;
62
+ /**
63
+ * Secret references that could not be resolved. Non-empty means DO NOT CONNECT — see
64
+ * {@link canConnect}. The variables are named so a host can say which one and where it lives.
65
+ */
66
+ missingSecrets?: {
67
+ variable: string;
68
+ ref: string;
69
+ }[];
44
70
  }
71
+ /**
72
+ * The outcome of reading an mcp.json, with "absent" kept distinct from "unreadable".
73
+ *
74
+ * ⚠️ THOSE TWO CANNOT SHARE AN ANSWER. Collapsing them to `{}` is what made adding a server to a
75
+ * hand-edited file DELETE the servers already in it — see {@link assertWritable}.
76
+ */
77
+ export interface MCPConfigRead {
78
+ servers: Record<string, MCPServerEntry>;
79
+ /** Present when the file exists but could not be parsed. The servers are then unknown, not none. */
80
+ error: string | null;
81
+ exists: boolean;
82
+ }
83
+ /**
84
+ * Read an mcp.json, saying which of the three things happened: absent, readable, or broken.
85
+ *
86
+ * Prefer this over {@link readMCPConfigFile} anywhere the result could lead to a WRITE.
87
+ */
88
+ export declare function readMCPConfig(filePath: string): MCPConfigRead;
45
89
  /**
46
90
  * Read and parse an mcp.json file. Returns empty record on any error.
91
+ *
92
+ * ⚠️ LENIENT, AND ONLY SAFE FOR READING. It cannot tell an absent file from a broken one. Use
93
+ * {@link readMCPConfig} when the answer might be written back.
47
94
  */
48
95
  export declare function readMCPConfigFile(filePath: string): Record<string, MCPServerEntry>;
96
+ /**
97
+ * Throw unless this file can be safely rewritten.
98
+ *
99
+ * ⚠️ THE DATA-LOSS BUG THIS EXISTS FOR, MEASURED. `readMCPConfigFile` returns `{}` for a file it
100
+ * cannot parse. `saveMCPServerEntry` then wrote `{}` plus the one new server — so a user whose
101
+ * mcp.json had a comment in it saw no servers in the app, added one, and **lost the other two**.
102
+ * Silence, then deletion.
103
+ *
104
+ * A file we cannot read is a file we must not overwrite. The host shows the reason and the user
105
+ * fixes their file; we do not guess at its contents.
106
+ */
107
+ export declare function assertWritable(filePath: string): Record<string, MCPServerEntry>;
49
108
  /**
50
109
  * Write servers to an mcp.json file (creates directory if needed).
51
110
  */
@@ -53,8 +112,19 @@ export declare function writeMCPConfigFile(filePath: string, servers: Record<str
53
112
  /**
54
113
  * Convert an MCPServerEntry to a ResolvedMCPServer.
55
114
  * Returns null if the entry is invalid (neither command nor url) or disabled.
115
+ *
116
+ * ⚠️ SECRET REFERENCES NEED `lookup`. Without one, a referenced value cannot be resolved and is
117
+ * reported in `missingSecrets` — it is never substituted with an empty string, because that turns
118
+ * a configuration problem into a 401 three layers away that nobody can trace back.
119
+ */
120
+ export declare function resolveServerEntry(name: string, entry: MCPServerEntry, lookup?: (ref: string) => string | null): ResolvedMCPServer | null;
121
+ /**
122
+ * Can this server be started without handing it a credential we could not find?
123
+ *
124
+ * Hosts check this before connecting. Connecting anyway produces an authentication failure that
125
+ * looks like a bad token rather than a missing one.
56
126
  */
57
- export declare function resolveServerEntry(name: string, entry: MCPServerEntry): ResolvedMCPServer | null;
127
+ export declare function canConnect(server: ResolvedMCPServer): boolean;
58
128
  /**
59
129
  * Load and resolve MCP servers from one or more config file paths.
60
130
  * Later paths override earlier ones (by server name).
@@ -9,26 +9,76 @@
9
9
  */
10
10
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
11
11
  import { dirname } from 'path';
12
- // =============================================================================
13
- // File I/O
14
- // =============================================================================
12
+ import { resolveValues } from './mcp/secrets.js';
15
13
  /**
16
- * Read and parse an mcp.json file. Returns empty record on any error.
14
+ * Read an mcp.json, saying which of the three things happened: absent, readable, or broken.
15
+ *
16
+ * Prefer this over {@link readMCPConfigFile} anywhere the result could lead to a WRITE.
17
17
  */
18
- export function readMCPConfigFile(filePath) {
18
+ export function readMCPConfig(filePath) {
19
+ if (!existsSync(filePath))
20
+ return { servers: {}, error: null, exists: false };
21
+ let data;
19
22
  try {
20
- if (!existsSync(filePath))
21
- return {};
22
- const data = readFileSync(filePath, 'utf-8');
23
- const parsed = JSON.parse(data);
24
- if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {
25
- return parsed.mcpServers;
26
- }
27
- return {};
23
+ data = readFileSync(filePath, 'utf-8');
24
+ }
25
+ catch (err) {
26
+ return { servers: {}, exists: true, error: `could not be read: ${String(err)}` };
28
27
  }
29
- catch {
30
- return {};
28
+ /*
29
+ An empty or whitespace-only file is a normal state — an editor that saved nothing, or a file
30
+ someone cleared — and treating it as corrupt would block every future write.
31
+ */
32
+ if (data.trim() === '')
33
+ return { servers: {}, error: null, exists: true };
34
+ let parsed;
35
+ try {
36
+ parsed = JSON.parse(data);
37
+ }
38
+ catch (err) {
39
+ const detail = err instanceof Error ? err.message : String(err);
40
+ /*
41
+ Comments are the common cause and worth naming: mcp.json looks like a config file people may
42
+ annotate, and every editor that speaks JSONC encourages it. Strict JSON is what the format is,
43
+ so we refuse clearly instead of stripping them — stripping would drop them on the next write.
44
+ */
45
+ const hint = /^\s*\/\/|\n\s*\/\/|\/\*/.test(data)
46
+ ? ' It looks like it contains comments; mcp.json must be strict JSON.'
47
+ : '';
48
+ return { servers: {}, exists: true, error: `is not valid JSON (${detail}).${hint}` };
31
49
  }
50
+ if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object') {
51
+ // A valid JSON document with no `mcpServers` key is empty, not broken.
52
+ return { servers: {}, error: null, exists: true };
53
+ }
54
+ return { servers: parsed.mcpServers, error: null, exists: true };
55
+ }
56
+ /**
57
+ * Read and parse an mcp.json file. Returns empty record on any error.
58
+ *
59
+ * ⚠️ LENIENT, AND ONLY SAFE FOR READING. It cannot tell an absent file from a broken one. Use
60
+ * {@link readMCPConfig} when the answer might be written back.
61
+ */
62
+ export function readMCPConfigFile(filePath) {
63
+ return readMCPConfig(filePath).servers;
64
+ }
65
+ /**
66
+ * Throw unless this file can be safely rewritten.
67
+ *
68
+ * ⚠️ THE DATA-LOSS BUG THIS EXISTS FOR, MEASURED. `readMCPConfigFile` returns `{}` for a file it
69
+ * cannot parse. `saveMCPServerEntry` then wrote `{}` plus the one new server — so a user whose
70
+ * mcp.json had a comment in it saw no servers in the app, added one, and **lost the other two**.
71
+ * Silence, then deletion.
72
+ *
73
+ * A file we cannot read is a file we must not overwrite. The host shows the reason and the user
74
+ * fixes their file; we do not guess at its contents.
75
+ */
76
+ export function assertWritable(filePath) {
77
+ const read = readMCPConfig(filePath);
78
+ if (read.error !== null) {
79
+ throw new Error(`${filePath} ${read.error} Refusing to write, because saving would replace servers that are still in the file. Fix the file and try again.`);
80
+ }
81
+ return read.servers;
32
82
  }
33
83
  /**
34
84
  * Write servers to an mcp.json file (creates directory if needed).
@@ -47,32 +97,49 @@ export function writeMCPConfigFile(filePath, servers) {
47
97
  /**
48
98
  * Convert an MCPServerEntry to a ResolvedMCPServer.
49
99
  * Returns null if the entry is invalid (neither command nor url) or disabled.
100
+ *
101
+ * ⚠️ SECRET REFERENCES NEED `lookup`. Without one, a referenced value cannot be resolved and is
102
+ * reported in `missingSecrets` — it is never substituted with an empty string, because that turns
103
+ * a configuration problem into a 401 three layers away that nobody can trace back.
50
104
  */
51
- export function resolveServerEntry(name, entry) {
105
+ export function resolveServerEntry(name, entry, lookup = () => null) {
52
106
  if (entry.disabled)
53
107
  return null;
54
108
  if (entry.command) {
109
+ const env = resolveValues(entry.env, lookup);
55
110
  return {
56
111
  name,
57
112
  transport: 'stdio',
58
113
  command: entry.command,
59
114
  args: entry.args,
60
- env: entry.env,
115
+ env: Object.keys(env.values).length > 0 ? env.values : undefined,
61
116
  cwd: entry.cwd,
62
117
  timeout: entry.timeout,
118
+ missingSecrets: env.missing,
63
119
  };
64
120
  }
65
121
  if (entry.url) {
122
+ const headers = resolveValues(entry.headers, lookup);
66
123
  return {
67
124
  name,
68
125
  transport: 'http',
69
126
  url: entry.url,
70
- headers: entry.headers,
127
+ headers: Object.keys(headers.values).length > 0 ? headers.values : undefined,
71
128
  timeout: entry.timeout,
129
+ missingSecrets: headers.missing,
72
130
  };
73
131
  }
74
132
  return null;
75
133
  }
134
+ /**
135
+ * Can this server be started without handing it a credential we could not find?
136
+ *
137
+ * Hosts check this before connecting. Connecting anyway produces an authentication failure that
138
+ * looks like a bad token rather than a missing one.
139
+ */
140
+ export function canConnect(server) {
141
+ return (server.missingSecrets ?? []).length === 0;
142
+ }
76
143
  // =============================================================================
77
144
  // High-level helpers
78
145
  // =============================================================================
@@ -98,7 +165,7 @@ export function loadMCPServers(...configPaths) {
98
165
  * Save a single MCP server entry to a config file (add or overwrite by name).
99
166
  */
100
167
  export function saveMCPServerEntry(filePath, name, entry) {
101
- const servers = readMCPConfigFile(filePath);
168
+ const servers = assertWritable(filePath);
102
169
  servers[name] = entry;
103
170
  writeMCPConfigFile(filePath, servers);
104
171
  }
@@ -106,7 +173,7 @@ export function saveMCPServerEntry(filePath, name, entry) {
106
173
  * Delete a single MCP server entry from a config file.
107
174
  */
108
175
  export function deleteMCPServerEntry(filePath, name) {
109
- const servers = readMCPConfigFile(filePath);
176
+ const servers = assertWritable(filePath);
110
177
  const { [name]: _, ...rest } = servers;
111
178
  writeMCPConfigFile(filePath, rest);
112
179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compilr-dev/sdk",
3
- "version": "0.29.2",
3
+ "version": "0.29.3",
4
4
  "description": "Universal agent runtime for building AI-powered applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",