@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 +4 -2
- package/dist/index.js +3 -1
- package/dist/mcp/cost.d.ts +50 -0
- package/dist/mcp/cost.js +69 -0
- package/dist/mcp/health.d.ts +61 -0
- package/dist/mcp/health.js +64 -0
- package/dist/mcp/index.d.ts +5 -0
- package/dist/mcp/index.js +5 -0
- package/dist/mcp/secrets.d.ts +76 -0
- package/dist/mcp/secrets.js +119 -0
- package/dist/mcp/tools.d.ts +40 -0
- package/dist/mcp/tools.js +61 -0
- package/dist/mcp-config.d.ts +73 -3
- package/dist/mcp-config.js +87 -20
- package/package.json +1 -1
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;
|
package/dist/mcp/cost.js
ADDED
|
@@ -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
|
+
}
|
package/dist/mcp-config.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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).
|
package/dist/mcp-config.js
CHANGED
|
@@ -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
|
|
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
|
|
18
|
+
export function readMCPConfig(filePath) {
|
|
19
|
+
if (!existsSync(filePath))
|
|
20
|
+
return { servers: {}, error: null, exists: false };
|
|
21
|
+
let data;
|
|
19
22
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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 =
|
|
176
|
+
const servers = assertWritable(filePath);
|
|
110
177
|
const { [name]: _, ...rest } = servers;
|
|
111
178
|
writeMCPConfigFile(filePath, rest);
|
|
112
179
|
}
|