@miphamai/cli 0.35.0 → 0.36.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.35.0",
3
+ "version": "0.36.1",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -0,0 +1,77 @@
1
+ /**
2
+ * CRSI ↔ MegaSystem provenance bridge.
3
+ *
4
+ * Closes the CRSI loop across the terminal↔backend boundary: every
5
+ * auto-generated rule is recorded as an auditable decision in megasystem
6
+ * (via the `mipham-kg` MCP server), and its effectiveness verdict is later
7
+ * attached via `evaluate_decision`. All calls degrade gracefully — when the
8
+ * megasystem MCP server is not connected, `recordDecision` returns null and
9
+ * `evaluateDecision` returns false, so the local CRSI loop keeps working.
10
+ */
11
+
12
+ import type { ToolCallResult } from '../mcp/types.js'
13
+
14
+ /** Effectiveness verdicts the CRSI loop can emit for a recorded decision. */
15
+ export type CrsiVerdict = 'effective' | 'ineffective' | 'degrading'
16
+
17
+ /** The narrow MCP surface the bridge needs — injectable for tests. */
18
+ export interface CrsiProvenanceClient {
19
+ callTool(
20
+ serverName: string,
21
+ toolName: string,
22
+ params?: Record<string, unknown>,
23
+ ): Promise<ToolCallResult>
24
+ }
25
+
26
+ export class CrsiProvenanceBridge {
27
+ constructor(
28
+ private readonly client: CrsiProvenanceClient,
29
+ private readonly serverName: string = 'mipham-kg',
30
+ ) {}
31
+
32
+ /**
33
+ * Record a rule as an auditable decision in megasystem.
34
+ *
35
+ * Returns the megasystem decision id, or null when the server is
36
+ * unavailable or the call failed — the caller may ignore null.
37
+ */
38
+ async recordDecision(query: string, answer: string, confidence?: number): Promise<string | null> {
39
+ const result = await this.client.callTool(this.serverName, 'record_decision', {
40
+ query,
41
+ answer,
42
+ confidence,
43
+ })
44
+ const parsed = this._parse(result)
45
+ return typeof parsed?.decision_id === 'string' ? parsed.decision_id : null
46
+ }
47
+
48
+ /**
49
+ * Attach an effectiveness verdict to a recorded decision.
50
+ *
51
+ * Returns true when the verdict was accepted by megasystem, false otherwise.
52
+ */
53
+ async evaluateDecision(
54
+ decisionId: string,
55
+ verdict: CrsiVerdict,
56
+ opts: { score?: number; metrics?: Record<string, unknown> } = {},
57
+ ): Promise<boolean> {
58
+ const result = await this.client.callTool(this.serverName, 'evaluate_decision', {
59
+ decision_id: decisionId,
60
+ verdict,
61
+ score: opts.score,
62
+ metrics: opts.metrics,
63
+ })
64
+ return !result.isError
65
+ }
66
+
67
+ private _parse(result: ToolCallResult): Record<string, unknown> | null {
68
+ if (result.isError) return null
69
+ const text = result.content?.[0]?.text
70
+ if (!text) return null
71
+ try {
72
+ return JSON.parse(text) as Record<string, unknown>
73
+ } catch {
74
+ return null
75
+ }
76
+ }
77
+ }
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
2
2
  import { join, dirname } from 'node:path'
3
+ import type { CrsiProvenanceBridge, CrsiVerdict } from './crsi-provenance-bridge.js'
3
4
 
4
5
  export interface RuleEffectiveness {
5
6
  ruleId: string
@@ -15,6 +16,8 @@ export interface RuleEffectiveness {
15
16
  appliedCount: number
16
17
  failureRate: number
17
18
  }>
19
+ /** megasystem decision id, set when the rule was recorded via the bridge. */
20
+ decisionId?: string
18
21
  }
19
22
 
20
23
  const EVAL_THRESHOLD = 10 // evaluate after 10 applications
