@miphamai/cli 0.34.1 → 0.36.0
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/crsi-provenance-bridge.ts +77 -0
- package/src/agent/effectiveness-tracker.ts +35 -0
- package/src/config/loader.ts +9 -1
- package/src/core/auto-memory.ts +30 -1
- package/src/core/constitution-loader.ts +1 -2
- package/src/core/engine.ts +8 -0
- package/src/mcp/client.ts +15 -8
- package/src/mcp/http-transport.ts +185 -0
- package/src/mcp/protocol.ts +4 -10
- package/src/mcp/transport.ts +18 -2
- package/src/mcp/types.ts +1 -1
- package/src/shared/constants.ts +21 -12
- package/src/shared/types.ts +8 -2
- package/src/ui/commands.ts +6 -2
package/package.json
CHANGED
|
@@ -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 })
|
package/src/config/loader.ts
CHANGED
|
@@ -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
|
-
{
|
|
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
|
}
|
package/src/core/auto-memory.ts
CHANGED
|
@@ -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')
|
package/src/core/engine.ts
CHANGED
|
@@ -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'
|
|
@@ -1179,6 +1181,12 @@ export class QueryEngine {
|
|
|
1179
1181
|
)
|
|
1180
1182
|
// Wire SIS error signature DB for cross-session immunity
|
|
1181
1183
|
this._autoMemory.setErrorSignatureDB(this.getErrorSignatureDB())
|
|
1184
|
+
|
|
1185
|
+
// Wire CRSI ↔ MegaSystem provenance bridge (degrades gracefully
|
|
1186
|
+
// when the mipham-kg MCP server is not connected).
|
|
1187
|
+
const bridge = new CrsiProvenanceBridge(McpClient.getInstance())
|
|
1188
|
+
this._autoMemory.setProvenanceBridge(bridge)
|
|
1189
|
+
this.getEffectivenessTracker().setProvenanceBridge(bridge)
|
|
1182
1190
|
}
|
|
1183
1191
|
return this._autoMemory
|
|
1184
1192
|
}
|
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:
|
|
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 =
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
config.
|
|
170
|
-
|
|
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
|
+
}
|
package/src/mcp/protocol.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
ResourceDefinition,
|
|
6
6
|
ResourceReadResult,
|
|
7
7
|
} from './types'
|
|
8
|
-
import {
|
|
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:
|
|
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
|
-
|
|
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,
|
package/src/mcp/transport.ts
CHANGED
|
@@ -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
|
|
107
|
+
config: { name: string; command?: string; args?: string[]; url?: string }
|
|
108
108
|
status: ConnectionStatus
|
|
109
109
|
tools: ToolDefinition[]
|
|
110
110
|
error?: string
|
package/src/shared/constants.ts
CHANGED
|
@@ -329,38 +329,47 @@ export const DEFAULT_PROVIDERS: ProviderConfig[] = [
|
|
|
329
329
|
id: 'mipham',
|
|
330
330
|
name: 'MiphamAI',
|
|
331
331
|
protocol: 'openai-compatible',
|
|
332
|
-
baseUrl: '
|
|
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-
|
|
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: '
|
|
342
|
+
status: 'active',
|
|
343
343
|
},
|
|
344
344
|
{
|
|
345
|
-
id: 'om-
|
|
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: '
|
|
351
|
+
status: 'active',
|
|
352
352
|
},
|
|
353
353
|
{
|
|
354
|
-
id: 'om-
|
|
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: '
|
|
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: '
|
|
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-
|
|
458
|
-
{ id: 'om-
|
|
459
|
-
{ id: 'om-
|
|
460
|
-
{ id: 'om-
|
|
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: '热门' },
|
package/src/shared/types.ts
CHANGED
|
@@ -187,8 +187,14 @@ export interface CrsiConfig {
|
|
|
187
187
|
|
|
188
188
|
export interface McpServerConfig {
|
|
189
189
|
name: string
|
|
190
|
-
|
|
191
|
-
|
|
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'
|
package/src/ui/commands.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|