@miphamai/cli 0.18.0 → 0.19.2
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 +2 -1
- package/src/core/context-compact.ts +1 -1
- package/src/core/context.ts +23 -3
- package/src/core/engine.ts +3 -3
- package/src/core/memory/memory-manager.ts +1 -1
- package/src/core/session-store.ts +62 -22
- package/src/core/tokenizer.ts +80 -0
- package/src/index.tsx +5 -1
- package/src/mcp/client.ts +94 -0
- package/src/mcp/oauth.ts +167 -0
- package/src/mcp/protocol.ts +19 -0
- package/src/mcp/token-store.ts +105 -0
- package/src/plugin/plugin-loader.ts +1 -2
- package/src/shared/types.ts +11 -18
- package/src/tools/exec/task.ts +1 -2
- package/src/tools/index.ts +1 -2
- package/src/ui/commands.ts +68 -36
- package/src/workflow/sandbox.ts +1 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miphamai/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.2",
|
|
4
4
|
"description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"commander": "^13.1.0",
|
|
46
46
|
"ink": "^5.2.1",
|
|
47
47
|
"ink-text-input": "^6.0.0",
|
|
48
|
+
"js-tiktoken": "^1.0.21",
|
|
48
49
|
"react": "^18.3.1",
|
|
49
50
|
"react-devtools-core": "^4.28.5",
|
|
50
51
|
"yaml": "^2.9.0"
|
|
@@ -43,7 +43,7 @@ export async function reactiveCompact(
|
|
|
43
43
|
// Rebuild messages: summary + recent
|
|
44
44
|
const summaryMsg = {
|
|
45
45
|
role: 'user' as const,
|
|
46
|
-
content: `[Earlier conversation summary — ${heading}]: ${summary.slice(0,
|
|
46
|
+
content: `[Earlier conversation summary — ${heading}]: ${summary.slice(0, 8000)}`,
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
context.replaceMessages([summaryMsg, ...toKeep])
|
package/src/core/context.ts
CHANGED
|
@@ -10,6 +10,7 @@ export type Summarizer = (messages: Message[], heading: string) => Promise<strin
|
|
|
10
10
|
interface ContextConfig {
|
|
11
11
|
maxTokens: number
|
|
12
12
|
compactionThreshold: number // e.g. 0.9 → compact at 90% usage
|
|
13
|
+
contextWindow?: number // model's declared context window (for adaptive thresholds)
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export interface CompactionStats {
|
|
@@ -53,11 +54,25 @@ export class ContextManager {
|
|
|
53
54
|
}
|
|
54
55
|
private compressionPending = false
|
|
55
56
|
|
|
56
|
-
constructor(private config: ContextConfig) {
|
|
57
|
+
constructor(private config: ContextConfig) {
|
|
58
|
+
// Adaptive thresholds: larger context → later compaction
|
|
59
|
+
// 200K → 0.90, 500K → 0.93, 1M → 0.95
|
|
60
|
+
if (config.contextWindow && config.contextWindow > 200_000) {
|
|
61
|
+
config.compactionThreshold = Math.max(0.9, 1 - 50000 / config.contextWindow)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getCompactionThreshold(): number {
|
|
66
|
+
return this.config.compactionThreshold
|
|
67
|
+
}
|
|
57
68
|
|
|
58
69
|
/** Dynamically update the max token limit (e.g., when switching models). */
|
|
59
|
-
updateMaxTokens(maxTokens: number): void {
|
|
70
|
+
updateMaxTokens(maxTokens: number, contextWindow?: number): void {
|
|
60
71
|
this.config.maxTokens = maxTokens
|
|
72
|
+
if (contextWindow) {
|
|
73
|
+
this.config.contextWindow = contextWindow
|
|
74
|
+
this.config.compactionThreshold = Math.max(0.9, 1 - 50000 / contextWindow)
|
|
75
|
+
}
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
getMaxTokens(): number {
|
|
@@ -286,7 +301,12 @@ export class ContextManager {
|
|
|
286
301
|
|
|
287
302
|
const usage = this.estimatedTokens / this.config.maxTokens
|
|
288
303
|
|
|
289
|
-
|
|
304
|
+
// Adaptive microcompact threshold: 200K→0.70, 500K→0.80, 1M→0.85
|
|
305
|
+
const microThreshold = this.config.contextWindow
|
|
306
|
+
? Math.max(0.7, 1 - 150000 / this.config.contextWindow)
|
|
307
|
+
: 0.7
|
|
308
|
+
|
|
309
|
+
if (usage > microThreshold) {
|
|
290
310
|
this.compressionPending = true
|
|
291
311
|
// Schedule microcompact asynchronously (fire-and-forget)
|
|
292
312
|
Promise.resolve().then(() => {
|
package/src/core/engine.ts
CHANGED
|
@@ -204,7 +204,7 @@ export class QueryEngine {
|
|
|
204
204
|
.map((m) => {
|
|
205
205
|
const role = m.role
|
|
206
206
|
const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content)
|
|
207
|
-
return `[${role}]: ${content.slice(0,
|
|
207
|
+
return `[${role}]: ${content.slice(0, 2000)}`
|
|
208
208
|
})
|
|
209
209
|
.join('\n')
|
|
210
210
|
|
|
@@ -220,7 +220,7 @@ export class QueryEngine {
|
|
|
220
220
|
{ role: 'system', content: summaryPrompt },
|
|
221
221
|
{ role: 'user', content: text },
|
|
222
222
|
],
|
|
223
|
-
maxTokens:
|
|
223
|
+
maxTokens: 2000,
|
|
224
224
|
})) {
|
|
225
225
|
if (chunk.type === 'text' && chunk.content) {
|
|
226
226
|
summary += chunk.content
|
|
@@ -232,7 +232,7 @@ export class QueryEngine {
|
|
|
232
232
|
// Return a minimal summary on failure
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
return summary.slice(0,
|
|
235
|
+
return summary.slice(0, 8000) || 'Prior conversation context omitted.'
|
|
236
236
|
})
|
|
237
237
|
}
|
|
238
238
|
|
|
@@ -175,7 +175,7 @@ export class MemoryManager {
|
|
|
175
175
|
this.updateIndex()
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
buildSystemReminder(context: string, maxTokens: number =
|
|
178
|
+
buildSystemReminder(context: string, maxTokens: number = 15000): string {
|
|
179
179
|
const relevant = this.recall(context, 10)
|
|
180
180
|
if (relevant.length === 0) return ''
|
|
181
181
|
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
unlinkSync,
|
|
7
7
|
renameSync,
|
|
8
8
|
existsSync,
|
|
9
|
+
statSync,
|
|
9
10
|
} from 'node:fs'
|
|
10
11
|
import { join } from 'node:path'
|
|
11
12
|
import type { Message } from '../shared/types'
|
|
@@ -84,7 +85,11 @@ export class SessionStore {
|
|
|
84
85
|
renameSync(tmp, path)
|
|
85
86
|
|
|
86
87
|
// Incremental index update — only touch this session's entry
|
|
87
|
-
|
|
88
|
+
try {
|
|
89
|
+
SessionStore.updateIndexEntry(name, session.metadata)
|
|
90
|
+
} catch {
|
|
91
|
+
// Index update is best-effort; .jsonl data is already safe
|
|
92
|
+
}
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
/**
|
|
@@ -233,25 +238,29 @@ export class SessionStore {
|
|
|
233
238
|
writeFileSync(summaryPath, `# ${name}\n\n${summary}\n\nTags: ${tags.join(', ')}\n`, 'utf-8')
|
|
234
239
|
|
|
235
240
|
// Update index entry — create minimal one if not present
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
entry
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
241
|
+
try {
|
|
242
|
+
const index = SessionStore.loadIndexRaw()
|
|
243
|
+
const entry = index.find((e) => e.name === name)
|
|
244
|
+
if (entry) {
|
|
245
|
+
entry.summary = summary
|
|
246
|
+
entry.tags = tags
|
|
247
|
+
} else {
|
|
248
|
+
index.push({
|
|
249
|
+
name,
|
|
250
|
+
createdAt: new Date().toISOString(),
|
|
251
|
+
updatedAt: new Date().toISOString(),
|
|
252
|
+
provider: 'unknown',
|
|
253
|
+
model: 'unknown',
|
|
254
|
+
messageCount: 0,
|
|
255
|
+
tokenCount: 0,
|
|
256
|
+
summary,
|
|
257
|
+
tags,
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), 'utf-8')
|
|
261
|
+
} catch {
|
|
262
|
+
// Index write is best-effort
|
|
253
263
|
}
|
|
254
|
-
writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), 'utf-8')
|
|
255
264
|
}
|
|
256
265
|
|
|
257
266
|
/**
|
|
@@ -259,9 +268,40 @@ export class SessionStore {
|
|
|
259
268
|
*/
|
|
260
269
|
static getLatest(): SessionIndexEntry | null {
|
|
261
270
|
const index = SessionStore.loadIndexRaw()
|
|
262
|
-
if (index.length
|
|
263
|
-
|
|
264
|
-
|
|
271
|
+
if (index.length > 0) {
|
|
272
|
+
index.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
|
273
|
+
return index[0]!
|
|
274
|
+
}
|
|
275
|
+
// Fallback: scan .jsonl files directly when index is missing
|
|
276
|
+
return SessionStore.scanLatestFromDisk()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Scan .jsonl files on disk by mtime — fallback when .index.json is missing. */
|
|
280
|
+
private static scanLatestFromDisk(): SessionIndexEntry | null {
|
|
281
|
+
ensureDir()
|
|
282
|
+
const files = readdirSync(SESSIONS_DIR).filter((f) => f.endsWith('.jsonl'))
|
|
283
|
+
if (files.length === 0) return null
|
|
284
|
+
let latest: { name: string; mtime: number } | null = null
|
|
285
|
+
for (const file of files) {
|
|
286
|
+
const stat = statSync(join(SESSIONS_DIR, file))
|
|
287
|
+
if (!latest || stat.mtimeMs > latest.mtime) {
|
|
288
|
+
latest = { name: file.replace('.jsonl', ''), mtime: stat.mtimeMs }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (!latest) return null
|
|
292
|
+
// Load the session to extract metadata
|
|
293
|
+
const session = SessionStore.load(latest.name)
|
|
294
|
+
if (!session) return null
|
|
295
|
+
return {
|
|
296
|
+
name: latest.name,
|
|
297
|
+
createdAt: session.metadata.createdAt,
|
|
298
|
+
updatedAt: session.metadata.updatedAt,
|
|
299
|
+
provider: session.metadata.provider,
|
|
300
|
+
model: session.metadata.model,
|
|
301
|
+
messageCount: session.metadata.messageCount,
|
|
302
|
+
tokenCount: 0,
|
|
303
|
+
cwd: session.metadata.cwd,
|
|
304
|
+
}
|
|
265
305
|
}
|
|
266
306
|
|
|
267
307
|
/**
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Tiktoken } from 'js-tiktoken'
|
|
2
|
+
import type { Message } from '../shared/types'
|
|
3
|
+
|
|
4
|
+
let encoder: Tiktoken | null = null
|
|
5
|
+
let initPromise: Promise<void> | null = null
|
|
6
|
+
|
|
7
|
+
async function getEncoder(): Promise<Tiktoken> {
|
|
8
|
+
if (encoder) return encoder
|
|
9
|
+
if (!initPromise) {
|
|
10
|
+
initPromise = (async () => {
|
|
11
|
+
const { getEncoding } = await import('js-tiktoken')
|
|
12
|
+
encoder = getEncoding('cl100k_base')
|
|
13
|
+
})()
|
|
14
|
+
}
|
|
15
|
+
await initPromise
|
|
16
|
+
return encoder!
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class TokenCounter {
|
|
20
|
+
private cache = new Map<string, number>()
|
|
21
|
+
private initialized = false
|
|
22
|
+
|
|
23
|
+
private async ensureInit(): Promise<void> {
|
|
24
|
+
if (!this.initialized) {
|
|
25
|
+
await getEncoder()
|
|
26
|
+
this.initialized = true
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async count(text: string): Promise<number> {
|
|
31
|
+
if (!text) return 0
|
|
32
|
+
const cached = this.cache.get(text)
|
|
33
|
+
if (cached !== undefined) return cached
|
|
34
|
+
await this.ensureInit()
|
|
35
|
+
const tokens = encoder!.encode(text).length
|
|
36
|
+
this.cache.set(text, tokens)
|
|
37
|
+
return tokens
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Synchronous fallback — chars/4 heuristic when WASM not loaded. */
|
|
41
|
+
countSync(text: string): number {
|
|
42
|
+
if (!text) return 0
|
|
43
|
+
const cached = this.cache.get(text)
|
|
44
|
+
if (cached !== undefined) return cached
|
|
45
|
+
return Math.ceil(text.length / 4)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async countMessages(messages: Message[]): Promise<number> {
|
|
49
|
+
let total = 0
|
|
50
|
+
for (const msg of messages) {
|
|
51
|
+
if (typeof msg.content === 'string') {
|
|
52
|
+
total += await this.count(msg.content)
|
|
53
|
+
} else if (Array.isArray(msg.content)) {
|
|
54
|
+
for (const block of msg.content) {
|
|
55
|
+
if (block.type === 'text' && block.text) {
|
|
56
|
+
total += await this.count(block.text)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
total += 4 // per-message format overhead
|
|
61
|
+
}
|
|
62
|
+
return total
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async truncateToTokens(text: string, maxTokens: number): Promise<string> {
|
|
66
|
+
await this.ensureInit()
|
|
67
|
+
const tokens = encoder!.encode(text)
|
|
68
|
+
if (tokens.length <= maxTokens) return text
|
|
69
|
+
return text.slice(0, Math.floor(text.length * (maxTokens / tokens.length)))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
invalidateCache(): void {
|
|
73
|
+
this.cache.clear()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
static reset(): void {
|
|
77
|
+
encoder = null
|
|
78
|
+
initPromise = null
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -120,7 +120,11 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
120
120
|
)
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
const context = new ContextManager({
|
|
123
|
+
const context = new ContextManager({
|
|
124
|
+
maxTokens: contextMaxTokens,
|
|
125
|
+
compactionThreshold: 0.9,
|
|
126
|
+
contextWindow: modelContextWindow,
|
|
127
|
+
})
|
|
124
128
|
|
|
125
129
|
if (options.resume) {
|
|
126
130
|
const saved = SessionStore.load(options.resume)
|
package/src/mcp/client.ts
CHANGED
|
@@ -8,6 +8,8 @@ import type {
|
|
|
8
8
|
} from './types'
|
|
9
9
|
import { StdioTransport } from './transport'
|
|
10
10
|
import { McpProtocol } from './protocol'
|
|
11
|
+
import { OAuthClient } from './oauth'
|
|
12
|
+
import { TokenStore } from './token-store'
|
|
11
13
|
|
|
12
14
|
interface ActiveConnection {
|
|
13
15
|
config: McpServerConfig
|
|
@@ -32,6 +34,19 @@ interface ActiveConnection {
|
|
|
32
34
|
export class McpClient {
|
|
33
35
|
private static instance: McpClient | null = null
|
|
34
36
|
private connections = new Map<string, ActiveConnection>()
|
|
37
|
+
private _tokenStore: TokenStore | null = null
|
|
38
|
+
private _oauthClient: OAuthClient | null = null
|
|
39
|
+
private eventHandlers = new Map<string, Array<(...args: any[]) => void>>()
|
|
40
|
+
|
|
41
|
+
private get tokenStore(): TokenStore {
|
|
42
|
+
if (!this._tokenStore) this._tokenStore = new TokenStore()
|
|
43
|
+
return this._tokenStore
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private get oauthClient(): OAuthClient {
|
|
47
|
+
if (!this._oauthClient) this._oauthClient = new OAuthClient(this.tokenStore)
|
|
48
|
+
return this._oauthClient
|
|
49
|
+
}
|
|
35
50
|
|
|
36
51
|
/** Get or create the singleton instance. */
|
|
37
52
|
static getInstance(): McpClient {
|
|
@@ -41,6 +56,80 @@ export class McpClient {
|
|
|
41
56
|
return McpClient.instance
|
|
42
57
|
}
|
|
43
58
|
|
|
59
|
+
on(event: string, handler: (...args: any[]) => void): void {
|
|
60
|
+
const list = this.eventHandlers.get(event) || []
|
|
61
|
+
list.push(handler)
|
|
62
|
+
this.eventHandlers.set(event, list)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private emit(event: string, ...args: any[]): void {
|
|
66
|
+
const list = this.eventHandlers.get(event) || []
|
|
67
|
+
for (const h of list) h(...args)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Connect with OAuth PKCE flow — injects access token into env vars. */
|
|
71
|
+
async connectWithOAuth(config: McpServerConfig): Promise<void> {
|
|
72
|
+
const accessToken = await this.oauthClient.getValidAccessToken(config.name, config)
|
|
73
|
+
return this.connect({
|
|
74
|
+
...config,
|
|
75
|
+
env: { ...config.env, MCP_ACCESS_TOKEN: accessToken },
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Handle tools/list_changed notification — diff and re-register. */
|
|
80
|
+
async onToolsChanged(name: string): Promise<void> {
|
|
81
|
+
const connection = this.connections.get(name)
|
|
82
|
+
if (!connection || connection.status !== 'connected') return
|
|
83
|
+
|
|
84
|
+
const oldToolNames = new Set(connection.tools.map((t) => t.name))
|
|
85
|
+
const newTools = await connection.protocol.listTools()
|
|
86
|
+
const newToolNames = new Set(newTools.map((t) => t.name))
|
|
87
|
+
|
|
88
|
+
const added = newTools.filter((t) => !oldToolNames.has(t.name))
|
|
89
|
+
const removed = connection.tools.filter((t) => !newToolNames.has(t.name))
|
|
90
|
+
|
|
91
|
+
connection.tools = newTools
|
|
92
|
+
|
|
93
|
+
if (added.length > 0 || removed.length > 0) {
|
|
94
|
+
this.emit('tools-changed', name, added, removed)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Reconnect with exponential backoff (1s→2s→4s→…max 60s, 10 attempts). */
|
|
99
|
+
async reconnect(name: string): Promise<void> {
|
|
100
|
+
const connection = this.connections.get(name)
|
|
101
|
+
if (!connection) throw new Error(`No connection for "${name}"`)
|
|
102
|
+
|
|
103
|
+
const config = connection.config
|
|
104
|
+
let delay = 1000
|
|
105
|
+
const maxDelay = 60000
|
|
106
|
+
const maxAttempts = 10
|
|
107
|
+
|
|
108
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
109
|
+
try {
|
|
110
|
+
try {
|
|
111
|
+
connection.transport.close()
|
|
112
|
+
} catch {
|
|
113
|
+
/* ok */
|
|
114
|
+
}
|
|
115
|
+
this.connections.delete(name)
|
|
116
|
+
|
|
117
|
+
await this.connect(config)
|
|
118
|
+
this.emit('reconnected', name)
|
|
119
|
+
return
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (attempt === maxAttempts) {
|
|
122
|
+
connection.status = 'error'
|
|
123
|
+
connection.error = String(err)
|
|
124
|
+
this.emit('disconnected', name, err)
|
|
125
|
+
throw err
|
|
126
|
+
}
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
128
|
+
delay = Math.min(delay * 2, maxDelay)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
44
133
|
/** Reset the singleton (useful for testing). */
|
|
45
134
|
static resetInstance(): void {
|
|
46
135
|
McpClient.instance = null
|
|
@@ -74,6 +163,11 @@ export class McpClient {
|
|
|
74
163
|
connection.status = 'connected'
|
|
75
164
|
connection.serverInfo = initResult.serverInfo
|
|
76
165
|
|
|
166
|
+
// Wire tools-changed notification
|
|
167
|
+
protocol.on('tools-changed', async () => {
|
|
168
|
+
await this.onToolsChanged(config.name)
|
|
169
|
+
})
|
|
170
|
+
|
|
77
171
|
// Discover tools
|
|
78
172
|
if (initResult.capabilities.tools) {
|
|
79
173
|
connection.tools = await protocol.listTools()
|
package/src/mcp/oauth.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { randomBytes, createHash } from 'node:crypto'
|
|
2
|
+
import { createServer, Server } from 'node:http'
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
4
|
+
import type { McpServerConfig } from '../shared/types'
|
|
5
|
+
import { TokenStore } from './token-store'
|
|
6
|
+
|
|
7
|
+
interface TokenResponse {
|
|
8
|
+
accessToken: string
|
|
9
|
+
refreshToken?: string
|
|
10
|
+
expiresAt: string
|
|
11
|
+
scopes?: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function base64url(buf: Buffer): string {
|
|
15
|
+
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class OAuthClient {
|
|
19
|
+
constructor(private store: TokenStore) {}
|
|
20
|
+
|
|
21
|
+
generatePkcePair(): { codeVerifier: string; codeChallenge: string } {
|
|
22
|
+
const codeVerifier = base64url(randomBytes(64))
|
|
23
|
+
const hash = createHash('sha256').update(codeVerifier).digest()
|
|
24
|
+
const codeChallenge = base64url(hash)
|
|
25
|
+
return { codeVerifier, codeChallenge }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async executePkceFlow(config: McpServerConfig): Promise<TokenResponse> {
|
|
29
|
+
const auth = config.auth!
|
|
30
|
+
const port = auth.redirectPort || 19876
|
|
31
|
+
const { codeVerifier, codeChallenge } = this.generatePkcePair()
|
|
32
|
+
const state = base64url(randomBytes(32))
|
|
33
|
+
|
|
34
|
+
const code = await new Promise<string>((resolve, reject) => {
|
|
35
|
+
const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
36
|
+
const url = new URL(req.url || '/', `http://localhost:${port}`)
|
|
37
|
+
if (url.pathname === '/callback') {
|
|
38
|
+
const receivedCode = url.searchParams.get('code')
|
|
39
|
+
const receivedState = url.searchParams.get('state')
|
|
40
|
+
if (receivedState !== state) {
|
|
41
|
+
res.writeHead(400)
|
|
42
|
+
res.end('State mismatch')
|
|
43
|
+
reject(new Error('OAuth state mismatch — possible CSRF'))
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
if (!receivedCode) {
|
|
47
|
+
res.writeHead(400)
|
|
48
|
+
res.end('No code received')
|
|
49
|
+
reject(new Error('No authorization code received'))
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
res.writeHead(200, { 'Content-Type': 'text/html' })
|
|
53
|
+
res.end(
|
|
54
|
+
'<html><body><h1>Authenticated</h1><p>You may close this window.</p></body></html>',
|
|
55
|
+
)
|
|
56
|
+
server.close()
|
|
57
|
+
resolve(receivedCode)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
server.listen(port, () => {
|
|
61
|
+
const authUrl = new URL(auth.authorizationUrl)
|
|
62
|
+
authUrl.searchParams.set('response_type', 'code')
|
|
63
|
+
authUrl.searchParams.set('client_id', auth.clientId)
|
|
64
|
+
authUrl.searchParams.set('code_challenge', codeChallenge)
|
|
65
|
+
authUrl.searchParams.set('code_challenge_method', 'S256')
|
|
66
|
+
authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)
|
|
67
|
+
authUrl.searchParams.set('state', state)
|
|
68
|
+
if (auth.scopes?.length) {
|
|
69
|
+
authUrl.searchParams.set('scope', auth.scopes.join(' '))
|
|
70
|
+
}
|
|
71
|
+
const cmd =
|
|
72
|
+
process.platform === 'darwin'
|
|
73
|
+
? `open "${authUrl.toString()}"`
|
|
74
|
+
: process.platform === 'win32'
|
|
75
|
+
? `start "" "${authUrl.toString()}"`
|
|
76
|
+
: `xdg-open "${authUrl.toString()}"`
|
|
77
|
+
const { exec } = require('node:child_process')
|
|
78
|
+
exec(cmd, () => {
|
|
79
|
+
/* fire-and-forget */
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
setTimeout(() => {
|
|
83
|
+
server.close()
|
|
84
|
+
reject(new Error('OAuth flow timed out (5 minutes)'))
|
|
85
|
+
}, 300_000)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const tokenResponse = await fetch(auth.tokenUrl, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
91
|
+
body: new URLSearchParams({
|
|
92
|
+
grant_type: 'authorization_code',
|
|
93
|
+
code,
|
|
94
|
+
code_verifier: codeVerifier,
|
|
95
|
+
redirect_uri: `http://localhost:${port}/callback`,
|
|
96
|
+
client_id: auth.clientId,
|
|
97
|
+
}).toString(),
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
if (!tokenResponse.ok) {
|
|
101
|
+
const body = await tokenResponse.text()
|
|
102
|
+
throw new Error(`Token exchange failed: ${tokenResponse.status} ${body}`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const data = (await tokenResponse.json()) as {
|
|
106
|
+
access_token: string
|
|
107
|
+
refresh_token?: string
|
|
108
|
+
expires_in?: number
|
|
109
|
+
scope?: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const result: TokenResponse = {
|
|
113
|
+
accessToken: data.access_token,
|
|
114
|
+
refreshToken: data.refresh_token,
|
|
115
|
+
expiresAt: new Date(Date.now() + (data.expires_in || 3600) * 1000).toISOString(),
|
|
116
|
+
scopes: data.scope?.split(' '),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.store.save(config.name, result)
|
|
120
|
+
return result
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async getValidAccessToken(serverName: string, config: McpServerConfig): Promise<string> {
|
|
124
|
+
const saved = this.store.load(serverName)
|
|
125
|
+
if (saved && new Date(saved.expiresAt).getTime() > Date.now() + 60000) {
|
|
126
|
+
return saved.accessToken
|
|
127
|
+
}
|
|
128
|
+
if (saved?.refreshToken) {
|
|
129
|
+
return this.refreshAccessToken(serverName, config)
|
|
130
|
+
}
|
|
131
|
+
const fresh = await this.executePkceFlow(config)
|
|
132
|
+
return fresh.accessToken
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async refreshAccessToken(serverName: string, config: McpServerConfig): Promise<string> {
|
|
136
|
+
const saved = this.store.load(serverName)
|
|
137
|
+
if (!saved?.refreshToken) {
|
|
138
|
+
throw new Error(`No refresh token available for "${serverName}"`)
|
|
139
|
+
}
|
|
140
|
+
const auth = config.auth!
|
|
141
|
+
const response = await fetch(auth.tokenUrl, {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
144
|
+
body: new URLSearchParams({
|
|
145
|
+
grant_type: 'refresh_token',
|
|
146
|
+
refresh_token: saved.refreshToken,
|
|
147
|
+
client_id: auth.clientId,
|
|
148
|
+
}).toString(),
|
|
149
|
+
})
|
|
150
|
+
if (!response.ok) {
|
|
151
|
+
this.store.delete(serverName)
|
|
152
|
+
const fresh = await this.executePkceFlow(config)
|
|
153
|
+
return fresh.accessToken
|
|
154
|
+
}
|
|
155
|
+
const data = (await response.json()) as {
|
|
156
|
+
access_token: string
|
|
157
|
+
refresh_token?: string
|
|
158
|
+
expires_in?: number
|
|
159
|
+
}
|
|
160
|
+
this.store.save(serverName, {
|
|
161
|
+
accessToken: data.access_token,
|
|
162
|
+
refreshToken: data.refresh_token || saved.refreshToken,
|
|
163
|
+
expiresAt: new Date(Date.now() + (data.expires_in || 3600) * 1000).toISOString(),
|
|
164
|
+
})
|
|
165
|
+
return data.access_token
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/mcp/protocol.ts
CHANGED
|
@@ -18,9 +18,21 @@ export class McpProtocol {
|
|
|
18
18
|
tools?: { listChanged?: boolean }
|
|
19
19
|
resources?: { subscribe?: boolean; listChanged?: boolean }
|
|
20
20
|
} = {}
|
|
21
|
+
private handlers = new Map<string, Array<(...args: any[]) => void>>()
|
|
21
22
|
|
|
22
23
|
constructor(private transport: StdioTransport) {}
|
|
23
24
|
|
|
25
|
+
on(event: string, handler: (...args: any[]) => void): void {
|
|
26
|
+
const list = this.handlers.get(event) || []
|
|
27
|
+
list.push(handler)
|
|
28
|
+
this.handlers.set(event, list)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private emit(event: string, ...args: any[]): void {
|
|
32
|
+
const list = this.handlers.get(event) || []
|
|
33
|
+
for (const h of list) h(...args)
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
async initialize(
|
|
25
37
|
serverCommand: string,
|
|
26
38
|
serverArgs: string[],
|
|
@@ -45,6 +57,13 @@ export class McpProtocol {
|
|
|
45
57
|
// Send initialized notification
|
|
46
58
|
this.transport.sendNotification('notifications/initialized')
|
|
47
59
|
|
|
60
|
+
// Register notification handler for tools/list_changed
|
|
61
|
+
this.transport.onNotification((notification) => {
|
|
62
|
+
if (notification.method === 'notifications/tools/list_changed') {
|
|
63
|
+
this.emit('tools-changed', notification.params)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
48
67
|
this.serverCapabilities = result.capabilities
|
|
49
68
|
|
|
50
69
|
return result
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
writeFileSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
chmodSync,
|
|
9
|
+
} from 'node:fs'
|
|
10
|
+
import { join, dirname } from 'node:path'
|
|
11
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
|
|
14
|
+
const ALGORITHM = 'aes-256-gcm'
|
|
15
|
+
const IV_LENGTH = 16
|
|
16
|
+
const AUTH_TAG_LENGTH = 16
|
|
17
|
+
const KEY_LENGTH = 32
|
|
18
|
+
|
|
19
|
+
interface TokenData {
|
|
20
|
+
accessToken: string
|
|
21
|
+
refreshToken?: string
|
|
22
|
+
expiresAt: string
|
|
23
|
+
createdAt?: string
|
|
24
|
+
scopes?: string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getEncryptionKey(keyPath: string): Buffer {
|
|
28
|
+
if (existsSync(keyPath)) {
|
|
29
|
+
return readFileSync(keyPath)
|
|
30
|
+
}
|
|
31
|
+
const key = randomBytes(KEY_LENGTH)
|
|
32
|
+
mkdirSync(dirname(keyPath), { recursive: true })
|
|
33
|
+
writeFileSync(keyPath, key)
|
|
34
|
+
chmodSync(keyPath, 0o400)
|
|
35
|
+
return key
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function encrypt(plaintext: string, key: Buffer): string {
|
|
39
|
+
const iv = randomBytes(IV_LENGTH)
|
|
40
|
+
const cipher = createCipheriv(ALGORITHM, key, iv)
|
|
41
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()])
|
|
42
|
+
const authTag = cipher.getAuthTag()
|
|
43
|
+
return Buffer.concat([iv, authTag, encrypted]).toString('base64')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function decrypt(ciphertext: string, key: Buffer): string {
|
|
47
|
+
const buf = Buffer.from(ciphertext, 'base64')
|
|
48
|
+
const iv = buf.subarray(0, IV_LENGTH)
|
|
49
|
+
const authTag = buf.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH)
|
|
50
|
+
const encrypted = buf.subarray(IV_LENGTH + AUTH_TAG_LENGTH)
|
|
51
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv)
|
|
52
|
+
decipher.setAuthTag(authTag)
|
|
53
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class TokenStore {
|
|
57
|
+
private key: Buffer
|
|
58
|
+
private storeDir: string
|
|
59
|
+
|
|
60
|
+
constructor(storeDir?: string) {
|
|
61
|
+
this.storeDir = storeDir || join(homedir(), '.mipham', 'mcp-tokens')
|
|
62
|
+
const keyPath = join(dirname(this.storeDir), '.mcp-key')
|
|
63
|
+
this.key = getEncryptionKey(keyPath)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
save(serverName: string, data: TokenData): void {
|
|
67
|
+
mkdirSync(this.storeDir, { recursive: true })
|
|
68
|
+
const filePath = join(this.storeDir, `${serverName}.enc`)
|
|
69
|
+
const json = JSON.stringify({
|
|
70
|
+
...data,
|
|
71
|
+
createdAt: data.createdAt || new Date().toISOString(),
|
|
72
|
+
})
|
|
73
|
+
const encrypted = encrypt(json, this.key)
|
|
74
|
+
writeFileSync(filePath, encrypted, { mode: 0o600 })
|
|
75
|
+
try {
|
|
76
|
+
chmodSync(filePath, 0o600)
|
|
77
|
+
} catch {
|
|
78
|
+
/* Windows — chmod is a no-op */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
load(serverName: string): TokenData | null {
|
|
83
|
+
const filePath = join(this.storeDir, `${serverName}.enc`)
|
|
84
|
+
if (!existsSync(filePath)) return null
|
|
85
|
+
try {
|
|
86
|
+
const encrypted = readFileSync(filePath, 'utf-8')
|
|
87
|
+
const json = decrypt(encrypted, this.key)
|
|
88
|
+
return JSON.parse(json) as TokenData
|
|
89
|
+
} catch {
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
delete(serverName: string): void {
|
|
95
|
+
const filePath = join(this.storeDir, `${serverName}.enc`)
|
|
96
|
+
if (existsSync(filePath)) unlinkSync(filePath)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
list(): string[] {
|
|
100
|
+
if (!existsSync(this.storeDir)) return []
|
|
101
|
+
return readdirSync(this.storeDir)
|
|
102
|
+
.filter((f) => f.endsWith('.enc'))
|
|
103
|
+
.map((f) => f.replace('.enc', ''))
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -99,8 +99,7 @@ export function loadPlugins(
|
|
|
99
99
|
for (const hookCfg of manifest.hooks as HookConfig[]) {
|
|
100
100
|
if (hookCfg.type && hookCfg) {
|
|
101
101
|
const event = (hookCfg as unknown as Record<string, unknown>).event as
|
|
102
|
-
|
|
|
103
|
-
| undefined
|
|
102
|
+
HookEvent | undefined
|
|
104
103
|
if (event) {
|
|
105
104
|
hookEvents.push(event)
|
|
106
105
|
hookEngine.register({
|
package/src/shared/types.ts
CHANGED
|
@@ -46,11 +46,7 @@ export interface ThinkingContent {
|
|
|
46
46
|
thinking: string
|
|
47
47
|
}
|
|
48
48
|
export type ContentBlock =
|
|
49
|
-
|
|
|
50
|
-
| ImageContent
|
|
51
|
-
| ToolUseContent
|
|
52
|
-
| ToolResultContent
|
|
53
|
-
| ThinkingContent
|
|
49
|
+
TextContent | ImageContent | ToolUseContent | ToolResultContent | ThinkingContent
|
|
54
50
|
|
|
55
51
|
export interface Message {
|
|
56
52
|
role: 'system' | 'user' | 'assistant'
|
|
@@ -62,13 +58,7 @@ export interface Message {
|
|
|
62
58
|
// ── Tool Types ──
|
|
63
59
|
export type ToolPermission = 'auto' | 'ask' | 'bypass'
|
|
64
60
|
export type ToolCategory =
|
|
65
|
-
| '
|
|
66
|
-
| 'exec'
|
|
67
|
-
| 'agent'
|
|
68
|
-
| 'network'
|
|
69
|
-
| 'system'
|
|
70
|
-
| 'artifact'
|
|
71
|
-
| 'scheduling'
|
|
61
|
+
'file' | 'exec' | 'agent' | 'network' | 'system' | 'artifact' | 'scheduling'
|
|
72
62
|
|
|
73
63
|
export interface ToolContext {
|
|
74
64
|
cwd: string
|
|
@@ -177,6 +167,14 @@ export interface McpServerConfig {
|
|
|
177
167
|
command: string
|
|
178
168
|
args: string[]
|
|
179
169
|
env?: Record<string, string>
|
|
170
|
+
auth?: {
|
|
171
|
+
type: 'oauth'
|
|
172
|
+
authorizationUrl: string
|
|
173
|
+
tokenUrl: string
|
|
174
|
+
clientId: string
|
|
175
|
+
scopes?: string[]
|
|
176
|
+
redirectPort?: number
|
|
177
|
+
}
|
|
180
178
|
}
|
|
181
179
|
|
|
182
180
|
// ── Skill Types ──
|
|
@@ -285,12 +283,7 @@ export interface InstructionFile {
|
|
|
285
283
|
// ── Permission Types ──
|
|
286
284
|
/** Six explicit permission modes matching Claude Code's permission architecture */
|
|
287
285
|
export type PermissionMode =
|
|
288
|
-
| '
|
|
289
|
-
| 'acceptEdits'
|
|
290
|
-
| 'plan'
|
|
291
|
-
| 'auto'
|
|
292
|
-
| 'dontAsk'
|
|
293
|
-
| 'bypassPermissions'
|
|
286
|
+
'default' | 'acceptEdits' | 'plan' | 'auto' | 'dontAsk' | 'bypassPermissions'
|
|
294
287
|
|
|
295
288
|
/** Backward-compatible alias: PermissionMode plus legacy 'ask' and 'bypass' */
|
|
296
289
|
export type PermissionLevel = PermissionMode | 'ask' | 'bypass'
|
package/src/tools/exec/task.ts
CHANGED
|
@@ -362,8 +362,7 @@ export const taskTool: ToolDefinition = {
|
|
|
362
362
|
// Signal to any background registry that this task should stop
|
|
363
363
|
// The background registry will be injected via the engine context
|
|
364
364
|
const bgRegistry = (_ctx as unknown as Record<string, unknown>).backgroundAgentRegistry as
|
|
365
|
-
|
|
366
|
-
| undefined
|
|
365
|
+
{ stop(id: string): boolean } | undefined
|
|
367
366
|
if (bgRegistry) {
|
|
368
367
|
bgRegistry.stop(taskId)
|
|
369
368
|
}
|
package/src/tools/index.ts
CHANGED
|
@@ -51,8 +51,7 @@ function validateParams(
|
|
|
51
51
|
|
|
52
52
|
// Check types for provided fields
|
|
53
53
|
const properties = schema.properties as
|
|
54
|
-
|
|
55
|
-
| undefined
|
|
54
|
+
Record<string, { type: string; enum?: string[] }> | undefined
|
|
56
55
|
if (properties) {
|
|
57
56
|
for (const [key, def] of Object.entries(properties)) {
|
|
58
57
|
const value = params[key]
|
package/src/ui/commands.ts
CHANGED
|
@@ -2493,9 +2493,67 @@ const deepResearchCmd: CommandHandler = (_ctx, args) => {
|
|
|
2493
2493
|
// Theme — display theme toggle
|
|
2494
2494
|
// ═══════════════════════════════════════════════════════════════
|
|
2495
2495
|
|
|
2496
|
-
const mcpCmd: CommandHandler = (ctx) => {
|
|
2496
|
+
const mcpCmd: CommandHandler = async (ctx, args) => {
|
|
2497
|
+
const client = McpClient.getInstance()
|
|
2498
|
+
const sub = args[0]?.toLowerCase()
|
|
2499
|
+
|
|
2500
|
+
// /mcp connect <name>
|
|
2501
|
+
if (sub === 'connect') {
|
|
2502
|
+
const name = args[1]
|
|
2503
|
+
if (!name) return { content: 'Usage: /mcp connect <server-name>' }
|
|
2504
|
+
const mcpServers = ctx.config.skills?.mcpServers ?? []
|
|
2505
|
+
const config = mcpServers.find((s) => s.name === name)
|
|
2506
|
+
if (!config) {
|
|
2507
|
+
return {
|
|
2508
|
+
content: `Server "${name}" not found in config.\n\nConfigured: ${mcpServers.map((s) => s.name).join(', ') || '(none)'}`,
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
if (config.auth?.type === 'oauth') {
|
|
2512
|
+
return {
|
|
2513
|
+
content: [
|
|
2514
|
+
`── MCP Connect: ${name} (OAuth) ──`,
|
|
2515
|
+
'',
|
|
2516
|
+
'Starting OAuth PKCE flow...',
|
|
2517
|
+
`Authorization: ${config.auth.authorizationUrl}`,
|
|
2518
|
+
`Scopes: ${config.auth.scopes?.join(', ') || '(default)'}`,
|
|
2519
|
+
].join('\n'),
|
|
2520
|
+
forwardToAI: `Connect to MCP server "${name}" using OAuth. Call McpClient.getInstance().connectWithOAuth() with the server config, then register its tools. Report the result.`,
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
return {
|
|
2524
|
+
content: `── MCP Connect: ${name} ──\n\nConnecting via stdio...`,
|
|
2525
|
+
forwardToAI: `Connect to MCP server "${name}" using McpClient.getInstance().connect(config), then register its tools. Report the result.`,
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
// /mcp disconnect <name>
|
|
2530
|
+
if (sub === 'disconnect') {
|
|
2531
|
+
const name = args[1]
|
|
2532
|
+
if (!name) return { content: 'Usage: /mcp disconnect <server-name>' }
|
|
2533
|
+
const tools = client.disconnect(name)
|
|
2534
|
+
return {
|
|
2535
|
+
content: [
|
|
2536
|
+
`── MCP Disconnect: ${name} ──`,
|
|
2537
|
+
'',
|
|
2538
|
+
tools.length > 0
|
|
2539
|
+
? `Disconnected. ${tools.length} tool(s) removed.`
|
|
2540
|
+
: 'Disconnected (no tools were registered).',
|
|
2541
|
+
].join('\n'),
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
// /mcp reload
|
|
2546
|
+
if (sub === 'reload') {
|
|
2547
|
+
return {
|
|
2548
|
+
content: '── MCP Reload ──\n\nDisconnecting all and reconnecting...',
|
|
2549
|
+
forwardToAI:
|
|
2550
|
+
'Disconnect all MCP servers via McpClient.getInstance().closeAll(), then reconnect all configured servers. Report each status.',
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
// /mcp (default status)
|
|
2497
2555
|
const configuredServers = ctx.config.skills?.mcpServers ?? []
|
|
2498
|
-
const liveConnections =
|
|
2556
|
+
const liveConnections = client.listConnections()
|
|
2499
2557
|
|
|
2500
2558
|
const lines: string[] = ['── MCP Servers ──', '']
|
|
2501
2559
|
|
|
@@ -2514,52 +2572,26 @@ const mcpCmd: CommandHandler = (ctx) => {
|
|
|
2514
2572
|
: '⚪'
|
|
2515
2573
|
: '⚪'
|
|
2516
2574
|
const statusLabel = live ? live.status : 'not started'
|
|
2517
|
-
const
|
|
2518
|
-
|
|
2519
|
-
lines.push(` ${statusIcon} ${s.name} [${statusLabel}]`)
|
|
2575
|
+
const oauthTag = s.auth?.type === 'oauth' ? ' [OAuth]' : ''
|
|
2576
|
+
lines.push(` ${statusIcon} ${s.name}${oauthTag} [${statusLabel}]`)
|
|
2520
2577
|
lines.push(` Command: ${s.command} ${s.args.join(' ')}`)
|
|
2521
|
-
lines.push(` Env vars: ${envKeys}`)
|
|
2522
2578
|
if (live?.tools && live.tools.length > 0) {
|
|
2523
|
-
lines.push(` Tools: ${live.tools.
|
|
2524
|
-
}
|
|
2525
|
-
if (live?.error) {
|
|
2526
|
-
lines.push(` Error: ${live.error}`)
|
|
2527
|
-
}
|
|
2528
|
-
if (live?.serverInfo) {
|
|
2529
|
-
lines.push(` Server: ${live.serverInfo.name} v${live.serverInfo.version}`)
|
|
2579
|
+
lines.push(` Tools: ${live.tools.length} registered`)
|
|
2530
2580
|
}
|
|
2581
|
+
if (live?.error) lines.push(` Error: ${live.error}`)
|
|
2531
2582
|
lines.push('')
|
|
2532
2583
|
}
|
|
2533
2584
|
} else {
|
|
2534
2585
|
lines.push('No MCP servers configured.')
|
|
2535
|
-
lines.push('')
|
|
2536
|
-
lines.push('── Configuration ──')
|
|
2537
|
-
lines.push('')
|
|
2538
|
-
lines.push('Add MCP servers to .mipham/config.yml:')
|
|
2539
|
-
lines.push('')
|
|
2540
|
-
lines.push(' skills:')
|
|
2541
|
-
lines.push(' mcpServers:')
|
|
2542
|
-
lines.push(' - name: filesystem')
|
|
2543
|
-
lines.push(' command: npx')
|
|
2544
|
-
lines.push(' args: ["-y", "@anthropic/mcp-filesystem", "/path"]')
|
|
2545
|
-
lines.push(' env:')
|
|
2546
|
-
lines.push(' HOME: $HOME')
|
|
2547
|
-
lines.push('')
|
|
2548
|
-
lines.push(' - name: github')
|
|
2549
|
-
lines.push(' command: npx')
|
|
2550
|
-
lines.push(' args: ["-y", "@anthropic/mcp-github"]')
|
|
2551
|
-
lines.push(' env:')
|
|
2552
|
-
lines.push(' GITHUB_TOKEN: $GITHUB_TOKEN')
|
|
2553
|
-
lines.push('')
|
|
2554
|
-
lines.push('After configuring, restart Mipham Code to connect.')
|
|
2555
|
-
lines.push('Use the MCP tool (Tool 16) to call server tools.')
|
|
2556
2586
|
}
|
|
2557
2587
|
|
|
2588
|
+
lines.push('── Commands ──')
|
|
2589
|
+
lines.push(' /mcp connect <name> Connect to a server (OAuth or stdio)')
|
|
2590
|
+
lines.push(' /mcp disconnect <name> Disconnect from a server')
|
|
2591
|
+
lines.push(' /mcp reload Disconnect all and reconnect')
|
|
2558
2592
|
lines.push('')
|
|
2559
2593
|
lines.push('── Protocol ──')
|
|
2560
2594
|
lines.push('MCP stdio transport (JSON-RPC 2.0) — fully implemented.')
|
|
2561
|
-
lines.push('Servers auto-connect on startup when configured.')
|
|
2562
|
-
lines.push('')
|
|
2563
2595
|
lines.push('Learn more: https://modelcontextprotocol.io')
|
|
2564
2596
|
|
|
2565
2597
|
return { content: lines.join('\n') }
|
package/src/workflow/sandbox.ts
CHANGED
|
@@ -66,8 +66,7 @@ export function createSandbox(
|
|
|
66
66
|
|
|
67
67
|
// Block crypto.randomUUID
|
|
68
68
|
const globalCrypto = (globalThis as Record<string, unknown>).crypto as
|
|
69
|
-
|
|
70
|
-
| undefined
|
|
69
|
+
{ randomUUID?: unknown; [key: string]: unknown } | undefined
|
|
71
70
|
if (globalCrypto) {
|
|
72
71
|
sandboxObj.crypto = new Proxy(globalCrypto, {
|
|
73
72
|
get(_target, prop) {
|