@miphamai/cli 0.85.3 → 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/package.json +1 -1
- package/src/agent-view/agents-standalone.tsx +42 -0
- package/src/commands/project.ts +75 -24
- package/src/core/context.ts +56 -5
- package/src/core/engine.ts +95 -34
- 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/permission-classifier.ts +21 -3
- package/src/core/permission-rules.ts +1 -1
- package/src/core/permission.ts +50 -1
- package/src/core/session-log.ts +60 -0
- package/src/daemon/index.ts +2 -5
- package/src/daemon/server.ts +19 -14
- package/src/i18n-core/locales/en-US.json +5 -2
- package/src/i18n-core/locales/zh-CN.json +5 -2
- 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 +36 -15
- package/src/providers/fetch-utils.ts +53 -5
- package/src/security/dangerous-rm.ts +192 -0
- package/src/shared/constants.ts +18 -0
- package/src/shared/deleted-cwd.ts +46 -1
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +8 -0
- package/src/ui/command-picker.tsx +18 -10
- package/src/ui/commands.ts +1 -1
- package/src/ui/config-wizard.tsx +22 -19
- 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/daemon/server.ts
CHANGED
|
@@ -3,7 +3,6 @@ import type { Server, ServerWebSocket } from 'bun'
|
|
|
3
3
|
import type { DaemonDatabase } from './database'
|
|
4
4
|
import type { SessionManager } from './session-manager'
|
|
5
5
|
import type { AgentManager } from './agent-manager'
|
|
6
|
-
import type { MessageBus } from './message-bus'
|
|
7
6
|
import type { DaemonGoal, AgentKind } from './types'
|
|
8
7
|
import type { GoalManager } from './goal-manager'
|
|
9
8
|
import type { ScheduleManager } from './schedule-manager'
|
|
@@ -49,7 +48,6 @@ interface ServerConfig {
|
|
|
49
48
|
port: number
|
|
50
49
|
hostname: string
|
|
51
50
|
agentManager: AgentManager
|
|
52
|
-
messageBus: MessageBus
|
|
53
51
|
goalManager: GoalManager
|
|
54
52
|
scheduleManager: ScheduleManager
|
|
55
53
|
rateLimiter: RateLimiter
|
|
@@ -156,7 +154,6 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
156
154
|
port,
|
|
157
155
|
hostname,
|
|
158
156
|
agentManager,
|
|
159
|
-
messageBus,
|
|
160
157
|
goalManager,
|
|
161
158
|
scheduleManager,
|
|
162
159
|
rateLimiter,
|
|
@@ -192,15 +189,8 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
192
189
|
}
|
|
193
190
|
}
|
|
194
191
|
|
|
195
|
-
// ── Agent lifecycle → WebSocket broadcast
|
|
192
|
+
// ── Agent lifecycle → WebSocket broadcast ──
|
|
196
193
|
agentManager.onLifecycleEvent((event) => {
|
|
197
|
-
// Register / unregister in the message bus for broadcastToSession routing
|
|
198
|
-
if (event.type === 'created') {
|
|
199
|
-
messageBus.registerAgent(event.agent.sessionId, event.agent.id)
|
|
200
|
-
} else if (event.type === 'completed' || event.type === 'failed') {
|
|
201
|
-
messageBus.unregisterAgent(event.agent.id)
|
|
202
|
-
}
|
|
203
|
-
|
|
204
194
|
// Broadcast lifecycle events to all WebSocket clients in the agent's session
|
|
205
195
|
broadcast(event.agent.sessionId, {
|
|
206
196
|
type: 'agent_lifecycle',
|
|
@@ -670,8 +660,23 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
670
660
|
return json({ ok: false, error: 'Agent not found' }, { status: 404 })
|
|
671
661
|
}
|
|
672
662
|
|
|
673
|
-
messageBus.send(
|
|
674
|
-
|
|
663
|
+
// 从前这里 `messageBus.send(...)` 后回 **202 + `{ok:true}`** —— 一张兑现不了的收条。
|
|
664
|
+
// daemon 侧**不存在 agent 执行循环**(`AgentManager` 纯持久化,`src/daemon/*.ts` 里
|
|
665
|
+
// `SubAgent`/`spawn` 零命中),所以进程内没有任何东西会去读这条消息;而真正在用的
|
|
666
|
+
// 那条总线(`src/agent/message-bus.ts`)由子代理 / workflow 用 `bg-…` 那套 id 投递,
|
|
667
|
+
// 与这里的 `agent-<uuid8>` **不同一个 id 空间**,改投它也找不到人。
|
|
668
|
+
// 202 在这里是有害的:调用方据此认为话已送达,于是不再重试、也不再报错。
|
|
669
|
+
// 报 501 并给出替代路径 —— 只说「不支持」会让调用方反复重试同一件事。
|
|
670
|
+
return json(
|
|
671
|
+
{
|
|
672
|
+
ok: false,
|
|
673
|
+
error:
|
|
674
|
+
'Agent messaging is not implemented on the daemon: this build has no agent ' +
|
|
675
|
+
'execution loop, so nothing would receive the message. Use ' +
|
|
676
|
+
'POST /api/v1/sessions/:id/prompt to send work to a session instead.',
|
|
677
|
+
},
|
|
678
|
+
{ status: 501 },
|
|
679
|
+
)
|
|
675
680
|
}
|
|
676
681
|
|
|
677
682
|
// ── Goals (Phase 4 — service-backed) ────────────
|
|
@@ -806,7 +811,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
806
811
|
{
|
|
807
812
|
method: 'POST',
|
|
808
813
|
path: '/api/v1/agents/:id/message',
|
|
809
|
-
description: '
|
|
814
|
+
description: 'Not implemented — always 501; use POST /api/v1/sessions/:id/prompt',
|
|
810
815
|
},
|
|
811
816
|
{ method: 'GET', path: '/api/v1/goals', description: 'List goals for a session' },
|
|
812
817
|
{ method: 'POST', path: '/api/v1/goals', description: 'Create a goal' },
|
|
@@ -996,8 +996,9 @@
|
|
|
996
996
|
"errors": {
|
|
997
997
|
"tool_denied_deny_rule": "Tool \"{name}\" blocked by a deny rule (\"{pattern}\"). Deny rules override permission mode — try a different approach, or drop the rule with: /permissions remove \"{pattern}\"",
|
|
998
998
|
"tool_denied_ask_rule": "Tool \"{name}\" requires approval (ask rule: \"{pattern}\"). Approve when prompted, or stop being asked with: /permissions allow \"{pattern}\"",
|
|
999
|
-
"tool_denied_mode": "Tool \"{name}\" requires approval under \"{mode}\" mode. Press Shift+Tab to switch permission mode, or stop being asked with: /permissions allow \"{name}\" (or a narrower \"{name}(arg)\")",
|
|
1000
|
-
"tool_denied_classifier": "Tool \"{name}\" was blocked in auto mode by the permission classifier ({reason}). This is a policy decision, not a failure — switching modes will not change it, and repeating the identical call returns the same ruling. Change the call so it no longer trips the rule, or allow it outright with: /permissions allow \"{name}\"",
|
|
999
|
+
"tool_denied_mode": "Tool \"{name}\" requires approval under \"{mode}\" mode. Press Shift+Tab to switch permission mode, or stop being asked with: /permissions allow \"{name}\" (or a narrower \"{name}(arg)\") Treat this denial as covering the outcome, not only this exact command.",
|
|
1000
|
+
"tool_denied_classifier": "Tool \"{name}\" was blocked in auto mode by the permission classifier ({reason}). This is a policy decision, not a failure — switching modes will not change it, and repeating the identical call returns the same ruling. Change the call so it no longer trips the rule, or allow it outright with: /permissions allow \"{name}\" Treat this denial as covering the outcome, not only this exact command.",
|
|
1001
|
+
"tool_denied_dangerous_rm": "Tool \"{name}\" was refused: this is a recursive rm whose target is not a path written in the command (\"{target}\"). The target is produced when the command runs, so neither an allow rule nor the permission mode can bound what it deletes. Name the directory literally instead — `rm -rf node_modules` rather than `rm -rf \"$(pwd)\"` — and treat this refusal as covering the outcome, not only this exact spelling. To turn this check off: MIPHAM_DISABLE_DANGEROUS_RM_PROMPT=1",
|
|
1001
1002
|
"tool_denied_classifier_unavailable": "Tool \"{name}\" was held back because the auto-mode classifier could not be reached or its answer could not be read ({reason}). This is NOT a policy decision — the call was never judged, and retrying is appropriate. If this keeps happening, switch modes with Shift+Tab.",
|
|
1002
1003
|
"tool_blocked": "Tool \"{name}\" blocked by hook",
|
|
1003
1004
|
"user_input_blocked": "User input blocked by hook.",
|
|
@@ -1020,6 +1021,8 @@
|
|
|
1020
1021
|
"max_tool_turns": "Max tool-calling turns ({max}) reached. Some tool calls were not executed.",
|
|
1021
1022
|
"max_tool_turns_warning": "You've reached the maximum of {max} tool-calling rounds. {pending} tool call(s) were not executed. Please summarize what you found so far and any next steps the user should take.",
|
|
1022
1023
|
"turn_timeout": "Task produced no substantial output for {minutes} minutes and was stopped. Break the task into smaller steps or clarify the next step, then retry.",
|
|
1024
|
+
"turn_truncated": "The response was cut off mid-turn, not finished — the provider stopped at its output limit or the connection closed.",
|
|
1025
|
+
"stop_hook_budget_spent": "The Stop hook asked for more work (\"{reason}\"), but this turn has used all {max} of its tool-calling rounds — stopping here. Send another message to continue.",
|
|
1023
1026
|
"mcp_not_connected": "Error: MCP server \"{server}\" not connected",
|
|
1024
1027
|
"mcp_tool_error": "MCP tool error: {error}",
|
|
1025
1028
|
"mcp_no_connection": "No connection for \"{name}\""
|
|
@@ -996,8 +996,9 @@
|
|
|
996
996
|
"errors": {
|
|
997
997
|
"tool_denied_deny_rule": "工具 \"{name}\" 被拒绝规则(\"{pattern}\")阻止。拒绝规则优先于权限模式 — 请改用其他方式,或移除该规则:/permissions remove \"{pattern}\"",
|
|
998
998
|
"tool_denied_ask_rule": "工具 \"{name}\" 需要批准(ask 规则:\"{pattern}\")。请在提示时批准,或不再询问:/permissions allow \"{pattern}\"",
|
|
999
|
-
"tool_denied_mode": "工具 \"{name}\" 在 \"{mode}\" 模式下需要批准。按 Shift+Tab 切换权限模式,或不再询问:/permissions allow \"{name}\"(更窄的 \"{name}(arg)\" 亦可)",
|
|
1000
|
-
"tool_denied_classifier": "工具 \"{name}\" 在 auto 模式下被权限分类器拦下({reason})。这是策略裁决、不是故障 —— 切换模式不会改变它,把同一次调用原样重发也只会得到同一个裁决。请改变这次调用使其不再触犯该规则,或直接放行:/permissions allow \"{name}\"",
|
|
999
|
+
"tool_denied_mode": "工具 \"{name}\" 在 \"{mode}\" 模式下需要批准。按 Shift+Tab 切换权限模式,或不再询问:/permissions allow \"{name}\"(更窄的 \"{name}(arg)\" 亦可) 请把这次拒绝理解为对结果本身的禁止,而不只是这一条命令。",
|
|
1000
|
+
"tool_denied_classifier": "工具 \"{name}\" 在 auto 模式下被权限分类器拦下({reason})。这是策略裁决、不是故障 —— 切换模式不会改变它,把同一次调用原样重发也只会得到同一个裁决。请改变这次调用使其不再触犯该规则,或直接放行:/permissions allow \"{name}\" 请把这次拒绝理解为对结果本身的禁止,而不只是这一条命令。",
|
|
1001
|
+
"tool_denied_dangerous_rm": "工具 \"{name}\" 被拒绝:这是一条递归 rm,其目标并非命令里写出的路径(\"{target}\")。该目标要到命令运行时才产生,所以任何放行规则与权限模式都无法界定它会删掉什么。请改为字面写出目录名 —— 用 `rm -rf node_modules` 而不是 `rm -rf \"$(pwd)\"` —— 并把这次拒绝理解为对结果本身的禁止,而不只是这一种写法。关闭此检查:MIPHAM_DISABLE_DANGEROUS_RM_PROMPT=1",
|
|
1001
1002
|
"tool_denied_classifier_unavailable": "工具 \"{name}\" 被暂缓:auto 模式的分类器不可达,或其回复无法解析({reason})。这**不是**策略裁决 —— 该调用从未被裁决,重试是恰当的。若持续发生,按 Shift+Tab 切换模式。",
|
|
1002
1003
|
"tool_blocked": "工具 \"{name}\" 被钩子拦截",
|
|
1003
1004
|
"user_input_blocked": "用户输入被钩子拦截。",
|
|
@@ -1020,6 +1021,8 @@
|
|
|
1020
1021
|
"max_tool_turns": "超出最大工具调用轮次({max})。部分工具调用未执行。",
|
|
1021
1022
|
"max_tool_turns_warning": "已达到最大 {max} 轮工具调用。{pending} 个工具调用未执行。请总结当前发现及后续步骤。",
|
|
1022
1023
|
"turn_timeout": "任务已超过 {minutes} 分钟无实质产出,已自动停止。建议拆分任务或明确下一步后重新发起。",
|
|
1024
|
+
"turn_truncated": "回复写到一半就断了,不是正常写完 —— 提供商到了输出上限,或连接被关闭。",
|
|
1025
|
+
"stop_hook_budget_spent": "Stop 钩子要求继续({reason}),但本轮 {max} 轮工具调用已用尽 —— 就此停下。再发一条消息即可继续。",
|
|
1023
1026
|
"mcp_not_connected": "错误:MCP 服务器 \"{server}\" 未连接",
|
|
1024
1027
|
"mcp_tool_error": "MCP 工具错误:{error}",
|
|
1025
1028
|
"mcp_no_connection": "未找到 \"{name}\" 的连接"
|
package/src/index.tsx
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
discoverSessions,
|
|
22
22
|
} from './agent/cross-session/discovery'
|
|
23
23
|
import { bootstrapProviders } from './providers/bootstrap'
|
|
24
|
-
import { InstructionsLoader } from './core/instructions'
|
|
24
|
+
import { InstructionsLoader, formatInstructionSizeNotice } from './core/instructions'
|
|
25
25
|
import { loadSessionMemories, getMemoryManager } from './core/memory/memory-loader'
|
|
26
26
|
import { ContextManager } from './core/context'
|
|
27
27
|
import { PrefixCacheTracker } from './core/context-token'
|
|
@@ -44,6 +44,7 @@ import { mountLlm, LLM_KEY } from './providers/llm'
|
|
|
44
44
|
import { mountConstitution, createConstitution } from './core/constitution-seam'
|
|
45
45
|
import { ConstitutionLoader } from './core/constitution-loader'
|
|
46
46
|
import { McpClient } from './mcp/client'
|
|
47
|
+
import { buildMcpInstructionsBlock } from './mcp/instructions'
|
|
47
48
|
import { registerMcpServerTools, syncMcpToolsOnChange } from './mcp/registry'
|
|
48
49
|
import { formatMcpConnectFailures, type McpConnectFailure } from './mcp/connect-failures'
|
|
49
50
|
import { AgentRegistry } from './agent/agent-registry'
|
|
@@ -58,7 +59,7 @@ import { getWorkspaceTrust, warnProjectHooksSkipped } from './core/workspace-tru
|
|
|
58
59
|
import { ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
|
|
59
60
|
import { artifactsRoot } from './artifacts/paths'
|
|
60
61
|
import { AgentViewManager } from './agent-view/agent-view-manager'
|
|
61
|
-
import {
|
|
62
|
+
import { AgentsStandalone } from './agent-view/agents-standalone'
|
|
62
63
|
import { createT } from './i18n-core/t'
|
|
63
64
|
import { detectLocale } from './i18n-core/detect'
|
|
64
65
|
import { I18nProvider } from './i18n-context'
|
|
@@ -380,11 +381,7 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
380
381
|
if (args[0] === 'agents') {
|
|
381
382
|
const agentViewManager = new AgentViewManager()
|
|
382
383
|
const { waitUntilExit } = render(
|
|
383
|
-
<
|
|
384
|
-
manager={agentViewManager}
|
|
385
|
-
onAttach={() => {}}
|
|
386
|
-
onExit={() => process.exit(0)}
|
|
387
|
-
/>,
|
|
384
|
+
<AgentsStandalone manager={agentViewManager} onExit={() => process.exit(0)} />,
|
|
388
385
|
{ exitOnCtrlC: false },
|
|
389
386
|
)
|
|
390
387
|
await waitUntilExit()
|
|
@@ -476,6 +473,11 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
476
473
|
const instructions = new InstructionsLoader()
|
|
477
474
|
instructions.loadAll(process.cwd())
|
|
478
475
|
|
|
476
|
+
// 指令体积提示:**按总量**说话(单文件都不大也可能一起挤占上下文)。
|
|
477
|
+
// 计数读的是真正会被发出去的那份投影(见 `instructionPartText`),不是磁盘上的字节。
|
|
478
|
+
const sizeNotice = formatInstructionSizeNotice(instructions.sizeReport())
|
|
479
|
+
if (sizeNotice) console.log(`\n${sizeNotice}\n`)
|
|
480
|
+
|
|
479
481
|
// 空目录提示:愣建文件夹时温和提醒走 mipham init,而非默默开始(寒暄克制——一句即可)
|
|
480
482
|
const { isEmptyProject } = await import('./core/project-scaffold')
|
|
481
483
|
if (isEmptyProject(process.cwd())) {
|
|
@@ -555,6 +557,14 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
555
557
|
// 这里接的是 live `permission` —— 同一实例也交给引擎去执行,故下一次请求就与新档一致。
|
|
556
558
|
context.setPermissionContextSource(() => instructions.buildPermissionBlock(permission.getMode()))
|
|
557
559
|
|
|
560
|
+
// MCP server 自带的 `instructions` 走同一条读时派生的路,理由比权限段更硬:server 是**启动后
|
|
561
|
+
// 异步连上**的(见下方 `connectMcpServers`),而提示在这一刻就建好了。组装时烘进去的话,
|
|
562
|
+
// 本次会话里后连上的 server 永远进不了提示 —— 用户只能重启。读 `/listConnections()` 是
|
|
563
|
+
// live 的,连上一个下一次请求就带上。
|
|
564
|
+
context.setMcpInstructionsSource(() =>
|
|
565
|
+
buildMcpInstructionsBlock(McpClient.getInstance().listConnections()),
|
|
566
|
+
)
|
|
567
|
+
|
|
558
568
|
// Adaptive memory budget: scale with model's context window
|
|
559
569
|
getMemoryManager().setContextWindow(modelContextWindow)
|
|
560
570
|
|
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)
|