@miphamai/cli 0.18.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.18.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"
@@ -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)
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
+ }
@@ -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 ──
@@ -2493,9 +2493,67 @@ const deepResearchCmd: CommandHandler = (_ctx, args) => {
2493
2493
  // Theme — display theme toggle
2494
2494
  // ═══════════════════════════════════════════════════════════════
2495
2495
 
2496
- const mcpCmd: CommandHandler = (ctx) => {
2496
+ const mcpCmd: CommandHandler = async (ctx, args) => {
2497
+ const client = McpClient.getInstance()
2498
+ const sub = args[0]?.toLowerCase()
2499
+
2500
+ // /mcp connect <name>
2501
+ if (sub === 'connect') {
2502
+ const name = args[1]
2503
+ if (!name) return { content: 'Usage: /mcp connect <server-name>' }
2504
+ const mcpServers = ctx.config.skills?.mcpServers ?? []
2505
+ const config = mcpServers.find((s) => s.name === name)
2506
+ if (!config) {
2507
+ return {
2508
+ content: `Server "${name}" not found in config.\n\nConfigured: ${mcpServers.map((s) => s.name).join(', ') || '(none)'}`,
2509
+ }
2510
+ }
2511
+ if (config.auth?.type === 'oauth') {
2512
+ return {
2513
+ content: [
2514
+ `── MCP Connect: ${name} (OAuth) ──`,
2515
+ '',
2516
+ 'Starting OAuth PKCE flow...',
2517
+ `Authorization: ${config.auth.authorizationUrl}`,
2518
+ `Scopes: ${config.auth.scopes?.join(', ') || '(default)'}`,
2519
+ ].join('\n'),
2520
+ forwardToAI: `Connect to MCP server "${name}" using OAuth. Call McpClient.getInstance().connectWithOAuth() with the server config, then register its tools. Report the result.`,
2521
+ }
2522
+ }
2523
+ return {
2524
+ content: `── MCP Connect: ${name} ──\n\nConnecting via stdio...`,
2525
+ forwardToAI: `Connect to MCP server "${name}" using McpClient.getInstance().connect(config), then register its tools. Report the result.`,
2526
+ }
2527
+ }
2528
+
2529
+ // /mcp disconnect <name>
2530
+ if (sub === 'disconnect') {
2531
+ const name = args[1]
2532
+ if (!name) return { content: 'Usage: /mcp disconnect <server-name>' }
2533
+ const tools = client.disconnect(name)
2534
+ return {
2535
+ content: [
2536
+ `── MCP Disconnect: ${name} ──`,
2537
+ '',
2538
+ tools.length > 0
2539
+ ? `Disconnected. ${tools.length} tool(s) removed.`
2540
+ : 'Disconnected (no tools were registered).',
2541
+ ].join('\n'),
2542
+ }
2543
+ }
2544
+
2545
+ // /mcp reload
2546
+ if (sub === 'reload') {
2547
+ return {
2548
+ content: '── MCP Reload ──\n\nDisconnecting all and reconnecting...',
2549
+ forwardToAI:
2550
+ 'Disconnect all MCP servers via McpClient.getInstance().closeAll(), then reconnect all configured servers. Report each status.',
2551
+ }
2552
+ }
2553
+
2554
+ // /mcp (default status)
2497
2555
  const configuredServers = ctx.config.skills?.mcpServers ?? []
2498
- const liveConnections = McpClient.getInstance().listConnections()
2556
+ const liveConnections = client.listConnections()
2499
2557
 
2500
2558
  const lines: string[] = ['── MCP Servers ──', '']
2501
2559
 
@@ -2514,52 +2572,26 @@ const mcpCmd: CommandHandler = (ctx) => {
2514
2572
  : '⚪'
2515
2573
  : '⚪'
2516
2574
  const statusLabel = live ? live.status : 'not started'
2517
- const envKeys = s.env ? Object.keys(s.env).join(', ') : '(none)'
2518
-
2519
- lines.push(` ${statusIcon} ${s.name} [${statusLabel}]`)
2575
+ const oauthTag = s.auth?.type === 'oauth' ? ' [OAuth]' : ''
2576
+ lines.push(` ${statusIcon} ${s.name}${oauthTag} [${statusLabel}]`)
2520
2577
  lines.push(` Command: ${s.command} ${s.args.join(' ')}`)
2521
- lines.push(` Env vars: ${envKeys}`)
2522
2578
  if (live?.tools && live.tools.length > 0) {
2523
- lines.push(` Tools: ${live.tools.map((t) => t.name).join(', ')}`)
2524
- }
2525
- if (live?.error) {
2526
- lines.push(` Error: ${live.error}`)
2527
- }
2528
- if (live?.serverInfo) {
2529
- lines.push(` Server: ${live.serverInfo.name} v${live.serverInfo.version}`)
2579
+ lines.push(` Tools: ${live.tools.length} registered`)
2530
2580
  }
2581
+ if (live?.error) lines.push(` Error: ${live.error}`)
2531
2582
  lines.push('')
2532
2583
  }
2533
2584
  } else {
2534
2585
  lines.push('No MCP servers configured.')
2535
- lines.push('')
2536
- lines.push('── Configuration ──')
2537
- lines.push('')
2538
- lines.push('Add MCP servers to .mipham/config.yml:')
2539
- lines.push('')
2540
- lines.push(' skills:')
2541
- lines.push(' mcpServers:')
2542
- lines.push(' - name: filesystem')
2543
- lines.push(' command: npx')
2544
- lines.push(' args: ["-y", "@anthropic/mcp-filesystem", "/path"]')
2545
- lines.push(' env:')
2546
- lines.push(' HOME: $HOME')
2547
- lines.push('')
2548
- lines.push(' - name: github')
2549
- lines.push(' command: npx')
2550
- lines.push(' args: ["-y", "@anthropic/mcp-github"]')
2551
- lines.push(' env:')
2552
- lines.push(' GITHUB_TOKEN: $GITHUB_TOKEN')
2553
- lines.push('')
2554
- lines.push('After configuring, restart Mipham Code to connect.')
2555
- lines.push('Use the MCP tool (Tool 16) to call server tools.')
2556
2586
  }
2557
2587
 
2588
+ lines.push('── Commands ──')
2589
+ lines.push(' /mcp connect <name> Connect to a server (OAuth or stdio)')
2590
+ lines.push(' /mcp disconnect <name> Disconnect from a server')
2591
+ lines.push(' /mcp reload Disconnect all and reconnect')
2558
2592
  lines.push('')
2559
2593
  lines.push('── Protocol ──')
2560
2594
  lines.push('MCP stdio transport (JSON-RPC 2.0) — fully implemented.')
2561
- lines.push('Servers auto-connect on startup when configured.')
2562
- lines.push('')
2563
2595
  lines.push('Learn more: https://modelcontextprotocol.io')
2564
2596
 
2565
2597
  return { content: lines.join('\n') }