@@ -23,12 +26,24 @@ const STORE_FILE = 'effectiveness.json'
23
26
  export class EffectivenessTracker {
24
27
  private data: Map<string, RuleEffectiveness>
25
28
  private storePath: string
29
+ private provenanceBridge?: CrsiProvenanceBridge
26
30
 
27
31
  constructor(storeDir: string = join(process.env.HOME || '~', '.mipham', 'rule-engine')) {
28
32
  this.storePath = join(storeDir, STORE_FILE)
29
33
  this.data = new Map()
30
34
  }
31
35
 
36
+ /** Wire the CRSI ↔ MegaSystem provenance bridge for verdict reporting. */
37
+ setProvenanceBridge(bridge: CrsiProvenanceBridge): void {
38
+ this.provenanceBridge = bridge
39
+ }
40
+
41
+ /** Associate a rule with the megasystem decision recorded for it. */
42
+ setDecisionId(ruleId: string, decisionId: string): void {
43
+ const eff = this.data.get(ruleId)
44
+ if (eff) eff.decisionId = decisionId
45
+ }
46
+
32
47
  recordApplication(ruleId: string, success: boolean): void {
33
48
  let eff = this.data.get(ruleId)
34
49
  if (!eff) {
@@ -88,6 +103,9 @@ export class EffectivenessTracker {
88
103
  eff.evaluationHistory = eff.evaluationHistory.slice(-10)
89
104
  }
90
105
 
106
+ // Report the verdict to megasystem (non-blocking, degrades gracefully)
107
+ this._reportEvaluation(eff)
108
+
91
109
  // Auto-management is gated by crsi.autoRuleManagement feature flag
92
110
  if (!autoRuleManagement) continue
93
111
 
@@ -121,6 +139,23 @@ export class EffectivenessTracker {
121
139
  return this.data.get(ruleId) || null
122
140
  }
123
141
 
142
+ /** Fire-and-forget the effectiveness verdict to megasystem. */
143
+ private _reportEvaluation(eff: RuleEffectiveness): void {
144
+ if (!this.provenanceBridge || !eff.decisionId) return
145
+ const verdict: CrsiVerdict =
146
+ eff.postRuleFailureRate < 0.4
147
+ ? 'effective'
148
+ : eff.postRuleFailureRate > 0.6
149
+ ? 'ineffective'
150
+ : 'degrading'
151
+ this.provenanceBridge
152
+ .evaluateDecision(eff.decisionId, verdict, {
153
+ score: eff.postRuleFailureRate,
154
+ metrics: { applied: eff.appliedCount, success: eff.successAfterCount },
155
+ })
156
+ .catch(() => {})
157
+ }
158
+
124
159
  persist(): void {
125
160
  const dir = dirname(this.storePath)
126
161
  mkdirSync(dir, { recursive: true })
@@ -34,11 +34,14 @@ export interface AgentSession {
34
34
  branch?: string
35
35
  /** Created PR URL (if requested) */
36
36
  prUrl?: string
37
+ /** Working directory the session was spawned from (used for directory grouping). */
38
+ directory: string
37
39
  }
38
40
 
39
41
  export interface CreateSessionOptions {
40
42
  provider?: string
41
43
  model?: string
44
+ directory?: string
42
45
  }
43
46
 
44
47
  export interface SessionPeek {
@@ -48,6 +51,11 @@ export interface SessionPeek {
48
51
 
49
52
  export type StatusGroups = Record<SessionStatus, AgentSession[]>
50
53
 
54
+ export interface DirectoryGroup {
55
+ directory: string
56
+ sessions: AgentSession[]
57
+ }
58
+
51
59
  export class AgentViewManager {
52
60
  private sessions: Map<string, AgentSession> = new Map()
53
61
  private sessionOrder: string[] = []
@@ -66,6 +74,7 @@ export class AgentViewManager {
66
74
  provider: options.provider ?? 'unknown',
67
75
  model: options.model ?? 'unknown',
68
76
  task,
77
+ directory: options.directory ?? process.cwd(),
69
78
  createdAt: new Date(),
70
79
  elapsedMs: 0,
71
80
  messages: [],
@@ -109,6 +118,27 @@ export class AgentViewManager {
109
118
  return groups
110
119
  }
111
120
 
121
+ /**
122
+ * Group all sessions by their working directory (v2.1.229 session-grouping alignment).
123
+ * Returns groups sorted alphabetically by directory path.
124
+ */
125
+ groupByDirectory(): DirectoryGroup[] {
126
+ const groups = new Map<string, AgentSession[]>()
127
+
128
+ for (const id of this.sessionOrder) {
129
+ const session = this.sessions.get(id)
130
+ if (!session) continue
131
+ const dir = session.directory || '(unknown)'
132
+ const existing = groups.get(dir)
133
+ if (existing) existing.push(session)
134
+ else groups.set(dir, [session])
135
+ }
136
+
137
+ return Array.from(groups.entries())
138
+ .map(([directory, sessions]) => ({ directory, sessions }))
139
+ .sort((a, b) => a.directory.localeCompare(b.directory))
140
+ }
141
+
112
142
  /**
113
143
  * Get a session by ID.
114
144
  */
@@ -30,7 +30,7 @@ const STATUS_HEADERS: Record<string, { label: string; color: string }> = {
30
30
  export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps) {
31
31
  const [selectedIndex, setSelectedIndex] = useState(0)
32
32
  const [peekingSessionId, setPeekingSessionId] = useState<string | null>(null)
33
- const [_groupBy, setGroupBy] = useState<'status' | 'directory'>('status')
33
+ const [groupBy, setGroupBy] = useState<'status' | 'directory'>('status')
34
34
  const [feedback, setFeedback] = useState<string | null>(null)
35
35
 
36
36
  // Flash a brief feedback message that auto-clears
@@ -41,37 +41,53 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
41
41
 
42
42
  // Build a flat list of sessions in group order, with group headers
43
43
  const flatList = useMemo(() => {
44
- const groups = manager.groupByStatus()
45
44
  const result: Array<
46
- | { type: 'header'; status: string; label: string; color: string; count: number }
45
+ | { type: 'header'; key: string; label: string; color: string; count: number }
47
46
  | { type: 'session'; session: AgentSession }
48
47
  > = []
49
48
 
50
- const statusOrder: Array<keyof typeof STATUS_HEADERS> = [
51
- 'working',
52
- 'needs-input',
53
- 'completed',
54
- 'failed',
55
- ]
49
+ if (groupBy === 'directory') {
50
+ for (const group of manager.groupByDirectory()) {
51
+ result.push({
52
+ type: 'header',
53
+ key: `dir-${group.directory}`,
54
+ label: group.directory,
55
+ color: 'blue',
56
+ count: group.sessions.length,
57
+ })
56
58
 
57
- for (const _status of statusOrder) {
58
- const status = _status as SessionStatus
59
- const sessions = groups[status] ?? []
60
- result.push({
61
- type: 'header',
62
- status,
63
- label: STATUS_HEADERS[status]!.label,
64
- color: STATUS_HEADERS[status]!.color,
65
- count: sessions.length,
66
- })
59
+ for (const session of group.sessions) {
60
+ result.push({ type: 'session', session })
61
+ }
62
+ }
63
+ } else {
64
+ const groups = manager.groupByStatus()
65
+ const statusOrder: Array<keyof typeof STATUS_HEADERS> = [
66
+ 'working',
67
+ 'needs-input',
68
+ 'completed',
69
+ 'failed',
70
+ ]
71
+
72
+ for (const _status of statusOrder) {
73
+ const status = _status as SessionStatus
74
+ const sessions = groups[status] ?? []
75
+ result.push({
76
+ type: 'header',
77
+ key: `status-${status}`,
78
+ label: STATUS_HEADERS[status]!.label,
79
+ color: STATUS_HEADERS[status]!.color,
80
+ count: sessions.length,
81
+ })
67
82
 
68
- for (const session of sessions) {
69
- result.push({ type: 'session', session })
83
+ for (const session of sessions) {
84
+ result.push({ type: 'session', session })
85
+ }
70
86
  }
71
87
  }
72
88
 
73
89
  return result
74
- }, [manager])
90
+ }, [manager, groupBy])
75
91
 
76
92
  // Flatten sessions only for navigation (skip headers)
77
93
  const sessionsOnly = useMemo(
@@ -106,7 +122,7 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
106
122
  // Ctrl+T — toggle group by (status ↔ directory)
107
123
  if (key.ctrl && input === 't') {
108
124
  setGroupBy((prev) => (prev === 'status' ? 'directory' : 'status'))
109
- showFeedback(`Grouped by ${_groupBy === 'status' ? 'directory' : 'status'}`)
125
+ showFeedback(`Grouped by ${groupBy === 'status' ? 'directory' : 'status'}`)
110
126
  return
111
127
  }
112
128
 
@@ -238,7 +254,7 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
238
254
  {flatList.map((item, _flatIdx) => {
239
255
  if (item.type === 'header') {
240
256
  return (
241
- <Box key={`h-${item.status}`} marginY={1}>
257
+ <Box key={`h-${item.key}`} marginY={1}>
242
258
  <Text bold color={item.color}>
243
259
  {' '}
244
260
  {item.label} ({item.count})
@@ -175,7 +175,13 @@ function loadMcpJson(cwd: string): McpServerConfig[] {
175
175
  const parsed = JSON.parse(raw) as {
176
176
  mcpServers?: Record<
177
177
  string,
178
- { command: string; args?: string[]; env?: Record<string, string> }
178
+ {
179
+ command?: string
180
+ args?: string[]
181
+ url?: string
182
+ headers?: Record<string, string>
183
+ env?: Record<string, string>
184
+ }
179
185
  >
180
186
  }
181
187
 
@@ -187,6 +193,8 @@ function loadMcpJson(cwd: string): McpServerConfig[] {
187
193
  name,
188
194
  command: cfg.command,
189
195
  args: cfg.args || [],
196
+ url: cfg.url,
197
+ headers: cfg.headers,
190
198
  env: cfg.env,
191
199
  })
192
200
  }
@@ -16,6 +16,7 @@ import type { PatternAnalyzer, Pattern } from '../agent/pattern-analyzer.js'
16
16
  import type { ExperienceRuleEngine, ToolRule } from './rule-engine.js'
17
17
  import type { EffectivenessTracker } from '../agent/effectiveness-tracker.js'
18
18
  import type { ErrorSignatureDB } from './error-signature-db.js'
19
+ import type { CrsiProvenanceBridge } from '../agent/crsi-provenance-bridge.js'
19
20
  import { join } from 'node:path'
20
21
  import { homedir } from 'node:os'
21
22
 
@@ -88,6 +89,7 @@ export class AutoMemoryEngine {
88
89
  private ruleEngine?: ExperienceRuleEngine
89
90
  private effectivenessTracker?: EffectivenessTracker
90
91
  private errorSignatureDB?: ErrorSignatureDB
92
+ private provenanceBridge?: CrsiProvenanceBridge
91
93
 
92
94
  /** Accumulated reflections for the current session (not yet persisted to disk). */
93
95
  private sessionReflections: TurnReflection[] = []
@@ -122,6 +124,14 @@ export class AutoMemoryEngine {
122
124
  this.errorSignatureDB = db
123
125
  }
124
126
 
127
+ /**
128
+ * Wire the CRSI ↔ MegaSystem provenance bridge. When set, every
129
+ * auto-generated rule is recorded as an auditable decision in megasystem.
130
+ */
131
+ setProvenanceBridge(bridge: CrsiProvenanceBridge): void {
132
+ this.provenanceBridge = bridge
133
+ }
134
+
125
135
  // ── Core API ──
126
136
 
127
137
  /**
@@ -218,8 +228,10 @@ export class AutoMemoryEngine {
218
228
  this.writeSessionSummary()
219
229
  }
220
230
 
221
- // Flush CRSI state
231
+ // Flush CRSI state — evaluate first (reports verdicts to megasystem,
232
+ // applies auto-degrade/disable/upgrade), then persist.
222
233
  if (this.effectivenessTracker) {
234
+ this.effectivenessTracker.evaluate()
223
235
  this.effectivenessTracker.persist()
224
236
  }
225
237
  }
@@ -352,6 +364,23 @@ export class AutoMemoryEngine {
352
364
  const toolRule = this.patternAnalyzer.toToolRule(pattern)
353
365
  this.ruleEngine.register(toolRule)
354
366
 
367
+ // ── CRSI ↔ MegaSystem: record the rule as an auditable decision ──
368
+ if (this.provenanceBridge) {
369
+ const confidence = pattern.confidence === 'high' ? 0.9 : 0.6
370
+ this.provenanceBridge
371
+ .recordDecision(
372
+ `[${pattern.category}] ${toolRule.toolName} — ${pattern.examples?.[0] || ''}`,
373
+ `rule ${toolRule.id} — ${pattern.category} auto-fix`,
374
+ confidence,
375
+ )
376
+ .then((decisionId) => {
377
+ if (decisionId && this.effectivenessTracker) {
378
+ this.effectivenessTracker.setDecisionId(toolRule.id, decisionId)
379
+ }
380
+ })
381
+ .catch(() => {})
382
+ }
383
+
355
384
  // Track the new rule's effectiveness
356
385
  if (this.effectivenessTracker) {
357
386
  this.effectivenessTracker.recordApplication(toolRule.id, true)
@@ -13,7 +13,7 @@
13
13
  * Format: YAML with schema validation
14
14
  */
15
15
 
16
- import { readFileSync, existsSync } from 'node:fs'
16
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
17
17
  import { join } from 'node:path'
18
18
  import { homedir } from 'node:os'
19
19
 
@@ -154,7 +154,6 @@ export class ConstitutionLoader {
154
154
  // Write the default constitution to disk for visibility
155
155
  this.cached = DEFAULT_CONSTITUTION
156
156
  try {
157
- const { writeFileSync, mkdirSync } = require('node:fs')
158
157
  const dir = join(homedir(), '.mipham')
159
158
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
160
159
  writeFileSync(this.path, this.serializeToYaml(DEFAULT_CONSTITUTION), 'utf-8')
@@ -23,6 +23,8 @@ import { RulesLoader } from './rules-loader'
23
23
  import { ExperienceRuleEngine } from './rule-engine.js'
24
24
  import { PatternAnalyzer } from '../agent/pattern-analyzer.js'
25
25
  import { EffectivenessTracker } from '../agent/effectiveness-tracker.js'
26
+ import { CrsiProvenanceBridge } from '../agent/crsi-provenance-bridge.js'
27
+ import { McpClient } from '../mcp/client.js'
26
28
  import { ErrorSignatureDB } from './error-signature-db.js'
27
29
  import { PreFlightChecker } from './preflight-checker.js'
28
30
  import { AutoCorrector } from './auto-corrector.js'
@@ -375,6 +377,10 @@ export class QueryEngine {
375
377
  }
376
378
 
377
379
  async *process(userInput: string, signal?: AbortSignal): AsyncGenerator<StreamChunk> {
380
+ // v2.1.229 alignment: reject whitespace-only messages before they reach the
381
+ // provider API (which would otherwise 400 on an empty/blank message).
382
+ if (!userInput.trim()) return
383
+
378
384
  // Poll for cross-session messages before each turn
379
385
  await this.pollCrossSessionInbox()
380
386
 
@@ -1179,6 +1185,12 @@ export class QueryEngine {
1179
1185
  )
1180
1186
  // Wire SIS error signature DB for cross-session immunity
1181
1187
  this._autoMemory.setErrorSignatureDB(this.getErrorSignatureDB())
1188
+
1189
+ // Wire CRSI ↔ MegaSystem provenance bridge (degrades gracefully
1190
+ // when the mipham-kg MCP server is not connected).
1191
+ const bridge = new CrsiProvenanceBridge(McpClient.getInstance())
1192
+ this._autoMemory.setProvenanceBridge(bridge)
1193
+ this.getEffectivenessTracker().setProvenanceBridge(bridge)
1182
1194
  }
1183
1195
  return this._autoMemory
1184
1196
  }
@@ -9,6 +9,18 @@ import type { PermissionRuleEntry } from '../shared/index.ts'
9
9
  import { matchBashRule, compileRule } from './permission-rules'
10
10
  import { loadPermissionConfig, nextMode, clampMode, MODE_CYCLE } from './permission-config'
11
11
 
12
+ /**
13
+ * Check if a Bash command is a git/gh operation using a destructive flag
14
+ * (--force, --amend, --no-verify, --hard) that must never be auto-approved.
15
+ * v2.1.229 alignment: these rewrite history / force-push, so they fall back
16
+ * to explicit user confirmation even in auto mode.
17
+ */
18
+ function hasDangerousGitFlags(input: Record<string, unknown>): boolean {
19
+ const cmd = (input.command as string) || ''
20
+ if (!/^(git|gh)\s+/.test(cmd.trim())) return false
21
+ return /--force(?!-with-lease)\b|--amend\b|--no-verify\b|--hard\b/.test(cmd)
22
+ }
23
+
12
24
  /**
13
25
  * Check if a Bash command is a "verification-only" command that should be
14
26
  * auto-approved in acceptEdits mode. These are non-destructive read/check
@@ -414,6 +426,10 @@ export class PermissionSystem {
414
426
  if (this.isSubAgent && ['Bash', 'Write', 'Edit'].includes(tool.name)) {
415
427
  return 'ask'
416
428
  }
429
+ // v2.1.229 alignment: destructive git/gh flags are never auto-approved.
430
+ if (tool.name === 'Bash' && input && hasDangerousGitFlags(input)) {
431
+ return 'ask'
432
+ }
417
433
  return 'bypass'
418
434
 
419
435
  case 'dontAsk':
package/src/mcp/client.ts CHANGED
@@ -6,7 +6,8 @@ import type {
6
6
  ToolCallResult,
7
7
  InitializeResult,
8
8
  } from './types'
9
- import { StdioTransport } from './transport'
9
+ import { StdioTransport, type Transport } from './transport'
10
+ import { HttpTransport } from './http-transport'
10
11
  import { McpProtocol } from './protocol'
11
12
  import { OAuthClient } from './oauth'
12
13
  import { TokenStore } from './token-store'
@@ -23,7 +24,7 @@ const t = createT(bundles['en-US'] || (enUS as TranslationMap), enUS as Translat
23
24
 
24
25
  interface ActiveConnection {
25
26
  config: McpServerConfig
26
- transport: StdioTransport
27
+ transport: Transport
27
28
  protocol: McpProtocol
28
29
  status: ConnectionStatus
29
30
  tools: ToolDefinition[]
@@ -150,7 +151,9 @@ export class McpClient {
150
151
  const existing = this.connections.get(config.name)
151
152
  if (existing?.status === 'connected') return
152
153
 
153
- const transport = new StdioTransport()
154
+ const transport: StdioTransport | HttpTransport = config.url
155
+ ? new HttpTransport()
156
+ : new StdioTransport()
154
157
  const protocol = new McpProtocol(transport)
155
158
 
156
159
  const connection: ActiveConnection = {
@@ -164,11 +167,13 @@ export class McpClient {
164
167
  this.connections.set(config.name, connection)
165
168
 
166
169
  try {
167
- const initResult: InitializeResult = await protocol.initialize(
168
- config.command,
169
- config.args,
170
- config.env,
171
- )
170
+ // Start the transport (transport-specific), then perform the handshake.
171
+ if (transport instanceof HttpTransport) {
172
+ await transport.start(config.url ?? '', config.headers, config.env)
173
+ } else {
174
+ await transport.start(config.command ?? '', config.args ?? [], config.env)
175
+ }
176
+ const initResult: InitializeResult = await protocol.initialize()
172
177
 
173
178
  connection.status = 'connected'
174
179
  connection.serverInfo = initResult.serverInfo
@@ -229,6 +234,7 @@ export class McpClient {
229
234
  name: conn.config.name,
230
235
  command: conn.config.command,
231
236
  args: conn.config.args,
237
+ url: conn.config.url,
232
238
  },
233
239
  status: conn.status,
234
240
  tools: conn.tools,
@@ -243,6 +249,7 @@ export class McpClient {
243
249
  name: conn.config.name,
244
250
  command: conn.config.command,
245
251
  args: conn.config.args,
252
+ url: conn.config.url,
246
253
  },
247
254
  status: conn.status,
248
255
  tools: conn.tools,
@@ -0,0 +1,185 @@
1
+ import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification, JsonRpcError } from './types'
2
+ import type { Transport, NotificationHandler } from './transport'
3
+
4
+ type FetchFn = (input: string, init?: RequestInit) => Promise<Response>
5
+
6
+ const REQUEST_TIMEOUT_MS = 60_000
7
+
8
+ /**
9
+ * Merge multiple SSE `data:` events into a single JSON-RPC result.
10
+ *
11
+ * A Streamable HTTP server may answer a tool call as an SSE stream of several
12
+ * `data:` events, each carrying the same request id and a slice of the tool
13
+ * result text (this is how Forge's `/mcp` streams progressive chunks). We
14
+ * reassemble those slices so callers see one complete result.
15
+ */
16
+ function parseSseResponse(body: string): unknown {
17
+ const messages: Array<{ id?: number; result?: unknown; error?: JsonRpcError }> = []
18
+
19
+ for (const block of body.split(/\n\n/)) {
20
+ for (const line of block.split('\n')) {
21
+ if (!line.startsWith('data:')) continue
22
+ const payload = line.slice('data:'.length).trim()
23
+ if (!payload) continue
24
+ try {
25
+ messages.push(JSON.parse(payload))
26
+ } catch {
27
+ // Skip unparseable event lines
28
+ }
29
+ }
30
+ }
31
+
32
+ if (messages.length === 0) {
33
+ throw new Error('Empty SSE response')
34
+ }
35
+
36
+ // A single event → return its result (or throw its error) directly.
37
+ if (messages.length === 1) {
38
+ const message = messages[0]!
39
+ if (message.error) {
40
+ throw new Error(`MCP error ${message.error.code}: ${message.error.message}`)
41
+ }
42
+ return message.result
43
+ }
44
+
45
+ // Multiple events → chunked tool result; reassemble text content.
46
+ const texts: string[] = []
47
+ for (const message of messages) {
48
+ if (message.error) {
49
+ throw new Error(`MCP error ${message.error.code}: ${message.error.message}`)
50
+ }
51
+ const content = (message.result as { content?: Array<{ type: string; text?: string }> })
52
+ ?.content
53
+ if (content) {
54
+ for (const item of content) {
55
+ if (item.type === 'text' && item.text) texts.push(item.text)
56
+ }
57
+ }
58
+ }
59
+
60
+ return { content: [{ type: 'text', text: texts.join('') }], isError: false }
61
+ }
62
+
63
+ /**
64
+ * MCP Streamable HTTP transport — speaks JSON-RPC 2.0 to an HTTP endpoint
65
+ * (e.g. Forge's `POST /mcp`), returning plain JSON or reassembled SSE streams.
66
+ *
67
+ * Startup is via `start(url, headers?, env?)`; the `Transport` interface
68
+ * methods match `StdioTransport` so `McpProtocol` can use either.
69
+ */
70
+ export class HttpTransport implements Transport {
71
+ private url: string | null = null
72
+ private headers: Record<string, string> = {}
73
+ private msgId = 0
74
+ private closed = false
75
+ private notificationHandlers: NotificationHandler[] = []
76
+
77
+ constructor(private fetchImpl: FetchFn = fetch as unknown as FetchFn) {}
78
+
79
+ async start(
80
+ url: string,
81
+ headers?: Record<string, string>,
82
+ env?: Record<string, string>,
83
+ ): Promise<void> {
84
+ this.url = url
85
+ this.closed = false
86
+
87
+ // Build base headers: explicit headers override, then auto-derive a
88
+ // Bearer token from FORGE_API_KEY (mirrors Forge's verify_api_key) so the
89
+ // secret stays in env rather than inline in config.
90
+ this.headers = {
91
+ ...(env?.FORGE_API_KEY ? { Authorization: `Bearer ${env.FORGE_API_KEY}` } : {}),
92
+ ...(headers ?? {}),
93
+ }
94
+ }
95
+
96
+ async sendRequest(method: string, params?: Record<string, unknown>): Promise<unknown> {
97
+ if (!this.url || this.closed) {
98
+ throw new Error('Transport not connected')
99
+ }
100
+
101
+ const id = ++this.msgId
102
+ const request: JsonRpcRequest = {
103
+ jsonrpc: '2.0',
104
+ id,
105
+ method,
106
+ ...(params ? { params } : {}),
107
+ }
108
+
109
+ const controller = new AbortController()
110
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
111
+
112
+ try {
113
+ const response = await this.fetchImpl(this.url, {
114
+ method: 'POST',
115
+ headers: {
116
+ 'Content-Type': 'application/json',
117
+ Accept: 'application/json, text/event-stream',
118
+ ...this.headers,
119
+ },
120
+ body: JSON.stringify(request),
121
+ signal: controller.signal,
122
+ })
123
+
124
+ if (!response.ok) {
125
+ let detail = `HTTP ${response.status}`
126
+ try {
127
+ detail = JSON.stringify(await response.json())
128
+ } catch {
129
+ /* keep status-only detail */
130
+ }
131
+ throw new Error(`MCP HTTP error ${response.status}: ${detail}`)
132
+ }
133
+
134
+ const contentType = response.headers.get('content-type') || ''
135
+ if (contentType.includes('text/event-stream')) {
136
+ return parseSseResponse(await response.text())
137
+ }
138
+
139
+ const json = (await response.json()) as JsonRpcResponse
140
+ if (json.error) {
141
+ throw new Error(`MCP error ${json.error.code}: ${json.error.message}`)
142
+ }
143
+ return json.result
144
+ } finally {
145
+ clearTimeout(timer)
146
+ }
147
+ }
148
+
149
+ sendNotification(method: string, params?: Record<string, unknown>): void {
150
+ if (!this.url || this.closed) return
151
+
152
+ const notification: JsonRpcNotification = {
153
+ jsonrpc: '2.0',
154
+ method,
155
+ ...(params ? { params } : {}),
156
+ }
157
+
158
+ // Fire-and-forget: do not await the response.
159
+ void this.fetchImpl(this.url, {
160
+ method: 'POST',
161
+ headers: {
162
+ 'Content-Type': 'application/json',
163
+ Accept: 'application/json, text/event-stream',
164
+ ...this.headers,
165
+ },
166
+ body: JSON.stringify(notification),
167
+ }).catch(() => {
168
+ /* notifications are best-effort */
169
+ })
170
+ }
171
+
172
+ onNotification(handler: NotificationHandler): void {
173
+ this.notificationHandlers.push(handler)
174
+ }
175
+
176
+ async close(): Promise<void> {
177
+ this.closed = true
178
+ this.url = null
179
+ this.notificationHandlers = []
180
+ }
181
+
182
+ isConnected(): boolean {
183
+ return this.url !== null && !this.closed
184
+ }
185
+ }
@@ -5,7 +5,7 @@ import type {
5
5
  ResourceDefinition,
6
6
  ResourceReadResult,
7
7
  } from './types'
8
- import { StdioTransport } from './transport'
8
+ import type { Transport } from './transport'
9
9
 
10
10
  const MCP_VERSION = '2024-11-05'
11
11
 
@@ -20,7 +20,7 @@ export class McpProtocol {
20
20
  } = {}
21
21
  private handlers = new Map<string, Array<(...args: any[]) => void>>()
22
22
 
23
- constructor(private transport: StdioTransport) {}
23
+ constructor(private transport: Transport) {}
24
24
 
25
25
  on(event: string, handler: (...args: any[]) => void): void {
26
26
  const list = this.handlers.get(event) || []
@@ -33,14 +33,8 @@ export class McpProtocol {
33
33
  for (const h of list) h(...args)
34
34
  }
35
35
 
36
- async initialize(
37
- serverCommand: string,
38
- serverArgs: string[],
39
- env?: Record<string, string>,
40
- ): Promise<InitializeResult> {
41
- // Start transport
42
- await this.transport.start(serverCommand, serverArgs, env)
43
-
36
+ async initialize(): Promise<InitializeResult> {
37
+ // Transport must already be started by the caller (McpClient.connect).
44
38
  // Send initialize request
45
39
  const result = (await this.transport.sendRequest('initialize', {
46
40
  protocolVersion: MCP_VERSION,
@@ -1,7 +1,23 @@
1
1
  import { spawn, type ChildProcess } from 'node:child_process'
2
2
  import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './types'
3
3
 
4
- type NotificationHandler = (notification: JsonRpcNotification) => void
4
+ export type NotificationHandler = (notification: JsonRpcNotification) => void
5
+
6
+ /**
7
+ * Common transport contract shared by stdio and HTTP transports.
8
+ *
9
+ * `McpProtocol` depends on this interface (not a concrete transport), so it
10
+ * can speak JSON-RPC 2.0 over either a spawned subprocess or an HTTP endpoint.
11
+ * Transport startup is transport-specific and intentionally NOT part of this
12
+ * interface — callers invoke the concrete `start(...)` before use.
13
+ */
14
+ export interface Transport {
15
+ sendRequest(method: string, params?: Record<string, unknown>): Promise<unknown>
16
+ sendNotification(method: string, params?: Record<string, unknown>): void
17
+ onNotification(handler: NotificationHandler): void
18
+ close(): Promise<void>
19
+ isConnected(): boolean
20
+ }
5
21
 
6
22
  const REQUEST_TIMEOUT_MS = 60_000
7
23
 
@@ -52,7 +68,7 @@ function buildProcEnv(extra?: Record<string, string>): Record<string, string> {
52
68
  *
53
69
  * Uses Node.js child_process for portability across Bun and Node runtimes.
54
70
  */
55
- export class StdioTransport {
71
+ export class StdioTransport implements Transport {
56
72
  private proc: ChildProcess | null = null
57
73
  private msgId = 0
58
74
  private pending = new Map<
package/src/mcp/types.ts CHANGED
@@ -104,7 +104,7 @@ export interface ResourceReadResult {
104
104
  export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
105
105
 
106
106
  export interface ConnectionInfo {
107
- config: { name: string; command: string; args: string[] }
107
+ config: { name: string; command?: string; args?: string[]; url?: string }
108
108
  status: ConnectionStatus
109
109
  tools: ToolDefinition[]
110
110
  error?: string
@@ -329,38 +329,47 @@ export const DEFAULT_PROVIDERS: ProviderConfig[] = [
329
329
  id: 'mipham',
330
330
  name: 'MiphamAI',
331
331
  protocol: 'openai-compatible',
332
- baseUrl: 'https://api.mipham.ai/v1',
332
+ baseUrl: 'http://127.0.0.1:8080/v1', // Engine 本地端点(om-V5 生产部署待定)
333
333
  apiKey: '${MIPHAM_API_KEY}',
334
334
  models: [
335
335
  {
336
- id: 'om-V5-Flash',
336
+ id: 'om-v5-flash',
337
337
  name: 'OM V5 Flash',
338
338
  providerId: 'mipham',
339
339
  contextWindow: 1_000_000,
340
340
  maxOutput: 128_000,
341
341
  vision: false,
342
- status: 'upcoming',
342
+ status: 'active',
343
343
  },
344
344
  {
345
- id: 'om-V5-Pro',
345
+ id: 'om-v5-pro',
346
346
  name: 'OM V5 Pro',
347
347
  providerId: 'mipham',
348
348
  contextWindow: 1_000_000,
349
349
  maxOutput: 128_000,
350
350
  vision: false,
351
- status: 'upcoming',
351
+ status: 'active',
352
352
  },
353
353
  {
354
- id: 'om-V5-Visual',
354
+ id: 'om-v5-visual',
355
355
  name: 'OM V5 Visual',
356
356
  providerId: 'mipham',
357
357
  contextWindow: 200_000,
358
358
  maxOutput: 32_000,
359
359
  vision: true,
360
- status: 'upcoming',
360
+ status: 'active',
361
+ },
362
+ {
363
+ id: 'om-v5-apex',
364
+ name: 'OM V5 Apex',
365
+ providerId: 'mipham',
366
+ contextWindow: 1_000_000,
367
+ maxOutput: 128_000,
368
+ vision: false,
369
+ status: 'active',
361
370
  },
362
371
  ],
363
- status: 'upcoming',
372
+ status: 'active',
364
373
  },
365
374
  {
366
375
  id: 'ollama',
@@ -454,10 +463,10 @@ export const TOOL_CATEGORIES = ['file', 'exec', 'agent', 'network', 'system', 'a
454
463
 
455
464
  /** 预置 Ollama 模型 — 与 ollama list 结果合并去重后展示 */
456
465
  export const OLLAMA_PRESET_MODELS = [
457
- { id: 'om-V5-Flash', source: 'MiphamAI' },
458
- { id: 'om-V5-Pro', source: 'MiphamAI' },
459
- { id: 'om-V5-Visual', source: 'MiphamAI' },
460
- { id: 'om-V5-Apex', source: 'MiphamAI' },
466
+ { id: 'om-v5-flash', source: 'MiphamAI' },
467
+ { id: 'om-v5-pro', source: 'MiphamAI' },
468
+ { id: 'om-v5-visual', source: 'MiphamAI' },
469
+ { id: 'om-v5-apex', source: 'MiphamAI' },
461
470
  { id: 'deepseek-r1:70b', source: '热门' },
462
471
  { id: 'deepseek-v3', source: '热门' },
463
472
  { id: 'qwen2.5:72b', source: '热门' },
@@ -187,8 +187,14 @@ export interface CrsiConfig {
187
187
 
188
188
  export interface McpServerConfig {
189
189
  name: string
190
- command: string
191
- args: string[]
190
+ /** stdio: executable to spawn (mutually exclusive with `url`). */
191
+ command?: string
192
+ /** stdio: args passed to `command`. */
193
+ args?: string[]
194
+ /** HTTP: Streamable HTTP endpoint (mutually exclusive with `command`). */
195
+ url?: string
196
+ /** HTTP: extra request headers (e.g. Authorization). */
197
+ headers?: Record<string, string>
192
198
  env?: Record<string, string>
193
199
  auth?: {
194
200
  type: 'oauth'
@@ -3575,8 +3575,9 @@ const mcpCmd: CommandHandler = async (ctx, args) => {
3575
3575
  forwardToAI: `Connect to MCP server "${name}" using OAuth. Call McpClient.getInstance().connectWithOAuth() with the server config, then register its tools. Report the result.`,
3576
3576
  }
3577
3577
  }
3578
+ const via = config.url ? 'HTTP' : 'stdio'
3578
3579
  return {
3579
- content: `── MCP Connect: ${name} ──\n\nConnecting via stdio...`,
3580
+ content: `── MCP Connect: ${name} ──\n\nConnecting via ${via}...`,
3580
3581
  forwardToAI: `Connect to MCP server "${name}" using McpClient.getInstance().connect(config), then register its tools. Report the result.`,
3581
3582
  }
3582
3583
  }
@@ -3629,7 +3630,10 @@ const mcpCmd: CommandHandler = async (ctx, args) => {
3629
3630
  const statusLabel = live ? live.status : 'not started'
3630
3631
  const oauthTag = s.auth?.type === 'oauth' ? ' [OAuth]' : ''
3631
3632
  lines.push(` ${statusIcon} ${s.name}${oauthTag} [${statusLabel}]`)
3632
- lines.push(` Command: ${s.command} ${s.args.join(' ')}`)
3633
+ const transportInfo = s.url
3634
+ ? `URL: ${s.url}`
3635
+ : `Command: ${s.command ?? ''} ${(s.args ?? []).join(' ')}`
3636
+ lines.push(` ${transportInfo}`)
3633
3637
  if (live?.tools && live.tools.length > 0) {
3634
3638
  lines.push(` Tools: ${live.tools.length} registered`)
3635
3639
  }
@@ -1,11 +1,29 @@
1
+ import os from 'node:os'
2
+
1
3
  /**
2
4
  * parallel() — barrier: executes all thunks concurrently, waits for all.
3
5
  * Failed thunks resolve to null. Never throws.
4
6
  *
5
- * P0-2 (v2.1.223 alignment): Added concurrency cap (MAX_CONCURRENT = 16)
6
- * to prevent resource exhaustion from unbounded fan-out.
7
+ * P0-2 (v2.1.223 alignment): Added concurrency cap (MAX_CONCURRENT) to prevent
8
+ * resource exhaustion from unbounded fan-out.
9
+ *
10
+ * v2.1.229 alignment: detect parallelism via os.availableParallelism() so
11
+ * CPU-limited containers (cgroup quota) don't fan out to the host core count.
12
+ * Still capped at 16 to bound resource usage, with a floor of 1.
7
13
  */
8
- const MAX_CONCURRENT = 16
14
+ function detectParallelism(): number {
15
+ try {
16
+ if (typeof os.availableParallelism === 'function') {
17
+ const p = os.availableParallelism()
18
+ if (typeof p === 'number' && p > 0) return p
19
+ }
20
+ } catch {
21
+ /* fall through to cpus() */
22
+ }
23
+ return os.cpus().length || 1
24
+ }
25
+
26
+ const MAX_CONCURRENT = Math.max(1, Math.min(16, detectParallelism()))
9
27
 
10
28
  /**
11
29
  * Simple async semaphore for concurrency limiting.