@miphamai/cli 0.85.4 → 0.85.6
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/bin/mipham.ts +48 -7
- package/package.json +2 -2
- package/src/agent/agent-context.ts +8 -1
- package/src/agent/effectiveness-tracker.ts +16 -2
- package/src/agent/sub-agent.ts +25 -17
- package/src/agent-view/agents-standalone.tsx +42 -0
- package/src/core/autocomplete.ts +30 -2
- package/src/core/context.ts +61 -6
- package/src/core/dream-engine.ts +17 -2
- package/src/core/engine.ts +95 -34
- package/src/core/error-signature-db.ts +7 -2
- package/src/core/hooks-executor.ts +88 -11
- package/src/core/hooks.ts +26 -2
- package/src/core/instructions.ts +105 -17
- package/src/core/memory/memory-manager.ts +14 -6
- package/src/core/permission-classifier.ts +17 -5
- package/src/core/permission-rules.ts +1 -1
- package/src/core/permission.ts +50 -1
- package/src/core/rule-engine.ts +27 -2
- package/src/core/self-critique.ts +15 -3
- package/src/core/session-log.ts +60 -0
- package/src/daemon/index.ts +2 -5
- package/src/daemon/launch.ts +69 -2
- package/src/daemon/server.ts +19 -14
- package/src/i18n-core/locales/en-US.json +86 -143
- package/src/i18n-core/locales/zh-CN.json +86 -143
- package/src/index.tsx +17 -7
- package/src/mcp/client.ts +61 -0
- package/src/mcp/instructions.ts +49 -0
- package/src/mcp/types.ts +7 -0
- package/src/plugin/claude-plugin.ts +12 -2
- package/src/plugin/plugin-loader.ts +32 -14
- package/src/plugin/plugin-manager.ts +16 -2
- package/src/plugin/plugin-validator.ts +183 -1
- package/src/providers/anthropic.ts +159 -124
- package/src/providers/fetch-utils.ts +65 -34
- package/src/providers/openai-compat.ts +121 -106
- package/src/security/dangerous-rm.ts +192 -0
- package/src/shared/arg-validation.ts +11 -1
- package/src/shared/constants.ts +18 -0
- package/src/shared/deleted-cwd.ts +46 -1
- package/src/shared/package-info.ts +36 -1
- package/src/shared/types.ts +8 -0
- package/src/shared/update.ts +290 -146
- package/src/ui/command-picker.tsx +18 -10
- package/src/ui/commands.ts +20 -6
- package/src/ui/config-wizard.tsx +22 -19
- package/src/ui/graft-status.tsx +35 -6
- package/src/ui/input.tsx +7 -2
- package/src/ui/picker.tsx +38 -27
- package/src/ui/use-key-state.ts +55 -0
- package/src/daemon/message-bus.ts +0 -84
package/src/mcp/client.ts
CHANGED
|
@@ -26,6 +26,11 @@ const t = createT(bundles['en-US'] || (enUS as TranslationMap), enUS as Translat
|
|
|
26
26
|
// than block on the per-request 60s timeout. Overridable via env for tests.
|
|
27
27
|
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
|
|
28
28
|
|
|
29
|
+
// How often a caller waiting on a handshake re-checks it. A handshake settles in
|
|
30
|
+
// tens of milliseconds when it settles at all; this only bounds how long the wait
|
|
31
|
+
// can overshoot the moment it actually did.
|
|
32
|
+
const CONNECT_POLL_MS = 50
|
|
33
|
+
|
|
29
34
|
function connectTimeoutMs(): number {
|
|
30
35
|
const env = Number(process.env.MIPHAM_MCP_CONNECT_TIMEOUT_MS)
|
|
31
36
|
return Number.isFinite(env) && env > 0 ? env : DEFAULT_CONNECT_TIMEOUT_MS
|
|
@@ -48,6 +53,8 @@ interface ActiveConnection {
|
|
|
48
53
|
status: ConnectionStatus
|
|
49
54
|
tools: ToolDefinition[]
|
|
50
55
|
serverInfo?: { name: string; version: string }
|
|
56
|
+
/** initialize 自带的 server 使用说明;见 `mcp/instructions.ts`。 */
|
|
57
|
+
instructions?: string
|
|
51
58
|
error?: string
|
|
52
59
|
/** Coalescing timer for `tools/list_changed` (see scheduleToolsRefresh). */
|
|
53
60
|
toolsRefreshTimer?: ReturnType<typeof setTimeout>
|
|
@@ -72,6 +79,17 @@ interface ActiveConnection {
|
|
|
72
79
|
export class McpClient {
|
|
73
80
|
private static instance: McpClient | null = null
|
|
74
81
|
private connections = new Map<string, ActiveConnection>()
|
|
82
|
+
/**
|
|
83
|
+
* 同名连接**正在握手中**的那一次。
|
|
84
|
+
*
|
|
85
|
+
* 两条来源在启动时是并发的:配置里的 server 在 `index.tsx` 一次 `Promise.allSettled`
|
|
86
|
+
* 里连(同一个同步块里那句没被 await),插件声明的 server 紧接着在 `loadPlugins` 里各
|
|
87
|
+
* 连一次(`.mcp.json` 走 `plugin-loader`、manifest 内联的 `mcpServers` 走 `claude-plugin`,
|
|
88
|
+
* 两处也都不 await)。撞上同一个名字时,第二条看到的是 `connecting`,于是**另起一条传输**
|
|
89
|
+
* 并把它塞回 map —— 而 `closeAll` 只遍历 `connections`,被换掉的那条没人关:stdio 的子
|
|
90
|
+
* 进程就此不被回收。名字就是身份(URL 怎么拼都不参与判重),所以同名就该是同一个连接。
|
|
91
|
+
*/
|
|
92
|
+
private connecting = new Map<string, Promise<void>>()
|
|
75
93
|
private _tokenStore: TokenStore | null = null
|
|
76
94
|
private _oauthClient: OAuthClient | null = null
|
|
77
95
|
private eventHandlers = new Map<string, Array<(...args: any[]) => void>>()
|
|
@@ -262,6 +280,21 @@ export class McpClient {
|
|
|
262
280
|
const existing = this.connections.get(config.name)
|
|
263
281
|
if (existing?.status === 'connected') return
|
|
264
282
|
|
|
283
|
+
// 名字正在握手中:合并到那一次,别另起一条传输。
|
|
284
|
+
const inflight = this.connecting.get(config.name)
|
|
285
|
+
if (inflight) return inflight
|
|
286
|
+
|
|
287
|
+
const attempt = this.connectOnce(config)
|
|
288
|
+
this.connecting.set(config.name, attempt)
|
|
289
|
+
try {
|
|
290
|
+
await attempt
|
|
291
|
+
} finally {
|
|
292
|
+
// 无论成败都要放开这个名字:失败后用户重连,得能真的重连。
|
|
293
|
+
this.connecting.delete(config.name)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private async connectOnce(config: McpServerConfig): Promise<void> {
|
|
265
298
|
const transport: StdioTransport | HttpTransport = config.url
|
|
266
299
|
? new HttpTransport(undefined, config.request_timeout_ms)
|
|
267
300
|
: new StdioTransport(config.request_timeout_ms)
|
|
@@ -289,6 +322,7 @@ export class McpClient {
|
|
|
289
322
|
|
|
290
323
|
connection.status = 'connected'
|
|
291
324
|
connection.serverInfo = initResult.serverInfo
|
|
325
|
+
connection.instructions = initResult.instructions
|
|
292
326
|
|
|
293
327
|
// Wire tools-changed notification (coalesced — see scheduleToolsRefresh)
|
|
294
328
|
protocol.on('tools-changed', () => {
|
|
@@ -335,6 +369,31 @@ export class McpClient {
|
|
|
335
369
|
}
|
|
336
370
|
}
|
|
337
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Wait for a server that is still mid-handshake to settle.
|
|
374
|
+
*
|
|
375
|
+
* Startup connects servers without blocking, so a caller that fires alongside
|
|
376
|
+
* it — a hook, SessionStart work — can arrive while a server is *becoming*
|
|
377
|
+
* connected. Asked for a tool then, `callTool` answers "not connected", which
|
|
378
|
+
* is a statement about this moment rather than about the server.
|
|
379
|
+
*
|
|
380
|
+
* Bounded by the same timeout the handshake itself is, so a server that never
|
|
381
|
+
* settles fails the waiting caller instead of hanging it.
|
|
382
|
+
*
|
|
383
|
+
* @returns `false` only when the deadline passed with the server still
|
|
384
|
+
* connecting. A server this client knows nothing about is `true`: there is
|
|
385
|
+
* nothing to wait for, and `callTool` will say so.
|
|
386
|
+
*/
|
|
387
|
+
async waitUntilReady(name: string, timeoutMs: number = connectTimeoutMs()): Promise<boolean> {
|
|
388
|
+
const deadline = Date.now() + timeoutMs
|
|
389
|
+
for (;;) {
|
|
390
|
+
const conn = this.connections.get(name)
|
|
391
|
+
if (!conn || conn.status !== 'connecting') return true
|
|
392
|
+
if (Date.now() >= deadline) return false
|
|
393
|
+
await new Promise((resolve) => setTimeout(resolve, CONNECT_POLL_MS))
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
338
397
|
/**
|
|
339
398
|
* Disconnect an MCP server and return the names of its registered tools
|
|
340
399
|
* so the caller can unregister them from the central tool registry.
|
|
@@ -386,6 +445,7 @@ export class McpClient {
|
|
|
386
445
|
tools: conn.tools,
|
|
387
446
|
error: conn.error,
|
|
388
447
|
serverInfo: conn.serverInfo,
|
|
448
|
+
instructions: conn.instructions,
|
|
389
449
|
}
|
|
390
450
|
}
|
|
391
451
|
|
|
@@ -401,6 +461,7 @@ export class McpClient {
|
|
|
401
461
|
tools: conn.tools,
|
|
402
462
|
error: conn.error,
|
|
403
463
|
serverInfo: conn.serverInfo,
|
|
464
|
+
instructions: conn.instructions,
|
|
404
465
|
}))
|
|
405
466
|
}
|
|
406
467
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ConnectionInfo } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 单个 server 的 instructions 上限。
|
|
5
|
+
*
|
|
6
|
+
* 这是**不受我们控制**的第三方文本,且**每次请求都要重新付一遍前缀** ——
|
|
7
|
+
* 一个啰嗦的 server 能靠一段自带说明把别人的预算吃光。截断而非拒绝:
|
|
8
|
+
* 前半段通常正是「这个 server 该怎么用」那部分。
|
|
9
|
+
*/
|
|
10
|
+
export const MCP_INSTRUCTIONS_PER_SERVER_CAP = 2000
|
|
11
|
+
|
|
12
|
+
const TRUNCATION_MARKER = '… [truncated]'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 把已连接 MCP server 自带的 `instructions` 拼成一段**系统提示用**的文本。
|
|
16
|
+
*
|
|
17
|
+
* 依据(MCP 规范):`initialize` 的返回值里 `instructions` 是 server 运维方写的
|
|
18
|
+
* 「我该怎么被使用」—— 它**不是**工具描述,没地方能寄生,不收就等于丢掉。
|
|
19
|
+
*
|
|
20
|
+
* 三条刻意的约束:
|
|
21
|
+
* - 没写 instructions 的 server **整段不出现**(空标题会让模型去猜一个不存在的 server);
|
|
22
|
+
* - 按 server 名**排序**,与连接完成顺序无关 —— 同一组 server 两次拼出的必须是同一段字节,
|
|
23
|
+
* 否则每次请求前缀都变,提供方的 prefix cache 全部落空;
|
|
24
|
+
* - 无话可说时返回**空串**,由调用方据此整段不注入(而不是注入一个空壳标题)。
|
|
25
|
+
*
|
|
26
|
+
* **已知边界**:接的是**主会话**的系统提示(`index.tsx` → `ContextManager`)。子代理自己拼
|
|
27
|
+
* 提示词、且**请求读的是局部变量而非上下文**(见 `agent/sub-agent.ts` 的 `currentSystemPrompt`),
|
|
28
|
+
* 所以子代理**拿不到**这一段 —— 与权限段那道班是同一个形状,但那道班里子代理是**必须**知道
|
|
29
|
+
* 自己处在哪一档(不然会拒绝做已被允许的事),MCP instructions 只是「这个 server 怎么用」,
|
|
30
|
+
* 拿不到不影响正确性。此处如实记下,免得被当成已覆盖。
|
|
31
|
+
*/
|
|
32
|
+
export function buildMcpInstructionsBlock(connections: ConnectionInfo[]): string {
|
|
33
|
+
const sections = connections
|
|
34
|
+
.filter((c) => typeof c.instructions === 'string' && c.instructions.trim() !== '')
|
|
35
|
+
.slice()
|
|
36
|
+
.sort((a, b) => a.config.name.localeCompare(b.config.name))
|
|
37
|
+
.map((c) => {
|
|
38
|
+
const raw = c.instructions!.trim()
|
|
39
|
+
const body =
|
|
40
|
+
raw.length > MCP_INSTRUCTIONS_PER_SERVER_CAP
|
|
41
|
+
? `${raw.slice(0, MCP_INSTRUCTIONS_PER_SERVER_CAP)}\n${TRUNCATION_MARKER}`
|
|
42
|
+
: raw
|
|
43
|
+
return `### ${c.config.name}\n${body}`
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
if (sections.length === 0) return ''
|
|
47
|
+
|
|
48
|
+
return ['## MCP server guidance', '', ...sections].join('\n')
|
|
49
|
+
}
|
package/src/mcp/types.ts
CHANGED
|
@@ -45,6 +45,11 @@ export interface InitializeResult {
|
|
|
45
45
|
protocolVersion: string
|
|
46
46
|
capabilities: ServerCapabilities
|
|
47
47
|
serverInfo: { name: string; version: string }
|
|
48
|
+
/**
|
|
49
|
+
* MCP 规范里 server 自带的「我该怎么被使用」。它没有工具描述那种可寄生之处,
|
|
50
|
+
* 不收就一个字节都到不了模型(见 `mcp/instructions.ts`)。
|
|
51
|
+
*/
|
|
52
|
+
instructions?: string
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
export interface ServerCapabilities {
|
|
@@ -109,4 +114,6 @@ export interface ConnectionInfo {
|
|
|
109
114
|
tools: ToolDefinition[]
|
|
110
115
|
error?: string
|
|
111
116
|
serverInfo?: { name: string; version: string }
|
|
117
|
+
/** 见 `InitializeResult.instructions`。 */
|
|
118
|
+
instructions?: string
|
|
112
119
|
}
|
|
@@ -64,8 +64,18 @@ function loadClaudeSkills(dir: string, skillsLoader: SkillsLoader): void {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Map a Claude MCP server entry to a Mipham `McpServerConfig`, or `null` if the
|
|
69
|
+
* entry carries no transport.
|
|
70
|
+
*
|
|
71
|
+
* Exported so `validatePlugin` can ask *this* function — rather than its own copy
|
|
72
|
+
* of the rule — whether an entry would survive the load. A second copy is a second
|
|
73
|
+
* answer, and the one that drifts is the one the operator reads.
|
|
74
|
+
*/
|
|
75
|
+
export function toMcpServerConfig(
|
|
76
|
+
name: string,
|
|
77
|
+
raw: Record<string, unknown>,
|
|
78
|
+
): McpServerConfig | null {
|
|
69
79
|
const command = typeof raw.command === 'string' ? raw.command : undefined
|
|
70
80
|
const url = typeof raw.url === 'string' ? raw.url : undefined
|
|
71
81
|
if (!command && !url) return null
|
|
@@ -7,7 +7,7 @@ import type { HookEngine } from '../core/hooks'
|
|
|
7
7
|
import type { McpClient } from '../mcp/client'
|
|
8
8
|
import { registerMcpServerTools } from '../mcp/registry'
|
|
9
9
|
import { executeHook } from '../core/hooks-executor'
|
|
10
|
-
import { detectPluginFormat } from './plugin-validator'
|
|
10
|
+
import { detectPluginFormat, isLoadableMcpConfig } from './plugin-validator'
|
|
11
11
|
import { loadClaudePlugin } from './claude-plugin'
|
|
12
12
|
import type { McpServerConfig, ToolDefinition, HookConfig, HookEvent } from '../shared/types'
|
|
13
13
|
|
|
@@ -50,7 +50,6 @@ export function loadPlugins(
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
const mcpServers: string[] = []
|
|
53
|
-
const hookEvents: HookEvent[] = []
|
|
54
53
|
|
|
55
54
|
// ── Custom agents ──
|
|
56
55
|
const agentsDir = join(plugin.path, 'agents')
|
|
@@ -86,7 +85,11 @@ export function loadPlugins(
|
|
|
86
85
|
try {
|
|
87
86
|
const raw = readFileSync(join(mcpDir, entry), 'utf-8')
|
|
88
87
|
const cfg = JSON.parse(raw) as McpServerConfig
|
|
89
|
-
|
|
88
|
+
// Both transports, not just the local one. Requiring `command` here used
|
|
89
|
+
// to drop every server declared by `url` — a remote server carries no
|
|
90
|
+
// command — and dropping it in silence, so the plugin looked installed
|
|
91
|
+
// and its tools simply never appeared.
|
|
92
|
+
if (isLoadableMcpConfig(cfg)) {
|
|
90
93
|
mcpServers.push(cfg.name)
|
|
91
94
|
mcpClient
|
|
92
95
|
.connect(cfg)
|
|
@@ -103,9 +106,20 @@ export function loadPlugins(
|
|
|
103
106
|
`[plugin] Failed to connect MCP "${cfg.name}" from "${plugin.name}": ${String(err)}\n`,
|
|
104
107
|
)
|
|
105
108
|
})
|
|
109
|
+
} else {
|
|
110
|
+
// Silent skips are the failure this branch exists to end: the operator
|
|
111
|
+
// sees a plugin that loaded and tools that are missing, with nothing
|
|
112
|
+
// connecting the two.
|
|
113
|
+
const declared =
|
|
114
|
+
typeof cfg.name === 'string' && cfg.name !== '' ? ` "MCP server ${cfg.name}"` : ''
|
|
115
|
+
process.stderr.write(
|
|
116
|
+
`[plugin] "${plugin.name}": mcp-servers/${entry}${declared} declares neither command nor url — skipped\n`,
|
|
117
|
+
)
|
|
106
118
|
}
|
|
107
|
-
} catch {
|
|
108
|
-
|
|
119
|
+
} catch (err) {
|
|
120
|
+
process.stderr.write(
|
|
121
|
+
`[plugin] "${plugin.name}": mcp-servers/${entry} could not be parsed — skipped: ${String(err)}\n`,
|
|
122
|
+
)
|
|
109
123
|
}
|
|
110
124
|
}
|
|
111
125
|
} catch (err) {
|
|
@@ -126,10 +140,14 @@ export function loadPlugins(
|
|
|
126
140
|
const event = (hookCfg as unknown as Record<string, unknown>).event as
|
|
127
141
|
HookEvent | undefined
|
|
128
142
|
if (event) {
|
|
129
|
-
hookEvents.push(event)
|
|
130
143
|
hookEngine.register({
|
|
131
144
|
event,
|
|
132
|
-
|
|
145
|
+
// Whoever is running the session can no longer tell this hook from
|
|
146
|
+
// one they wrote themselves: the failure it prints would name only
|
|
147
|
+
// its command, its health would be tracked under the bare event
|
|
148
|
+
// name, and the cleanup below would have nothing to scope to.
|
|
149
|
+
source: plugin.name,
|
|
150
|
+
handler: async (ctx) => executeHook(hookCfg, ctx, plugin.name),
|
|
133
151
|
})
|
|
134
152
|
}
|
|
135
153
|
}
|
|
@@ -153,13 +171,13 @@ export function loadPlugins(
|
|
|
153
171
|
/* best effort */
|
|
154
172
|
}
|
|
155
173
|
}
|
|
156
|
-
// Unregister hooks
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
174
|
+
// Unregister hooks — this plugin's, and only this plugin's. Keyed by event,
|
|
175
|
+
// this removed every hook on those events: the operator's own from settings
|
|
176
|
+
// and other plugins' alike, silently.
|
|
177
|
+
try {
|
|
178
|
+
hookEngine.unregisterSource(plugin.name)
|
|
179
|
+
} catch {
|
|
180
|
+
/* best effort */
|
|
163
181
|
}
|
|
164
182
|
})
|
|
165
183
|
}
|
|
@@ -15,6 +15,18 @@ import { miphamHome } from '../core/paths.ts'
|
|
|
15
15
|
|
|
16
16
|
const PLUGIN_DIR = miphamHome('plugins')
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Render validation warnings for the install result.
|
|
20
|
+
*
|
|
21
|
+
* A warning the install message drops is a warning nobody reads, and the whole
|
|
22
|
+
* point of reporting a declaration we would skip is that the operator finds out
|
|
23
|
+
* here — before wondering why the plugin's tools never appeared.
|
|
24
|
+
*/
|
|
25
|
+
function warningsBlock(warnings: string[]): string {
|
|
26
|
+
if (warnings.length === 0) return ''
|
|
27
|
+
return warnings.map((w) => `\n⚠ ${w}`).join('')
|
|
28
|
+
}
|
|
29
|
+
|
|
18
30
|
export interface InstalledPlugin {
|
|
19
31
|
name: string
|
|
20
32
|
version: string
|
|
@@ -68,7 +80,8 @@ export class PluginManager {
|
|
|
68
80
|
success: true,
|
|
69
81
|
message:
|
|
70
82
|
`Plugin "${validation.manifest.name}" v${validation.manifest.version} installed` +
|
|
71
|
-
(similarWarning ? `\n⚠ ${similarWarning}` : '')
|
|
83
|
+
(similarWarning ? `\n⚠ ${similarWarning}` : '') +
|
|
84
|
+
warningsBlock(validation.warnings),
|
|
72
85
|
}
|
|
73
86
|
}
|
|
74
87
|
|
|
@@ -169,7 +182,8 @@ export class PluginManager {
|
|
|
169
182
|
success: true,
|
|
170
183
|
message:
|
|
171
184
|
`Plugin "${manifestName}" installed from npm` +
|
|
172
|
-
(similarWarning ? `\n⚠ ${similarWarning}` : '')
|
|
185
|
+
(similarWarning ? `\n⚠ ${similarWarning}` : '') +
|
|
186
|
+
warningsBlock(validation.warnings),
|
|
173
187
|
}
|
|
174
188
|
} catch (err: unknown) {
|
|
175
189
|
const msg = err instanceof Error ? err.message : String(err)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'node:fs'
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
+
import { toMcpServerConfig } from './claude-plugin'
|
|
3
4
|
|
|
4
5
|
export interface PluginManifest {
|
|
5
6
|
name: string
|
|
@@ -16,12 +17,43 @@ export type PluginFormat = 'mipham' | 'claude'
|
|
|
16
17
|
export interface PluginValidation {
|
|
17
18
|
valid: boolean
|
|
18
19
|
errors: string[]
|
|
20
|
+
/**
|
|
21
|
+
* Findings that do not block installation.
|
|
22
|
+
*
|
|
23
|
+
* The split is the point: a declaration we would silently drop is a defect in one
|
|
24
|
+
* part of one plugin, and refusing to install the whole thing over it would make
|
|
25
|
+
* the validator an obstacle rather than a report. What it must not do is stay
|
|
26
|
+
* quiet — a dropped declaration that nothing mentions is indistinguishable from
|
|
27
|
+
* one that worked.
|
|
28
|
+
*/
|
|
29
|
+
warnings: string[]
|
|
19
30
|
manifest?: PluginManifest
|
|
20
31
|
format: PluginFormat
|
|
21
32
|
/** Resolved manifest path (empty when no manifest found). */
|
|
22
33
|
manifestPath: string
|
|
23
34
|
}
|
|
24
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Whether `loadPlugins` will hand this declaration to the MCP client.
|
|
38
|
+
*
|
|
39
|
+
* Lives here so the check and the loader read one rule: `plugin-loader.ts` calls
|
|
40
|
+
* this at its guard, and the MCP checks below call it to decide what to report. It
|
|
41
|
+
* requires a `name` because that is the key the client connects under, and one
|
|
42
|
+
* transport or the other — `McpServerConfig` makes `command` and `url` mutually
|
|
43
|
+
* exclusive, so demanding a `command` is what used to drop every remote server.
|
|
44
|
+
*/
|
|
45
|
+
export function isLoadableMcpConfig(cfg: {
|
|
46
|
+
name?: unknown
|
|
47
|
+
command?: unknown
|
|
48
|
+
url?: unknown
|
|
49
|
+
}): boolean {
|
|
50
|
+
return (
|
|
51
|
+
typeof cfg.name === 'string' &&
|
|
52
|
+
cfg.name.length > 0 &&
|
|
53
|
+
(typeof cfg.command === 'string' || typeof cfg.url === 'string')
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
25
57
|
/**
|
|
26
58
|
* Resolve a plugin's manifest. Mipham plugins use `plugin.json` at the plugin
|
|
27
59
|
* root; Claude marketplace plugins use `.claude-plugin/plugin.json`.
|
|
@@ -39,12 +71,144 @@ export function detectPluginFormat(dir: string): PluginFormat {
|
|
|
39
71
|
return resolveManifestPath(dir)?.format ?? 'mipham'
|
|
40
72
|
}
|
|
41
73
|
|
|
74
|
+
/**
|
|
75
|
+
* `${user_config.*}` is Claude Code's placeholder for a value it prompts the user
|
|
76
|
+
* for at install time. This loader has no such step: `expandPluginRoot` substitutes
|
|
77
|
+
* `${CLAUDE_PLUGIN_ROOT}` and nothing else, so the reference reaches the server as
|
|
78
|
+
* literal text. Every reference is reported — "declared" and "undeclared" are not a
|
|
79
|
+
* useful split here, because neither one resolves.
|
|
80
|
+
*/
|
|
81
|
+
const USER_CONFIG_RE = /\$\{user_config\.([A-Za-z0-9_.-]+)\}/g
|
|
82
|
+
|
|
83
|
+
function userConfigKeys(texts: string[]): string[] {
|
|
84
|
+
const keys: string[] = []
|
|
85
|
+
const seen = new Set<string>()
|
|
86
|
+
for (const text of texts) {
|
|
87
|
+
for (const m of text.matchAll(USER_CONFIG_RE)) {
|
|
88
|
+
const key = m[1]!
|
|
89
|
+
if (seen.has(key)) continue
|
|
90
|
+
seen.add(key)
|
|
91
|
+
keys.push(key)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return keys
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Hosts for which a cleartext URL stays on the machine, so there is nothing in transit. */
|
|
98
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]'])
|
|
99
|
+
|
|
100
|
+
function cleartextWarning(server: string, url: string): string | null {
|
|
101
|
+
if (!/^http:\/\//i.test(url)) return null
|
|
102
|
+
let host: string
|
|
103
|
+
try {
|
|
104
|
+
host = new URL(url).hostname
|
|
105
|
+
} catch {
|
|
106
|
+
return null // an unparseable URL is not the finding this check is for
|
|
107
|
+
}
|
|
108
|
+
if (LOOPBACK_HOSTS.has(host)) return null
|
|
109
|
+
return `MCP server "${server}" uses a cleartext http:// URL (${url}) — it is readable in transit`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const DROPPED = 'declares neither command nor url and would be skipped at load'
|
|
113
|
+
|
|
114
|
+
function describe(err: unknown): string {
|
|
115
|
+
return err instanceof Error ? err.message : String(err)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Check the Claude-format sources: `.mcp.json` at the root, plus inline `mcpServers`. */
|
|
119
|
+
function checkClaudeMcp(
|
|
120
|
+
dir: string,
|
|
121
|
+
manifest: PluginManifest,
|
|
122
|
+
texts: string[],
|
|
123
|
+
warnings: string[],
|
|
124
|
+
): void {
|
|
125
|
+
const inspect = (label: string, servers: unknown): void => {
|
|
126
|
+
if (!servers || typeof servers !== 'object') return
|
|
127
|
+
for (const [name, entry] of Object.entries(servers as Record<string, unknown>)) {
|
|
128
|
+
const raw = (entry ?? {}) as Record<string, unknown>
|
|
129
|
+
if (toMcpServerConfig(name, raw) === null) {
|
|
130
|
+
warnings.push(`${label}: MCP server "${name}" ${DROPPED}`)
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if (typeof raw.url === 'string') {
|
|
134
|
+
const cleartext = cleartextWarning(name, raw.url)
|
|
135
|
+
if (cleartext) warnings.push(`${label}: ${cleartext}`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const mcpJsonPath = join(dir, '.mcp.json')
|
|
141
|
+
if (existsSync(mcpJsonPath)) {
|
|
142
|
+
const text = readFileSync(mcpJsonPath, 'utf-8')
|
|
143
|
+
texts.push(text)
|
|
144
|
+
try {
|
|
145
|
+
inspect('`.mcp.json`', (JSON.parse(text) as { mcpServers?: unknown }).mcpServers)
|
|
146
|
+
} catch (err) {
|
|
147
|
+
warnings.push(`\`.mcp.json\` could not be parsed and is skipped at load: ${describe(err)}`)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const inline = manifest.mcpServers
|
|
152
|
+
if (typeof inline === 'string') {
|
|
153
|
+
// The manifest type admits a string here, and the loader's `collect` returns
|
|
154
|
+
// early on anything that is not an object — so this form is declared and read
|
|
155
|
+
// by nothing.
|
|
156
|
+
warnings.push('`mcpServers` is a string; this loader only reads an object and ignores it')
|
|
157
|
+
} else {
|
|
158
|
+
inspect('manifest `mcpServers`', inline)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Check the Mipham-format source: `mcp-servers/*.json` at the plugin root. */
|
|
163
|
+
function checkMiphamMcp(dir: string, texts: string[], warnings: string[]): void {
|
|
164
|
+
const mcpDir = join(dir, 'mcp-servers')
|
|
165
|
+
if (!existsSync(mcpDir)) return
|
|
166
|
+
|
|
167
|
+
let entries: string[]
|
|
168
|
+
try {
|
|
169
|
+
entries = readdirSync(mcpDir)
|
|
170
|
+
} catch {
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const entry of entries) {
|
|
175
|
+
if (!entry.endsWith('.json')) continue
|
|
176
|
+
const label = `\`mcp-servers/${entry}\``
|
|
177
|
+
let text: string
|
|
178
|
+
try {
|
|
179
|
+
text = readFileSync(join(mcpDir, entry), 'utf-8')
|
|
180
|
+
} catch (err) {
|
|
181
|
+
warnings.push(`${label} could not be read and is skipped at load: ${describe(err)}`)
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
texts.push(text)
|
|
185
|
+
|
|
186
|
+
let cfg: Record<string, unknown>
|
|
187
|
+
try {
|
|
188
|
+
cfg = JSON.parse(text) as Record<string, unknown>
|
|
189
|
+
} catch (err) {
|
|
190
|
+
warnings.push(`${label} could not be parsed and is skipped at load: ${describe(err)}`)
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!isLoadableMcpConfig(cfg ?? {})) {
|
|
195
|
+
warnings.push(`${label}: MCP server "${String(cfg?.name ?? entry)}" ${DROPPED}`)
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
if (typeof cfg.url === 'string') {
|
|
199
|
+
const cleartext = cleartextWarning(String(cfg.name), cfg.url)
|
|
200
|
+
if (cleartext) warnings.push(`${label}: ${cleartext}`)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
42
205
|
export function validatePlugin(dir: string): PluginValidation {
|
|
43
206
|
const resolved = resolveManifestPath(dir)
|
|
44
207
|
if (!resolved) {
|
|
45
208
|
return {
|
|
46
209
|
valid: false,
|
|
47
210
|
errors: ['No plugin manifest found (expected plugin.json or .claude-plugin/plugin.json)'],
|
|
211
|
+
warnings: [],
|
|
48
212
|
format: 'mipham',
|
|
49
213
|
manifestPath: '',
|
|
50
214
|
}
|
|
@@ -69,9 +233,26 @@ export function validatePlugin(dir: string): PluginValidation {
|
|
|
69
233
|
}
|
|
70
234
|
}
|
|
71
235
|
|
|
236
|
+
const warnings: string[] = []
|
|
237
|
+
// Every text read on the way to a declaration, so a `${user_config.*}` inside an
|
|
238
|
+
// `args` array or a header value is caught wherever it appears.
|
|
239
|
+
const texts: string[] = [raw]
|
|
240
|
+
if (resolved.format === 'claude') {
|
|
241
|
+
checkClaudeMcp(dir, manifest, texts, warnings)
|
|
242
|
+
} else {
|
|
243
|
+
checkMiphamMcp(dir, texts, warnings)
|
|
244
|
+
}
|
|
245
|
+
for (const key of userConfigKeys(texts)) {
|
|
246
|
+
warnings.push(
|
|
247
|
+
`\`\${user_config.${key}}\` is passed through literally — this loader has no user-config ` +
|
|
248
|
+
`step, so the server receives the text itself`,
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
72
252
|
return {
|
|
73
253
|
valid: errors.length === 0,
|
|
74
254
|
errors,
|
|
255
|
+
warnings,
|
|
75
256
|
manifest,
|
|
76
257
|
format: resolved.format,
|
|
77
258
|
manifestPath: resolved.path,
|
|
@@ -81,6 +262,7 @@ export function validatePlugin(dir: string): PluginValidation {
|
|
|
81
262
|
return {
|
|
82
263
|
valid: false,
|
|
83
264
|
errors: [`Failed to read ${label}: ${String(err)}`],
|
|
265
|
+
warnings: [],
|
|
84
266
|
format: resolved.format,
|
|
85
267
|
manifestPath: resolved.path,
|
|
86
268
|
}
|