@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.17.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, 2000)}`,
46
+ content: `[Earlier conversation summary — ${heading}]: ${summary.slice(0, 8000)}`,
47
47
  }
48
48
 
49
49
  context.replaceMessages([summaryMsg, ...toKeep])
@@ -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
- if (usage > 0.7) {
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(() => {
@@ -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, 500)}`
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: 300,
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, 2000) || 'Prior conversation context omitted.'
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 = 5000): string {
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
- SessionStore.updateIndexEntry(name, session.metadata)
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
- const index = SessionStore.loadIndexRaw()
237
- const entry = index.find((e) => e.name === name)
238
- if (entry) {
239
- entry.summary = summary
240
- entry.tags = tags
241
- } else {
242
- index.push({
243
- name,
244
- createdAt: new Date().toISOString(),
245
- updatedAt: new Date().toISOString(),
246
- provider: 'unknown',
247
- model: 'unknown',
248
- messageCount: 0,
249
- tokenCount: 0,
250
- summary,
251
- tags,
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 === 0) return null
263
- index.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
264
- return index[0]!
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 context = new ContextManager({ maxTokens: contextMaxTokens, compactionThreshold: 0.9 })
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()
@@ -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
+ }
@@ -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
+ }
@@ -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 ──
@@ -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 = McpClient.getInstance().listConnections()
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 envKeys = s.env ? Object.keys(s.env).join(', ') : '(none)'
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.map((t) => t.name).join(', ')}`)
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',