@miphamai/cli 0.3.0 → 0.4.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.
@@ -0,0 +1,343 @@
1
+ import { createServer, type Server } from 'node:http'
2
+ import { createReadStream, existsSync, statSync } from 'node:fs'
3
+ import { join, normalize, extname } from 'node:path'
4
+ import { ARTIFACT_ALLOWED_EXTENSIONS } from '../shared/constants'
5
+ import { readManifest } from './manifest'
6
+ import type { ArtifactEntry } from '../shared/types'
7
+
8
+ // ── SSE client tracking ──
9
+ interface SseClient {
10
+ id: number
11
+ res: any
12
+ }
13
+
14
+ /**
15
+ * Embedded HTTP server for serving artifact files (.html, .svg).
16
+ *
17
+ * Phase 2 additions:
18
+ * - Gallery page at / with dark-themed card layout
19
+ * - SSE /events endpoint for live reload
20
+ * - Versioned file serving (name.v2.html)
21
+ * - Port auto-increment on conflict
22
+ */
23
+ export class ArtifactServer {
24
+ private server: Server | null = null
25
+ private port: number
26
+ private artifactsDir: string
27
+ private started = false
28
+ private sseClients: SseClient[] = []
29
+ private sseIdCounter = 0
30
+
31
+ constructor(artifactsDir: string, preferredPort: number) {
32
+ this.artifactsDir = artifactsDir
33
+ this.port = preferredPort
34
+ }
35
+
36
+ /** Notify all connected SSE clients to reload. Called after artifact changes. */
37
+ notifyReload(): void {
38
+ for (const client of this.sseClients) {
39
+ try {
40
+ client.res.write('event: reload\ndata: {}\n\n')
41
+ } catch {
42
+ // Client disconnected — will be cleaned up on next request
43
+ }
44
+ }
45
+ }
46
+
47
+ async start(maxTries = 10): Promise<number> {
48
+ if (this.started) return this.port
49
+
50
+ for (let attempt = 0; attempt < maxTries; attempt++) {
51
+ const port = this.port + attempt
52
+ try {
53
+ await this.listenOn(port)
54
+ this.port = port
55
+ this.started = true
56
+ return port
57
+ } catch (err: unknown) {
58
+ if ((err as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw err
59
+ }
60
+ }
61
+
62
+ throw new Error(`Artifact server could not find an available port after ${maxTries} attempts.`)
63
+ }
64
+
65
+ stop(): void {
66
+ // Close all SSE connections
67
+ for (const client of this.sseClients) {
68
+ try {
69
+ client.res.end()
70
+ } catch {
71
+ /* ignore */
72
+ }
73
+ }
74
+ this.sseClients = []
75
+
76
+ if (this.server) {
77
+ this.server.close()
78
+ this.server = null
79
+ this.started = false
80
+ }
81
+ }
82
+
83
+ getPort(): number {
84
+ return this.port
85
+ }
86
+ isRunning(): boolean {
87
+ return this.started
88
+ }
89
+
90
+ resolveFile(urlPath: string): { filePath: string; error?: string; status?: number } {
91
+ const cleaned = urlPath.replace(/^\/+/, '')
92
+ const normalized = normalize(cleaned)
93
+ if (normalized.startsWith('..') || normalized.includes('/../')) {
94
+ return { filePath: '', error: 'Path traversal rejected', status: 403 }
95
+ }
96
+
97
+ const fullPath = join(this.artifactsDir, normalized)
98
+ const ext = extname(fullPath).toLowerCase()
99
+ if (!ARTIFACT_ALLOWED_EXTENSIONS.includes(ext)) {
100
+ return { filePath: '', error: `File type "${ext}" not served`, status: 403 }
101
+ }
102
+
103
+ return { filePath: fullPath }
104
+ }
105
+
106
+ // ── Private ──
107
+
108
+ private listenOn(port: number): Promise<void> {
109
+ return new Promise((resolve, reject) => {
110
+ const srv = createServer((req, res) => {
111
+ this.handleRequest(req, res)
112
+ })
113
+ srv.on('error', reject)
114
+ srv.listen(port, () => {
115
+ this.server = srv
116
+ resolve()
117
+ })
118
+ })
119
+ }
120
+
121
+ private handleRequest(req: { method?: string; url?: string }, res: any): void {
122
+ if (req.method !== 'GET') {
123
+ res.writeHead(405, { Allow: 'GET' })
124
+ res.end('Method Not Allowed')
125
+ return
126
+ }
127
+
128
+ const urlPath = req.url || '/'
129
+
130
+ // SSE endpoint
131
+ if (urlPath === '/events') {
132
+ this.handleSse(res)
133
+ return
134
+ }
135
+
136
+ // Gallery page
137
+ if (urlPath === '/' || urlPath === '/index.html') {
138
+ this.serveGallery(res)
139
+ return
140
+ }
141
+
142
+ // Artifact file
143
+ this.serveArtifact(urlPath, res)
144
+ }
145
+
146
+ // ── SSE ──
147
+
148
+ private handleSse(res: any): void {
149
+ res.writeHead(200, {
150
+ 'Content-Type': 'text/event-stream',
151
+ 'Cache-Control': 'no-cache',
152
+ Connection: 'keep-alive',
153
+ 'Access-Control-Allow-Origin': '*',
154
+ })
155
+
156
+ const client: SseClient = { id: ++this.sseIdCounter, res }
157
+ this.sseClients.push(client)
158
+
159
+ // Send initial heartbeat
160
+ res.write(':ok\n\n')
161
+
162
+ // Clean up on close
163
+ res.on('close', () => {
164
+ this.sseClients = this.sseClients.filter((c) => c.id !== client.id)
165
+ })
166
+ }
167
+
168
+ // ── Gallery Page ──
169
+
170
+ private serveGallery(res: any): void {
171
+ const manifest = readManifest(this.artifactsDir)
172
+ const artifacts = manifest.artifacts
173
+
174
+ const cards =
175
+ artifacts.length === 0
176
+ ? `<div style="grid-column: 1/-1; text-align: center; padding: 60px 20px; color: #666;">
177
+ <p style="font-size: 48px; margin: 0 0 16px 0;">🎨</p>
178
+ <p style="font-size: 16px;">No artifacts yet.</p>
179
+ <p style="font-size: 13px; color: #555;">Ask the AI to create one with the Artifact tool.</p>
180
+ </div>`
181
+ : artifacts.map((a) => this.artifactCard(a)).join('\n')
182
+
183
+ const html = `<!DOCTYPE html>
184
+ <html lang="en">
185
+ <head>
186
+ <meta charset="UTF-8">
187
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
188
+ <title>Mipham Code — Artifacts</title>
189
+ <style>
190
+ :root {
191
+ --bg: #0d1117; --surface: #161b22; --border: #30363d;
192
+ --text: #c9d1d9; --muted: #8b949e; --accent: #58a6ff;
193
+ --green: #3fb950; --orange: #d2991d; --purple: #a371f7;
194
+ }
195
+ * { box-sizing: border-box; margin: 0; padding: 0; }
196
+ body {
197
+ background: var(--bg); color: var(--text);
198
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
199
+ min-height: 100vh;
200
+ }
201
+ header {
202
+ background: var(--surface); border-bottom: 1px solid var(--border);
203
+ padding: 20px 32px; display: flex; align-items: center; justify-content: space-between;
204
+ }
205
+ header h1 { font-size: 20px; font-weight: 600; }
206
+ header .meta { font-size: 13px; color: var(--muted); }
207
+ .grid {
208
+ display: grid;
209
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
210
+ gap: 16px; padding: 24px 32px;
211
+ }
212
+ .card {
213
+ background: var(--surface); border: 1px solid var(--border);
214
+ border-radius: 8px; padding: 20px; transition: border-color .15s;
215
+ text-decoration: none; color: inherit; display: block;
216
+ }
217
+ .card:hover { border-color: var(--accent); }
218
+ .card .icon { font-size: 32px; margin-bottom: 12px; }
219
+ .card .name { font-size: 15px; font-weight: 600; margin-bottom: 8px; color: var(--accent); }
220
+ .card .row { font-size: 12px; color: var(--muted); margin-top: 4px; display: flex; gap: 12px; }
221
+ .card .badge {
222
+ display: inline-block; padding: 1px 6px; border-radius: 3px;
223
+ font-size: 11px; font-weight: 600;
224
+ }
225
+ .badge-html { background: #1f3a5f; color: var(--accent); }
226
+ .badge-svg { background: #2d1f4e; color: var(--purple); }
227
+ .badge-version { background: #2d2d1f; color: var(--orange); }
228
+ footer {
229
+ padding: 16px 32px; border-top: 1px solid var(--border);
230
+ font-size: 12px; color: var(--muted); display: flex; gap: 16px;
231
+ }
232
+ .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); display: inline-block; margin-right: 4px; }
233
+ </style>
234
+ </head>
235
+ <body>
236
+ <header>
237
+ <div>
238
+ <h1>🎨 Mipham Code Artifacts</h1>
239
+ <p style="font-size:13px;color:var(--muted);margin-top:4px;">
240
+ Interactive visual output from your AI sessions
241
+ </p>
242
+ </div>
243
+ <div class="meta">
244
+ <div><span class="dot"></span> Server running</div>
245
+ <div>Port ${this.port}</div>
246
+ </div>
247
+ </header>
248
+ <div class="grid">${cards}</div>
249
+ <footer>
250
+ <span>${artifacts.length} artifact${artifacts.length === 1 ? '' : 's'}</span>
251
+ <span>·</span>
252
+ <span>Open in terminal: <code>/artifact open &lt;name&gt;</code></span>
253
+ <span>·</span>
254
+ <span>Auto-refresh active</span>
255
+ </footer>
256
+ <script>
257
+ // SSE auto-refresh
258
+ const es = new EventSource('/events')
259
+ es.addEventListener('reload', () => location.reload())
260
+ </script>
261
+ </body>
262
+ </html>`
263
+
264
+ res.writeHead(200, {
265
+ 'Content-Type': 'text/html; charset=utf-8',
266
+ 'Cache-Control': 'no-cache',
267
+ })
268
+ res.end(html)
269
+ }
270
+
271
+ private artifactCard(a: ArtifactEntry): string {
272
+ const icon = a.type === 'svg' ? '🖼' : '📊'
273
+ const sizeStr =
274
+ a.size < 1024
275
+ ? `${a.size}B`
276
+ : a.size < 1_048_576
277
+ ? `${(a.size / 1024).toFixed(1)}KB`
278
+ : `${(a.size / 1_048_576).toFixed(1)}MB`
279
+ const date = a.createdAt.slice(0, 16).replace('T', ' ')
280
+ const versionInfo =
281
+ a.versions && a.versions.length > 1
282
+ ? `<span class="badge badge-version">${a.versions.length} versions</span>`
283
+ : ''
284
+
285
+ return `<a class="card" href="/${a.sessionId}/${a.name}.${a.type === 'svg' ? 'svg' : 'html'}">
286
+ <div class="icon">${icon}</div>
287
+ <div class="name">${this.escapeHtml(a.name)}</div>
288
+ <div class="row">
289
+ <span class="badge badge-${a.type}">${a.type.toUpperCase()}</span>
290
+ <span>${sizeStr}</span>
291
+ <span>${date}</span>
292
+ ${versionInfo}
293
+ </div>
294
+ <div class="row">
295
+ <span>session: ${a.sessionId}</span>
296
+ </div>
297
+ </a>`
298
+ }
299
+
300
+ // ── Artifact Serving ──
301
+
302
+ private serveArtifact(urlPath: string, res: any): void {
303
+ const { filePath, error, status } = this.resolveFile(urlPath)
304
+
305
+ if (error) {
306
+ res.writeHead(status || 403, { 'Content-Type': 'text/plain' })
307
+ res.end(error)
308
+ return
309
+ }
310
+
311
+ if (!existsSync(filePath)) {
312
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
313
+ res.end('Artifact not found')
314
+ return
315
+ }
316
+
317
+ const ext = extname(filePath).toLowerCase()
318
+ const mimeType = ext === '.svg' ? 'image/svg+xml' : 'text/html; charset=utf-8'
319
+ const size = statSync(filePath).size
320
+
321
+ res.writeHead(200, {
322
+ 'Content-Type': mimeType,
323
+ 'Content-Length': size,
324
+ 'Content-Security-Policy':
325
+ "default-src 'self'; style-src 'unsafe-inline'; script-src 'none'; img-src data: 'self';",
326
+ 'X-Content-Type-Options': 'nosniff',
327
+ 'Cache-Control': 'no-cache',
328
+ })
329
+
330
+ const stream = createReadStream(filePath)
331
+ stream.pipe(res)
332
+ stream.on('error', () => {
333
+ if (!res.headersSent) {
334
+ res.writeHead(500)
335
+ res.end('Stream error')
336
+ }
337
+ })
338
+ }
339
+
340
+ private escapeHtml(s: string): string {
341
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
342
+ }
343
+ }
@@ -1,11 +1,13 @@
1
- import type { Message, StreamChunk, ToolDefinition, ToolResult } from '../shared/index.ts'
1
+ import type { StreamChunk, ToolDefinition, ToolResult } from '../shared/index.ts'
2
2
  import { ProviderRegistry } from '../providers/registry'
3
3
  import { ContextManager } from './context'
4
4
  import { PermissionSystem } from './permission'
5
5
  import type { HookEngine } from './hooks'
6
+ import type { ArtifactServer } from '../artifacts/server'
6
7
 
7
8
  export class QueryEngine {
8
9
  private hookEngine?: HookEngine
10
+ private artifactServer?: ArtifactServer
9
11
 
10
12
  constructor(
11
13
  private registry: ProviderRegistry,
@@ -19,6 +21,11 @@ export class QueryEngine {
19
21
  this.hookEngine = hooks
20
22
  }
21
23
 
24
+ /** Register the artifact server for tool context. */
25
+ setArtifactServer(server: ArtifactServer): void {
26
+ this.artifactServer = server
27
+ }
28
+
22
29
  /** Wire LLM-based conversation summarization into the context manager. */
23
30
  setupContextSummarizer(): void {
24
31
  this.context.setSummarizer(async (messages, heading) => {
@@ -31,8 +38,7 @@ export class QueryEngine {
31
38
  .join('\n')
32
39
 
33
40
  // Build a minimal system prompt for summarization
34
- const summaryPrompt =
35
- `You are a conversation summarizer. Create a concise summary (1-3 paragraphs) of this conversation excerpt. Focus on: key topics discussed, decisions made, code changes mentioned, and open questions. Heading: ${heading}`
41
+ const summaryPrompt = `You are a conversation summarizer. Create a concise summary (1-3 paragraphs) of this conversation excerpt. Focus on: key topics discussed, decisions made, code changes mentioned, and open questions. Heading: ${heading}`
36
42
 
37
43
  // Collect full summary text from streaming response
38
44
  let summary = ''
@@ -81,39 +87,39 @@ export class QueryEngine {
81
87
 
82
88
  // Stream model response
83
89
  try {
84
- for await (const chunk of this.registry.chat({
85
- model: this.registry.getActiveModel(),
86
- messages,
87
- systemPrompt,
88
- tools: toolDefs.length > 0 ? toolDefs : undefined,
89
- signal,
90
- })) {
91
- yield chunk
92
-
93
- if (chunk.type === 'error') {
94
- this.context.addMessage({ role: 'assistant', content: `Error: ${chunk.error}` })
95
- return
96
- }
90
+ for await (const chunk of this.registry.chat({
91
+ model: this.registry.getActiveModel(),
92
+ messages,
93
+ systemPrompt,
94
+ tools: toolDefs.length > 0 ? toolDefs : undefined,
95
+ signal,
96
+ })) {
97
+ yield chunk
97
98
 
98
- if (chunk.type === 'text' && chunk.content) {
99
- assistantContent += chunk.content
100
- }
99
+ if (chunk.type === 'error') {
100
+ this.context.addMessage({ role: 'assistant', content: `Error: ${chunk.error}` })
101
+ return
102
+ }
101
103
 
102
- if (chunk.type === 'tool_use' && chunk.toolUse) {
103
- toolUses.push({
104
- id: chunk.toolUse.id,
105
- name: chunk.toolUse.name,
106
- input: chunk.toolUse.input,
107
- })
108
- }
104
+ if (chunk.type === 'text' && chunk.content) {
105
+ assistantContent += chunk.content
106
+ }
109
107
 
110
- if (chunk.type === 'stop') {
111
- // Add assistant response to context
112
- if (assistantContent) {
113
- this.context.addMessage({ role: 'assistant', content: assistantContent })
108
+ if (chunk.type === 'tool_use' && chunk.toolUse) {
109
+ toolUses.push({
110
+ id: chunk.toolUse.id,
111
+ name: chunk.toolUse.name,
112
+ input: chunk.toolUse.input,
113
+ })
114
+ }
115
+
116
+ if (chunk.type === 'stop') {
117
+ // Add assistant response to context
118
+ if (assistantContent) {
119
+ this.context.addMessage({ role: 'assistant', content: assistantContent })
120
+ }
114
121
  }
115
122
  }
116
- }
117
123
  } catch (err) {
118
124
  if (isAbortError(err)) {
119
125
  // User interrupted — keep partial content, stop gracefully
@@ -133,7 +139,7 @@ export class QueryEngine {
133
139
  yield {
134
140
  type: 'tool_result',
135
141
  tool_use_id: toolUse.id,
136
- content: result.success ? result.content : (result.error || result.content),
142
+ content: result.success ? result.content : result.error || result.content,
137
143
  }
138
144
 
139
145
  // Add tool use + result to context
@@ -165,26 +171,26 @@ export class QueryEngine {
165
171
  const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
166
172
 
167
173
  try {
168
- for await (const chunk of this.registry.chat({
169
- model: this.registry.getActiveModel(),
170
- messages,
171
- systemPrompt,
172
- tools: toolDefs.length > 0 ? toolDefs : undefined,
173
- signal,
174
- })) {
175
- yield chunk
174
+ for await (const chunk of this.registry.chat({
175
+ model: this.registry.getActiveModel(),
176
+ messages,
177
+ systemPrompt,
178
+ tools: toolDefs.length > 0 ? toolDefs : undefined,
179
+ signal,
180
+ })) {
181
+ yield chunk
176
182
 
177
- if (chunk.type === 'error') return
178
- if (chunk.type === 'text' && chunk.content) assistantContent += chunk.content
183
+ if (chunk.type === 'error') return
184
+ if (chunk.type === 'text' && chunk.content) assistantContent += chunk.content
179
185
 
180
- if (chunk.type === 'tool_use' && chunk.toolUse) {
181
- toolUses.push({
182
- id: chunk.toolUse.id,
183
- name: chunk.toolUse.name,
184
- input: chunk.toolUse.input,
185
- })
186
+ if (chunk.type === 'tool_use' && chunk.toolUse) {
187
+ toolUses.push({
188
+ id: chunk.toolUse.id,
189
+ name: chunk.toolUse.name,
190
+ input: chunk.toolUse.input,
191
+ })
192
+ }
186
193
  }
187
- }
188
194
  } catch (err) {
189
195
  if (isAbortError(err)) {
190
196
  if (assistantContent) {
@@ -243,11 +249,7 @@ export class QueryEngine {
243
249
  // Run PreToolUse hooks
244
250
  let effectiveParams = params
245
251
  if (this.hookEngine) {
246
- const preResult = await this.hookEngine.executePreToolUse(
247
- name,
248
- params,
249
- 'session-1',
250
- )
252
+ const preResult = await this.hookEngine.executePreToolUse(name, params, 'session-1')
251
253
  if (!preResult.allowed) {
252
254
  return {
253
255
  success: false,
@@ -266,6 +268,7 @@ export class QueryEngine {
266
268
  sessionId: 'session-1',
267
269
  provider: this.registry.getActive().config.id,
268
270
  model: this.registry.getActiveModel(),
271
+ artifactServer: this.artifactServer,
269
272
  })
270
273
 
271
274
  // Run PostToolUse hooks
@@ -303,6 +306,11 @@ export class QueryEngine {
303
306
 
304
307
  function isAbortError(err: unknown): boolean {
305
308
  if (err instanceof Error && err.name === 'AbortError') return true
306
- if (typeof DOMException !== 'undefined' && err instanceof DOMException && err.name === 'AbortError') return true
309
+ if (
310
+ typeof DOMException !== 'undefined' &&
311
+ err instanceof DOMException &&
312
+ err.name === 'AbortError'
313
+ )
314
+ return true
307
315
  return false
308
316
  }
@@ -1,4 +1,4 @@
1
- import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
1
+ import { readFileSync, existsSync, readdirSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { parse as parseYaml } from 'yaml'
4
4
  import type { InstructionFile } from '../shared/index.ts'
@@ -1,4 +1,9 @@
1
- import type { ToolDefinition, ToolPermission, PermissionLevel, PermissionRule } from '../shared/index.ts'
1
+ import type {
2
+ ToolDefinition,
3
+ ToolPermission,
4
+ PermissionLevel,
5
+ PermissionRule,
6
+ } from '../shared/index.ts'
2
7
 
3
8
  export class PermissionSystem {
4
9
  private rules = new Map<string, PermissionLevel>()
@@ -36,7 +41,7 @@ export class PermissionSystem {
36
41
  *
37
42
  * Fallback chain: exact rule → pattern rule → tool permission → system default
38
43
  */
39
- check(tool: ToolDefinition, input: Record<string, unknown>): PermissionLevel {
44
+ check(tool: ToolDefinition, _input: Record<string, unknown>): PermissionLevel {
40
45
  // 1. Exact-name rule wins
41
46
  const ruleLevel = this.rules.get(tool.name)
42
47
  if (ruleLevel) return ruleLevel
package/src/index.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ import { join } from 'node:path'
1
2
  import { render } from 'ink'
2
3
  import { App } from './ui/app'
3
4
  import { loadConfig } from './config/loader'
@@ -10,6 +11,8 @@ import { SkillsLoader } from './skills/loader'
10
11
  import { createToolRegistry } from './tools'
11
12
  import { McpClient } from './mcp/client'
12
13
  import { HookEngine } from './core/hooks'
14
+ import { ArtifactServer } from './artifacts/server'
15
+ import { ARTIFACTS_DIR, ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
13
16
 
14
17
  interface RunOptions {
15
18
  model?: string
@@ -83,16 +86,21 @@ export async function runApp(options: RunOptions): Promise<void> {
83
86
  }
84
87
  }
85
88
 
89
+ // Start artifact server (lazy — first artifact creation triggers listening)
90
+ const artifactsDir = join(process.cwd(), MIPHAM_DIR, ARTIFACTS_DIR)
91
+ const artifactServer = new ArtifactServer(artifactsDir, ARTIFACT_PORT)
92
+
86
93
  // Create query engine
87
94
  const engine = new QueryEngine(registry, context, tools)
88
95
  engine.setHookEngine(hookEngine)
96
+ engine.setArtifactServer(artifactServer)
89
97
  engine.setupContextSummarizer()
90
98
 
91
99
  // Auto-save session on exit
92
- let autoSaveName: string | undefined
93
100
  const saveAndExit = () => {
101
+ artifactServer.stop()
94
102
  if (context.getMessageCount() > 0) {
95
- autoSaveName = SessionStore.autoSave(context.getMessages(), {
103
+ SessionStore.autoSave(context.getMessages(), {
96
104
  provider: defaultProvider,
97
105
  model: defaultModel,
98
106
  })
@@ -1,11 +1,51 @@
1
1
  import { spawn, type ChildProcess } from 'node:child_process'
2
2
  import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './types'
3
3
 
4
- type ResponseHandler = (response: JsonRpcResponse) => void
5
4
  type NotificationHandler = (notification: JsonRpcNotification) => void
6
5
 
7
6
  const REQUEST_TIMEOUT_MS = 30_000
8
7
 
8
+ // ── Environment variable security: block sensitive vars from MCP subprocess ──
9
+ //
10
+ // By default, MCP servers receive a sanitised copy of the parent environment.
11
+ // Sensitive values (API keys, tokens, secrets, cloud credentials) are stripped
12
+ // to prevent data exfiltration by third-party MCP plugins.
13
+ //
14
+ // The `env` parameter on `start()` allows explicit overrides — use it to
15
+ // intentionally pass specific values to a trusted MCP server.
16
+ const SENSITIVE_ENV_PATTERNS = [
17
+ /_API_KEY$/i, // ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.
18
+ /_SECRET$/i, // STRIPE_SECRET, etc.
19
+ /_TOKEN$/i, // GITHUB_TOKEN, NPM_TOKEN, etc.
20
+ /_PASSWORD$/i, // DB_PASSWORD, etc.
21
+ /_CREDENTIALS?$/i, // GOOGLE_APPLICATION_CREDENTIALS, etc.
22
+ /^AWS_/, // AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, etc.
23
+ /^GCLOUD_/, // GCP credentials
24
+ /^AZURE_/, // Azure credentials
25
+ /^DOCKER_/, // DOCKER_TOKEN, DOCKER_PASSWORD
26
+ ]
27
+
28
+ /**
29
+ * Build a sanitised environment for MCP subprocesses.
30
+ * Strips sensitive vars; allows explicit overrides via `extra`.
31
+ */
32
+ function buildProcEnv(extra?: Record<string, string>): Record<string, string> {
33
+ const sanitized: Record<string, string> = {}
34
+
35
+ for (const [key, value] of Object.entries(process.env as Record<string, string>)) {
36
+ if (value === undefined) continue
37
+ if (SENSITIVE_ENV_PATTERNS.some((p) => p.test(key))) continue
38
+ sanitized[key] = value
39
+ }
40
+
41
+ // Explicit overrides take precedence (for intentionally shared secrets)
42
+ if (extra) {
43
+ Object.assign(sanitized, extra)
44
+ }
45
+
46
+ return sanitized
47
+ }
48
+
9
49
  /**
10
50
  * MCP stdio transport — spawns a subprocess and communicates via
11
51
  * newline-delimited JSON-RPC 2.0 messages on stdin/stdout.
@@ -30,10 +70,7 @@ export class StdioTransport {
30
70
  async start(command: string, args: string[], env?: Record<string, string>): Promise<void> {
31
71
  this.closed = false
32
72
 
33
- const procEnv: Record<string, string> = {
34
- ...(process.env as Record<string, string>),
35
- ...env,
36
- }
73
+ const procEnv = buildProcEnv(env)
37
74
 
38
75
  return new Promise((resolve, reject) => {
39
76
  const child = spawn(command, args, {
@@ -39,9 +39,7 @@ export async function fetchWithRetry(
39
39
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
40
40
  const controller = new AbortController()
41
41
  const timer = setTimeout(() => controller.abort(), timeout)
42
- const signal = init.signal
43
- ? anySignal([init.signal, controller.signal])
44
- : controller.signal
42
+ const signal = init.signal ? anySignal([init.signal, controller.signal]) : controller.signal
45
43
 
46
44
  try {
47
45
  const response = await fetch(url, { ...init, signal })