@miphamai/cli 0.85.4 → 0.85.5

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/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
- /** Map a Claude MCP server entry to a Mipham `McpServerConfig`. */
68
- function toMcpServerConfig(name: string, raw: Record<string, unknown>): McpServerConfig | null {
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
- if (cfg.name && cfg.command) {
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
- // skip unparseable MCP config files
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
- handler: async (ctx) => executeHook(hookCfg, ctx),
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
- for (const event of hookEvents) {
158
- try {
159
- hookEngine.unregister(event)
160
- } catch {
161
- /* best effort */
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
  }
@@ -78,6 +78,15 @@ export class AnthropicProvider implements ProviderInstance {
78
78
  // off at the output ceiling rather than ended by the model.
79
79
  let truncated = false
80
80
 
81
+ // Whether this stream reached `message_stop`. A stream that runs out without
82
+ // one was cut — a proxy or gateway closing the connection cleanly looks
83
+ // exactly like a finished response otherwise.
84
+ let sawTerminalEvent = false
85
+
86
+ // Tool blocks already emitted. A replayed event is the same call, not a
87
+ // second one; emitting it twice makes the engine run the tool twice.
88
+ const emittedToolIds = new Set<string>()
89
+
81
90
  const messages = this.convertMessages(req.messages)
82
91
  this.markPrefixCacheBreakpoint(messages)
83
92
 
@@ -223,21 +232,27 @@ export class AnthropicProvider implements ProviderInstance {
223
232
  // 要在这里丢弃,就得把 `tool_use` 缓冲到 `message_stop` 再发 ——
224
233
  // 那是一次行为变更,不属本次范围。
225
234
  if (currentToolId && currentToolName && accumulatedToolInput) {
226
- let parsedInput: Record<string, unknown> = {}
227
- try {
228
- parsedInput = JSON.parse(accumulatedToolInput)
229
- } catch {
230
- parsedInput = { _raw: accumulatedToolInput }
231
- }
232
-
233
- yield {
234
- type: 'tool_use',
235
- toolUse: {
235
+ // A replayed block carries the id it was first sent with, so the
236
+ // id is what tells a second call apart from the same call twice.
237
+ if (!emittedToolIds.has(currentToolId)) {
238
+ emittedToolIds.add(currentToolId)
239
+
240
+ let parsedInput: Record<string, unknown> = {}
241
+ try {
242
+ parsedInput = JSON.parse(accumulatedToolInput)
243
+ } catch {
244
+ parsedInput = { _raw: accumulatedToolInput }
245
+ }
246
+
247
+ yield {
236
248
  type: 'tool_use',
237
- id: currentToolId,
238
- name: currentToolName,
239
- input: parsedInput,
240
- },
249
+ toolUse: {
250
+ type: 'tool_use',
251
+ id: currentToolId,
252
+ name: currentToolName,
253
+ input: parsedInput,
254
+ },
255
+ }
241
256
  }
242
257
 
243
258
  // Reset accumulator
@@ -272,6 +287,7 @@ export class AnthropicProvider implements ProviderInstance {
272
287
  }
273
288
 
274
289
  case 'message_stop': {
290
+ sawTerminalEvent = true
275
291
  yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
276
292
  return
277
293
  }
@@ -287,7 +303,12 @@ export class AnthropicProvider implements ProviderInstance {
287
303
  }
288
304
  }
289
305
 
290
- yield { type: 'stop' }
306
+ // The stream ran out without `message_stop`. Whatever stopped it, the turn is
307
+ // incomplete — and this is the only place that knows, because a cleanly
308
+ // closed connection and a finished response are otherwise the same stream.
309
+ if (!sawTerminalEvent) truncated = true
310
+
311
+ yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
291
312
  }
292
313
 
293
314
  async listModels(): Promise<ModelInfo[]> {