@miphamai/cli 0.17.0 → 0.19.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 +2 -1
- package/src/commands/keys.ts +94 -0
- package/src/config/keys-manager.ts +121 -0
- 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 +61 -22
- package/src/core/tokenizer.ts +80 -0
- package/src/index.tsx +18 -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/security/gate.ts +93 -0
- package/src/shared/types.ts +8 -0
- package/src/ui/commands.ts +81 -36
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miphamai/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
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"
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { CommandHandler } from '../ui/commands'
|
|
2
|
+
import { KeyManager } from '../config/keys-manager'
|
|
3
|
+
|
|
4
|
+
export const keysCmd: CommandHandler = async (_ctx, args) => {
|
|
5
|
+
const manager = new KeyManager()
|
|
6
|
+
const sub = args[0]?.toLowerCase()
|
|
7
|
+
|
|
8
|
+
// /keys rotate <provider>
|
|
9
|
+
if (sub === 'rotate') {
|
|
10
|
+
const provider = args[1]
|
|
11
|
+
if (!provider) {
|
|
12
|
+
return {
|
|
13
|
+
content: 'Usage: /keys rotate <provider>\n\nExample: /keys rotate deepseek',
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// Interactive: prompt user to provide new key via follow-up chat message
|
|
17
|
+
return {
|
|
18
|
+
content: [
|
|
19
|
+
'── Key Rotation ──',
|
|
20
|
+
'',
|
|
21
|
+
`Provider: ${provider}`,
|
|
22
|
+
'',
|
|
23
|
+
'To complete rotation, the new API key must be provided.',
|
|
24
|
+
'Type your new key in chat and the AI will handle rotation securely.',
|
|
25
|
+
'',
|
|
26
|
+
'The old key will be backed up to ~/.mipham/keys/<provider>.backup (chmod 600).',
|
|
27
|
+
].join('\n'),
|
|
28
|
+
forwardToAI: `The user wants to rotate the API key for provider "${provider}". Ask them to provide the new key value, then:
|
|
29
|
+
1. Call KeyManager.rotate("${provider}", newKey) with the provided key
|
|
30
|
+
2. The new key should be stored in ~/.mipham/config.json (or appropriate config)
|
|
31
|
+
3. Confirm rotation count and backup location
|
|
32
|
+
4. Remind them the old key is backed up at ~/.mipham/keys/${provider}.backup`,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// /keys audit
|
|
37
|
+
if (sub === 'audit') {
|
|
38
|
+
const expired = manager.audit()
|
|
39
|
+
if (expired.length === 0) {
|
|
40
|
+
return {
|
|
41
|
+
content:
|
|
42
|
+
'── Key Audit ──\n\n✅ All keys are within the 90-day rotation window.\n\nNo expired keys found.',
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const lines: string[] = [
|
|
47
|
+
'── Key Audit ──',
|
|
48
|
+
'',
|
|
49
|
+
`⚠️ ${expired.length} key(s) have exceeded the 90-day rotation threshold:`,
|
|
50
|
+
'',
|
|
51
|
+
...expired.map(
|
|
52
|
+
(k) =>
|
|
53
|
+
` 🔴 ${k.provider.padEnd(16)} ${k.ageDays} days since last rotation (${k.lastRotated.slice(0, 10)})`,
|
|
54
|
+
),
|
|
55
|
+
'',
|
|
56
|
+
'Rotate expired keys with: /keys rotate <provider>',
|
|
57
|
+
]
|
|
58
|
+
return { content: lines.join('\n') }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// /keys (list)
|
|
62
|
+
const keys = manager.list()
|
|
63
|
+
|
|
64
|
+
if (keys.length === 0) {
|
|
65
|
+
return {
|
|
66
|
+
content: [
|
|
67
|
+
'── API Keys ──',
|
|
68
|
+
'',
|
|
69
|
+
'No API keys tracked yet.',
|
|
70
|
+
'',
|
|
71
|
+
'Keys are tracked in ~/.mipham/keys.json when you configure providers.',
|
|
72
|
+
'Use /keys rotate <provider> to begin tracking rotation for a provider.',
|
|
73
|
+
].join('\n'),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const lines: string[] = [
|
|
78
|
+
'── API Keys ──',
|
|
79
|
+
'',
|
|
80
|
+
'Provider Age Rotations Status',
|
|
81
|
+
'─'.repeat(55),
|
|
82
|
+
...keys.map((k) => {
|
|
83
|
+
const status = k.expired ? '🔴 EXPIRED' : '🟢 OK'
|
|
84
|
+
return ` ${k.provider.padEnd(16)} ${String(k.ageDays).padStart(4)}d ${String(k.rotationCount).padStart(5)} ${status}`
|
|
85
|
+
}),
|
|
86
|
+
'',
|
|
87
|
+
`${keys.length} key(s) tracked.`,
|
|
88
|
+
'',
|
|
89
|
+
'Commands:',
|
|
90
|
+
' /keys rotate <provider> — rotate a key',
|
|
91
|
+
' /keys audit — check for expired keys',
|
|
92
|
+
]
|
|
93
|
+
return { content: lines.join('\n') }
|
|
94
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'
|
|
2
|
+
import { join, dirname } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
|
|
5
|
+
const MIPHAM_DIR = join(homedir(), '.mipham')
|
|
6
|
+
const KEYS_FILE = join(MIPHAM_DIR, 'keys.json')
|
|
7
|
+
|
|
8
|
+
export interface KeyEntry {
|
|
9
|
+
createdAt: string
|
|
10
|
+
lastRotated: string
|
|
11
|
+
rotationCount: number
|
|
12
|
+
provider: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface KeyStatus {
|
|
16
|
+
provider: string
|
|
17
|
+
createdAt: string
|
|
18
|
+
lastRotated: string
|
|
19
|
+
rotationCount: number
|
|
20
|
+
ageDays: number
|
|
21
|
+
expired: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface KeysData {
|
|
25
|
+
[provider: string]: KeyEntry
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loadKeys(): KeysData {
|
|
29
|
+
if (!existsSync(KEYS_FILE)) return {}
|
|
30
|
+
try {
|
|
31
|
+
const raw = readFileSync(KEYS_FILE, 'utf-8')
|
|
32
|
+
return JSON.parse(raw) as KeysData
|
|
33
|
+
} catch {
|
|
34
|
+
return {}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function saveKeys(data: KeysData): void {
|
|
39
|
+
mkdirSync(dirname(KEYS_FILE), { recursive: true })
|
|
40
|
+
const tmp = KEYS_FILE + '.tmp'
|
|
41
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
|
|
42
|
+
writeFileSync(KEYS_FILE, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
|
|
43
|
+
try {
|
|
44
|
+
chmodSync(KEYS_FILE, 0o600)
|
|
45
|
+
} catch {
|
|
46
|
+
// chmod on Windows is a no-op
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function daysSince(iso: string): number {
|
|
51
|
+
const then = new Date(iso).getTime()
|
|
52
|
+
const now = Date.now()
|
|
53
|
+
return Math.floor((now - then) / (1000 * 60 * 60 * 24))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class KeyManager {
|
|
57
|
+
list(): KeyStatus[] {
|
|
58
|
+
const data = loadKeys()
|
|
59
|
+
return Object.entries(data).map(([provider, entry]) => ({
|
|
60
|
+
provider,
|
|
61
|
+
createdAt: entry.createdAt,
|
|
62
|
+
lastRotated: entry.lastRotated,
|
|
63
|
+
rotationCount: entry.rotationCount,
|
|
64
|
+
ageDays: daysSince(entry.lastRotated),
|
|
65
|
+
expired: daysSince(entry.lastRotated) > 90,
|
|
66
|
+
}))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
rotate(provider: string, _newKey: string): { success: boolean; message: string } {
|
|
70
|
+
const data = loadKeys()
|
|
71
|
+
const now = new Date().toISOString()
|
|
72
|
+
const existing = data[provider]
|
|
73
|
+
|
|
74
|
+
// Backup old entry if it exists
|
|
75
|
+
if (existing) {
|
|
76
|
+
const backupDir = join(MIPHAM_DIR, 'keys')
|
|
77
|
+
mkdirSync(backupDir, { recursive: true })
|
|
78
|
+
const backupPath = join(backupDir, `${provider}.backup`)
|
|
79
|
+
writeFileSync(backupPath, JSON.stringify(existing, null, 2) + '\n', { mode: 0o600 })
|
|
80
|
+
try {
|
|
81
|
+
chmodSync(backupPath, 0o600)
|
|
82
|
+
} catch {
|
|
83
|
+
// Windows
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
data[provider] = {
|
|
87
|
+
createdAt: existing.createdAt,
|
|
88
|
+
lastRotated: now,
|
|
89
|
+
rotationCount: existing.rotationCount + 1,
|
|
90
|
+
provider,
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
data[provider] = {
|
|
94
|
+
createdAt: now,
|
|
95
|
+
lastRotated: now,
|
|
96
|
+
rotationCount: 1,
|
|
97
|
+
provider,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
saveKeys(data)
|
|
102
|
+
return {
|
|
103
|
+
success: true,
|
|
104
|
+
message: `Key for "${provider}" rotated (rotation #${data[provider]!.rotationCount}). Backup saved to ~/.mipham/keys/${provider}.backup`,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
audit(): KeyStatus[] {
|
|
109
|
+
return this.list().filter((k) => k.expired)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
getExpiryReminder(): string | null {
|
|
113
|
+
const expired = this.audit()
|
|
114
|
+
if (expired.length === 0) return null
|
|
115
|
+
|
|
116
|
+
const lines = expired.map(
|
|
117
|
+
(k) => ` - ${k.provider}: last rotated ${k.ageDays} days ago (90-day threshold exceeded)`,
|
|
118
|
+
)
|
|
119
|
+
return `⚠️ API key rotation overdue:\n${lines.join('\n')}\n\nRun /keys rotate <provider> to rotate.`
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -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
|
|
|
@@ -84,7 +84,11 @@ export class SessionStore {
|
|
|
84
84
|
renameSync(tmp, path)
|
|
85
85
|
|
|
86
86
|
// Incremental index update — only touch this session's entry
|
|
87
|
-
|
|
87
|
+
try {
|
|
88
|
+
SessionStore.updateIndexEntry(name, session.metadata)
|
|
89
|
+
} catch {
|
|
90
|
+
// Index update is best-effort; .jsonl data is already safe
|
|
91
|
+
}
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
/**
|
|
@@ -233,25 +237,29 @@ export class SessionStore {
|
|
|
233
237
|
writeFileSync(summaryPath, `# ${name}\n\n${summary}\n\nTags: ${tags.join(', ')}\n`, 'utf-8')
|
|
234
238
|
|
|
235
239
|
// 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
|
-
|
|
240
|
+
try {
|
|
241
|
+
const index = SessionStore.loadIndexRaw()
|
|
242
|
+
const entry = index.find((e) => e.name === name)
|
|
243
|
+
if (entry) {
|
|
244
|
+
entry.summary = summary
|
|
245
|
+
entry.tags = tags
|
|
246
|
+
} else {
|
|
247
|
+
index.push({
|
|
248
|
+
name,
|
|
249
|
+
createdAt: new Date().toISOString(),
|
|
250
|
+
updatedAt: new Date().toISOString(),
|
|
251
|
+
provider: 'unknown',
|
|
252
|
+
model: 'unknown',
|
|
253
|
+
messageCount: 0,
|
|
254
|
+
tokenCount: 0,
|
|
255
|
+
summary,
|
|
256
|
+
tags,
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), 'utf-8')
|
|
260
|
+
} catch {
|
|
261
|
+
// Index write is best-effort
|
|
253
262
|
}
|
|
254
|
-
writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), 'utf-8')
|
|
255
263
|
}
|
|
256
264
|
|
|
257
265
|
/**
|
|
@@ -259,9 +267,40 @@ export class SessionStore {
|
|
|
259
267
|
*/
|
|
260
268
|
static getLatest(): SessionIndexEntry | null {
|
|
261
269
|
const index = SessionStore.loadIndexRaw()
|
|
262
|
-
if (index.length
|
|
263
|
-
|
|
264
|
-
|
|
270
|
+
if (index.length > 0) {
|
|
271
|
+
index.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
|
272
|
+
return index[0]!
|
|
273
|
+
}
|
|
274
|
+
// Fallback: scan .jsonl files directly when index is missing
|
|
275
|
+
return SessionStore.scanLatestFromDisk()
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Scan .jsonl files on disk by mtime — fallback when .index.json is missing. */
|
|
279
|
+
private static scanLatestFromDisk(): SessionIndexEntry | null {
|
|
280
|
+
ensureDir()
|
|
281
|
+
const files = readdirSync(SESSIONS_DIR).filter((f) => f.endsWith('.jsonl'))
|
|
282
|
+
if (files.length === 0) return null
|
|
283
|
+
let latest: { name: string; mtime: number } | null = null
|
|
284
|
+
for (const file of files) {
|
|
285
|
+
const stat = statSync(join(SESSIONS_DIR, file))
|
|
286
|
+
if (!latest || stat.mtimeMs > latest.mtime) {
|
|
287
|
+
latest = { name: file.replace('.jsonl', ''), mtime: stat.mtimeMs }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (!latest) return null
|
|
291
|
+
// Load the session to extract metadata
|
|
292
|
+
const session = SessionStore.load(latest.name)
|
|
293
|
+
if (!session) return null
|
|
294
|
+
return {
|
|
295
|
+
name: latest.name,
|
|
296
|
+
createdAt: session.metadata.createdAt,
|
|
297
|
+
updatedAt: session.metadata.updatedAt,
|
|
298
|
+
provider: session.metadata.provider,
|
|
299
|
+
model: session.metadata.model,
|
|
300
|
+
messageCount: session.metadata.messageCount,
|
|
301
|
+
tokenCount: 0,
|
|
302
|
+
cwd: session.metadata.cwd,
|
|
303
|
+
}
|
|
265
304
|
}
|
|
266
305
|
|
|
267
306
|
/**
|
|
@@ -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,15 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
120
120
|
)
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
const
|
|
123
|
+
const useRealTokenizer = config.features?.context?.useRealTokenizer !== false
|
|
124
|
+
const adaptiveThresholds = config.features?.context?.adaptiveThresholds !== false
|
|
125
|
+
const effectiveContextWindow = adaptiveThresholds ? modelContextWindow : 200_000
|
|
126
|
+
|
|
127
|
+
const context = new ContextManager({
|
|
128
|
+
maxTokens: contextMaxTokens,
|
|
129
|
+
compactionThreshold: 0.9,
|
|
130
|
+
contextWindow: effectiveContextWindow,
|
|
131
|
+
})
|
|
124
132
|
|
|
125
133
|
if (options.resume) {
|
|
126
134
|
const saved = SessionStore.load(options.resume)
|
|
@@ -153,6 +161,15 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
153
161
|
if (latestSession?.summary) {
|
|
154
162
|
prompt = `${prompt}\n\n<system-context name="previous-session">\n# Previous Session Summary\n${latestSession.summary}\n</system-context>`
|
|
155
163
|
}
|
|
164
|
+
|
|
165
|
+
// Key expiry reminder (Task 7)
|
|
166
|
+
const { KeyManager } = await import('./config/keys-manager')
|
|
167
|
+
const keyManager = new KeyManager()
|
|
168
|
+
const keyReminder = keyManager.getExpiryReminder()
|
|
169
|
+
if (keyReminder) {
|
|
170
|
+
prompt = `${prompt}\n\n<system-reminder name="key-expiry">\n${keyReminder}\n</system-reminder>`
|
|
171
|
+
}
|
|
172
|
+
|
|
156
173
|
context.setSystemPrompt(prompt)
|
|
157
174
|
}
|
|
158
175
|
|
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
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface GateResult {
|
|
2
|
+
blocked: boolean
|
|
3
|
+
reason?: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const PROMPT_INJECTION_PATTERNS: Array<{ regex: RegExp; label: string }> = [
|
|
7
|
+
{
|
|
8
|
+
regex: /ignore\s+(all\s+)?(previous|prior|above)\s+(instructions?|prompts?)/i,
|
|
9
|
+
label: 'ignore-previous-instructions',
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
regex: /^system\s*:\s*(now\s+)?(act|pretend|you\s+are)/im,
|
|
13
|
+
label: 'role-impersonation',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
regex: /(^|\n)(---\s*BEGIN|<\|\w+\|>)/,
|
|
17
|
+
label: 'delimiter-injection',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
regex: /you\s+are\s+now\s+(dan|jailbroken|unrestricted)/i,
|
|
21
|
+
label: 'dan-jailbreak',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
regex:
|
|
25
|
+
/(disregard|override|supersede)\s+(all\s+)?(previous|prior|above|system)\s+(instructions?|rules?|prompts?)/i,
|
|
26
|
+
label: 'override-instructions',
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const DANGEROUS_BASH_PATTERNS: Array<{ regex: RegExp; label: string }> = [
|
|
31
|
+
{ regex: /\$\(.+\)/, label: 'command-substitution' },
|
|
32
|
+
{ regex: /`[^`]+`/, label: 'backtick-substitution' },
|
|
33
|
+
{ regex: /;\s*(rm|cat|sh|bash)\b/, label: 'command-chain-injection' },
|
|
34
|
+
{ regex: /curl.+\|\s*(sh|bash)\b/, label: 'curl-pipe-shell' },
|
|
35
|
+
{ regex: /\|\s*(sh|bash)\b/, label: 'pipe-to-shell' },
|
|
36
|
+
{ regex: />\s*\/dev\//, label: 'redirect-to-dev' },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
const API_KEY_PATTERNS: Array<{ regex: RegExp; label: string }> = [
|
|
40
|
+
{ regex: /sk-ant-[a-zA-Z0-9_-]{20,}/, label: 'anthropic-key' },
|
|
41
|
+
{ regex: /sk-[a-zA-Z0-9]{32,}/, label: 'openai-key' },
|
|
42
|
+
{ regex: /eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{10,}/, label: 'jwt-token' },
|
|
43
|
+
{ regex: /x-api-key:\s*[a-zA-Z0-9_-]{20,}/i, label: 'api-key-header' },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
export class SecurityGate {
|
|
47
|
+
static checkPromptInjection(input: string): GateResult {
|
|
48
|
+
if (!input || input.length < 10) return { blocked: false }
|
|
49
|
+
for (const pattern of PROMPT_INJECTION_PATTERNS) {
|
|
50
|
+
if (pattern.regex.test(input)) {
|
|
51
|
+
return { blocked: true, reason: `prompt injection detected: ${pattern.label}` }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { blocked: false }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static checkPathTraversal(path: string, _cwd: string): GateResult {
|
|
58
|
+
if (!path) return { blocked: false }
|
|
59
|
+
if (/\0/.test(path)) {
|
|
60
|
+
return { blocked: true, reason: 'null byte in path' }
|
|
61
|
+
}
|
|
62
|
+
if (/%25|%2e%2e/i.test(path)) {
|
|
63
|
+
return { blocked: true, reason: 'double-encoded path traversal' }
|
|
64
|
+
}
|
|
65
|
+
const segments = path.replace(/\\/g, '/').split('/')
|
|
66
|
+
for (const seg of segments) {
|
|
67
|
+
if (seg === '..') {
|
|
68
|
+
return { blocked: true, reason: 'path traversal attempt (..)' }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { blocked: false }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
static checkBashCommand(command: string): GateResult {
|
|
75
|
+
if (!command) return { blocked: false }
|
|
76
|
+
for (const pattern of DANGEROUS_BASH_PATTERNS) {
|
|
77
|
+
if (pattern.regex.test(command)) {
|
|
78
|
+
return { blocked: true, reason: `dangerous bash pattern: ${pattern.label}` }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { blocked: false }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
static checkCredentialLeak(output: string): GateResult {
|
|
85
|
+
if (!output || output.length < 10) return { blocked: false }
|
|
86
|
+
for (const pattern of API_KEY_PATTERNS) {
|
|
87
|
+
if (pattern.regex.test(output)) {
|
|
88
|
+
return { blocked: true, reason: `credential leak detected: ${pattern.label}` }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { blocked: false }
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/shared/types.ts
CHANGED
|
@@ -177,6 +177,14 @@ export interface McpServerConfig {
|
|
|
177
177
|
command: string
|
|
178
178
|
args: string[]
|
|
179
179
|
env?: Record<string, string>
|
|
180
|
+
auth?: {
|
|
181
|
+
type: 'oauth'
|
|
182
|
+
authorizationUrl: string
|
|
183
|
+
tokenUrl: string
|
|
184
|
+
clientId: string
|
|
185
|
+
scopes?: string[]
|
|
186
|
+
redirectPort?: number
|
|
187
|
+
}
|
|
180
188
|
}
|
|
181
189
|
|
|
182
190
|
// ── Skill Types ──
|
package/src/ui/commands.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from '../commands/project.js'
|
|
25
25
|
import { themeCmd, releaseNotesCmd, ideCmd, terminalSetupCmd } from '../commands/environment.js'
|
|
26
26
|
import { commitCmd, pushCmd, prCmd, issueCmd } from '../commands/git.js'
|
|
27
|
+
import { keysCmd } from '../commands/keys'
|
|
27
28
|
|
|
28
29
|
export interface CommandContext {
|
|
29
30
|
engine: QueryEngine
|
|
@@ -187,6 +188,9 @@ const helpCmd: CommandHandler = (ctx) => {
|
|
|
187
188
|
── Account ─────────────────────────
|
|
188
189
|
/login Show API key status
|
|
189
190
|
/logout Clear credentials guide
|
|
191
|
+
/keys List API key rotation status
|
|
192
|
+
/keys rotate Rotate an API key
|
|
193
|
+
/keys audit Check for expired keys
|
|
190
194
|
/feedback Send feedback
|
|
191
195
|
|
|
192
196
|
── Agents ──────────────────────────
|
|
@@ -2489,9 +2493,67 @@ const deepResearchCmd: CommandHandler = (_ctx, args) => {
|
|
|
2489
2493
|
// Theme — display theme toggle
|
|
2490
2494
|
// ═══════════════════════════════════════════════════════════════
|
|
2491
2495
|
|
|
2492
|
-
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)
|
|
2493
2555
|
const configuredServers = ctx.config.skills?.mcpServers ?? []
|
|
2494
|
-
const liveConnections =
|
|
2556
|
+
const liveConnections = client.listConnections()
|
|
2495
2557
|
|
|
2496
2558
|
const lines: string[] = ['── MCP Servers ──', '']
|
|
2497
2559
|
|
|
@@ -2510,52 +2572,26 @@ const mcpCmd: CommandHandler = (ctx) => {
|
|
|
2510
2572
|
: '⚪'
|
|
2511
2573
|
: '⚪'
|
|
2512
2574
|
const statusLabel = live ? live.status : 'not started'
|
|
2513
|
-
const
|
|
2514
|
-
|
|
2515
|
-
lines.push(` ${statusIcon} ${s.name} [${statusLabel}]`)
|
|
2575
|
+
const oauthTag = s.auth?.type === 'oauth' ? ' [OAuth]' : ''
|
|
2576
|
+
lines.push(` ${statusIcon} ${s.name}${oauthTag} [${statusLabel}]`)
|
|
2516
2577
|
lines.push(` Command: ${s.command} ${s.args.join(' ')}`)
|
|
2517
|
-
lines.push(` Env vars: ${envKeys}`)
|
|
2518
2578
|
if (live?.tools && live.tools.length > 0) {
|
|
2519
|
-
lines.push(` Tools: ${live.tools.
|
|
2520
|
-
}
|
|
2521
|
-
if (live?.error) {
|
|
2522
|
-
lines.push(` Error: ${live.error}`)
|
|
2523
|
-
}
|
|
2524
|
-
if (live?.serverInfo) {
|
|
2525
|
-
lines.push(` Server: ${live.serverInfo.name} v${live.serverInfo.version}`)
|
|
2579
|
+
lines.push(` Tools: ${live.tools.length} registered`)
|
|
2526
2580
|
}
|
|
2581
|
+
if (live?.error) lines.push(` Error: ${live.error}`)
|
|
2527
2582
|
lines.push('')
|
|
2528
2583
|
}
|
|
2529
2584
|
} else {
|
|
2530
2585
|
lines.push('No MCP servers configured.')
|
|
2531
|
-
lines.push('')
|
|
2532
|
-
lines.push('── Configuration ──')
|
|
2533
|
-
lines.push('')
|
|
2534
|
-
lines.push('Add MCP servers to .mipham/config.yml:')
|
|
2535
|
-
lines.push('')
|
|
2536
|
-
lines.push(' skills:')
|
|
2537
|
-
lines.push(' mcpServers:')
|
|
2538
|
-
lines.push(' - name: filesystem')
|
|
2539
|
-
lines.push(' command: npx')
|
|
2540
|
-
lines.push(' args: ["-y", "@anthropic/mcp-filesystem", "/path"]')
|
|
2541
|
-
lines.push(' env:')
|
|
2542
|
-
lines.push(' HOME: $HOME')
|
|
2543
|
-
lines.push('')
|
|
2544
|
-
lines.push(' - name: github')
|
|
2545
|
-
lines.push(' command: npx')
|
|
2546
|
-
lines.push(' args: ["-y", "@anthropic/mcp-github"]')
|
|
2547
|
-
lines.push(' env:')
|
|
2548
|
-
lines.push(' GITHUB_TOKEN: $GITHUB_TOKEN')
|
|
2549
|
-
lines.push('')
|
|
2550
|
-
lines.push('After configuring, restart Mipham Code to connect.')
|
|
2551
|
-
lines.push('Use the MCP tool (Tool 16) to call server tools.')
|
|
2552
2586
|
}
|
|
2553
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')
|
|
2554
2592
|
lines.push('')
|
|
2555
2593
|
lines.push('── Protocol ──')
|
|
2556
2594
|
lines.push('MCP stdio transport (JSON-RPC 2.0) — fully implemented.')
|
|
2557
|
-
lines.push('Servers auto-connect on startup when configured.')
|
|
2558
|
-
lines.push('')
|
|
2559
2595
|
lines.push('Learn more: https://modelcontextprotocol.io')
|
|
2560
2596
|
|
|
2561
2597
|
return { content: lines.join('\n') }
|
|
@@ -3122,6 +3158,9 @@ const commandsListCmd: CommandHandler = () => {
|
|
|
3122
3158
|
'/release-notes': 'Environment',
|
|
3123
3159
|
'/login': 'Account',
|
|
3124
3160
|
'/logout': 'Account',
|
|
3161
|
+
'/keys': 'Account',
|
|
3162
|
+
'/keys rotate': 'Account',
|
|
3163
|
+
'/keys audit': 'Account',
|
|
3125
3164
|
'/feedback': 'Account',
|
|
3126
3165
|
'/agents': 'Agents',
|
|
3127
3166
|
'/bg': 'Agents',
|
|
@@ -3273,6 +3312,9 @@ registry.set('/release-notes', releaseNotesCmd)
|
|
|
3273
3312
|
registry.set('/mcp', mcpCmd)
|
|
3274
3313
|
registry.set('/login', loginCmd)
|
|
3275
3314
|
registry.set('/logout', logoutCmd)
|
|
3315
|
+
registry.set('/keys', keysCmd)
|
|
3316
|
+
registry.set('/keys rotate', keysCmd)
|
|
3317
|
+
registry.set('/keys audit', keysCmd)
|
|
3276
3318
|
registry.set('/feedback', feedbackCmd)
|
|
3277
3319
|
registry.set('/agents', agentsCmd)
|
|
3278
3320
|
registry.set('/bg', bgCmd)
|
|
@@ -3399,6 +3441,9 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
|
|
|
3399
3441
|
'/upgrade': 'Show upgrade instructions',
|
|
3400
3442
|
'/login': 'Show API key status',
|
|
3401
3443
|
'/logout': 'Clear credentials guide',
|
|
3444
|
+
'/keys': 'List API key rotation status',
|
|
3445
|
+
'/keys rotate': 'Rotate an API key',
|
|
3446
|
+
'/keys audit': 'Check for expired keys',
|
|
3402
3447
|
'/feedback': 'Send feedback',
|
|
3403
3448
|
'/agents': 'Agent view dashboard',
|
|
3404
3449
|
'/bg': 'Run a background agent task',
|