@miphamai/cli 0.2.3

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.
Files changed (67) hide show
  1. package/README.md +76 -0
  2. package/assets/icon.icns +0 -0
  3. package/assets/icon.jpg +0 -0
  4. package/bin/mipham +51 -0
  5. package/bin/mipham.ts +8 -0
  6. package/package.json +59 -0
  7. package/skills/mipham/om-model-optimize.mipham-skill.md +20 -0
  8. package/skills/mipham/om-security.mipham-skill.md +21 -0
  9. package/skills/standard/code-review.SKILL.md +116 -0
  10. package/skills/standard/compassionate-communication.SKILL.md +80 -0
  11. package/skills/standard/doc-generator.SKILL.md +21 -0
  12. package/skills/standard/github-ops.SKILL.md +22 -0
  13. package/skills/standard/memory.SKILL.md +22 -0
  14. package/skills/standard/mipham-code-setup.SKILL.md +113 -0
  15. package/skills/standard/security-review.SKILL.md +122 -0
  16. package/skills/standard/self-review.SKILL.md +20 -0
  17. package/skills/standard/superpower.SKILL.md +19 -0
  18. package/skills/standard/tdd.SKILL.md +20 -0
  19. package/skills/standard/web-search.SKILL.md +23 -0
  20. package/src/agent/sub-agent.ts +122 -0
  21. package/src/config/defaults.ts +10 -0
  22. package/src/config/loader.ts +30 -0
  23. package/src/core/context.ts +135 -0
  24. package/src/core/engine.ts +193 -0
  25. package/src/core/hooks.ts +116 -0
  26. package/src/core/instructions.ts +126 -0
  27. package/src/core/permission.ts +54 -0
  28. package/src/core/session-store.ts +136 -0
  29. package/src/index.tsx +93 -0
  30. package/src/mcp/client.ts +170 -0
  31. package/src/mcp/protocol.ts +104 -0
  32. package/src/mcp/transport.ts +169 -0
  33. package/src/mcp/types.ts +112 -0
  34. package/src/providers/anthropic.ts +286 -0
  35. package/src/providers/bootstrap.ts +28 -0
  36. package/src/providers/openai-compat.ts +158 -0
  37. package/src/providers/registry.ts +70 -0
  38. package/src/security/path.ts +90 -0
  39. package/src/security/url.ts +96 -0
  40. package/src/shared/constants.ts +368 -0
  41. package/src/shared/index.ts +2 -0
  42. package/src/shared/types.ts +161 -0
  43. package/src/skills/loader.ts +109 -0
  44. package/src/skills/mipham/runtime.ts +63 -0
  45. package/src/skills/standard/runtime.ts +59 -0
  46. package/src/tools/agent/agent.ts +51 -0
  47. package/src/tools/agent/memory.ts +51 -0
  48. package/src/tools/agent/plan.ts +87 -0
  49. package/src/tools/agent/skill.ts +58 -0
  50. package/src/tools/exec/bash.ts +111 -0
  51. package/src/tools/exec/git.ts +63 -0
  52. package/src/tools/exec/task.ts +69 -0
  53. package/src/tools/file/edit.ts +57 -0
  54. package/src/tools/file/glob.ts +29 -0
  55. package/src/tools/file/grep.ts +52 -0
  56. package/src/tools/file/read.ts +38 -0
  57. package/src/tools/file/write.ts +27 -0
  58. package/src/tools/index.ts +126 -0
  59. package/src/tools/network/web-fetch.ts +61 -0
  60. package/src/tools/network/web-search.ts +32 -0
  61. package/src/tools/system/config.ts +68 -0
  62. package/src/tools/system/mcp.ts +75 -0
  63. package/src/ui/app.tsx +317 -0
  64. package/src/ui/chat.tsx +76 -0
  65. package/src/ui/commands.ts +2232 -0
  66. package/src/ui/input.tsx +83 -0
  67. package/src/ui/picker.tsx +209 -0
@@ -0,0 +1,116 @@
1
+ import type {
2
+ HookDefinition,
3
+ HookEvent,
4
+ HookContext,
5
+ HookResult,
6
+ ToolResult,
7
+ } from '../shared/index.ts'
8
+
9
+ export class HookEngine {
10
+ private hooks: HookDefinition[] = []
11
+
12
+ register(hook: HookDefinition): void {
13
+ this.hooks.push(hook)
14
+ }
15
+
16
+ unregister(event: HookEvent, toolName?: string): void {
17
+ this.hooks = this.hooks.filter(
18
+ (h) => !(h.event === event && (!toolName || h.toolName === toolName)),
19
+ )
20
+ }
21
+
22
+ async executePreToolUse(
23
+ toolName: string,
24
+ toolInput: Record<string, unknown>,
25
+ sessionId: string,
26
+ ): Promise<HookResult> {
27
+ const ctx: HookContext = {
28
+ event: 'PreToolUse',
29
+ toolName,
30
+ toolInput,
31
+ sessionId,
32
+ }
33
+
34
+ return this.runHooks('PreToolUse', toolName, ctx)
35
+ }
36
+
37
+ async executePostToolUse(
38
+ toolName: string,
39
+ toolInput: Record<string, unknown>,
40
+ toolResult: ToolResult,
41
+ sessionId: string,
42
+ ): Promise<HookResult> {
43
+ const ctx: HookContext = {
44
+ event: 'PostToolUse',
45
+ toolName,
46
+ toolInput,
47
+ toolResult,
48
+ sessionId,
49
+ }
50
+
51
+ return this.runHooks('PostToolUse', toolName, ctx)
52
+ }
53
+
54
+ async executeSessionStart(sessionId: string): Promise<HookResult> {
55
+ const ctx: HookContext = {
56
+ event: 'SessionStart',
57
+ sessionId,
58
+ }
59
+
60
+ return this.runHooks('SessionStart', undefined, ctx)
61
+ }
62
+
63
+ async executeSessionEnd(sessionId: string): Promise<HookResult> {
64
+ const ctx: HookContext = {
65
+ event: 'SessionEnd',
66
+ sessionId,
67
+ }
68
+
69
+ return this.runHooks('SessionEnd', undefined, ctx)
70
+ }
71
+
72
+ async executeNotification(message: string, sessionId: string): Promise<HookResult> {
73
+ const ctx: HookContext = {
74
+ event: 'Notification',
75
+ sessionId,
76
+ toolInput: { message },
77
+ }
78
+
79
+ return this.runHooks('Notification', undefined, ctx)
80
+ }
81
+
82
+ private async runHooks(
83
+ event: HookEvent,
84
+ toolName: string | undefined,
85
+ ctx: HookContext,
86
+ ): Promise<HookResult> {
87
+ const matching = this.hooks.filter(
88
+ (h) => h.event === event && (!toolName || !h.toolName || h.toolName === toolName),
89
+ )
90
+
91
+ const result: HookResult = { allowed: true }
92
+
93
+ for (const hook of matching) {
94
+ try {
95
+ const hookResult = await hook.handler(ctx)
96
+ if (!hookResult.allowed) {
97
+ return hookResult // Block on first deny
98
+ }
99
+ if (hookResult.modifiedInput) {
100
+ result.modifiedInput = {
101
+ ...(result.modifiedInput || {}),
102
+ ...hookResult.modifiedInput,
103
+ }
104
+ }
105
+ } catch {
106
+ // Hook failures do not block execution
107
+ }
108
+ }
109
+
110
+ return result
111
+ }
112
+
113
+ listHooks(): HookDefinition[] {
114
+ return [...this.hooks]
115
+ }
116
+ }
@@ -0,0 +1,126 @@
1
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { parse as parseYaml } from 'yaml'
4
+ import type { InstructionFile } from '../shared/index.ts'
5
+
6
+ interface FrontmatterResult {
7
+ data: Record<string, unknown>
8
+ content: string
9
+ }
10
+
11
+ function parseFrontmatter(raw: string): FrontmatterResult {
12
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
13
+ if (!match) {
14
+ return { data: {}, content: raw }
15
+ }
16
+ return {
17
+ data: parseYaml(match[1] || '') as Record<string, unknown>,
18
+ content: match[2] || '',
19
+ }
20
+ }
21
+
22
+ export class InstructionsLoader {
23
+ private instructions: InstructionFile[] = []
24
+
25
+ loadAll(cwd: string): void {
26
+ this.instructions = []
27
+
28
+ // Tier 1: Ancestor-level CLAUDE.md (for Claude Code compatibility)
29
+ // ../../ = Rismed_Ronxin_Capital
30
+ this.tryLoad(join(cwd, '..', '..', 'CLAUDE.md'), 'group')
31
+ // ../ = One_Mipham_Corporation
32
+ this.tryLoad(join(cwd, '..', 'CLAUDE.md'), 'company')
33
+
34
+ // Tier 1b: Group-level MIPHAM.md — One_Mipham_Corporation is the root
35
+ this.tryLoad(join(cwd, '..', 'MIPHAM.md'), 'group')
36
+
37
+ // Tier 2: Project-level (CLAUDE.md + MIPHAM.md at cwd)
38
+ this.tryLoad(join(cwd, 'MIPHAM.md'), 'project')
39
+ this.tryLoad(join(cwd, 'CLAUDE.md'), 'project')
40
+
41
+ // Tier 2b: Directory-level MIPHAM.md (recursive, up to 3 levels)
42
+ this.tryLoadRecursive(cwd, 'directory')
43
+
44
+ // Tier 3: User-level ~/.mipham/USER.md
45
+ const home = process.env.HOME || '~'
46
+ this.tryLoad(join(home, '.mipham', 'USER.md'), 'user')
47
+ }
48
+
49
+ buildSystemPrompt(): string {
50
+ const parts: string[] = []
51
+
52
+ for (const inst of this.instructions) {
53
+ const levelLabel: Record<string, string> = {
54
+ group: 'Group Policy',
55
+ company: 'Company Policy',
56
+ project: 'Project Rules',
57
+ directory: 'Directory Rules',
58
+ user: 'User Preferences',
59
+ }
60
+ parts.push(`<!-- ${levelLabel[inst.level] || inst.level} (${inst.path}) -->\n${inst.content}`)
61
+ }
62
+
63
+ return parts.join('\n\n---\n\n')
64
+ }
65
+
66
+ list(): InstructionFile[] {
67
+ return [...this.instructions]
68
+ }
69
+
70
+ private tryLoad(path: string, level: InstructionFile['level']): void {
71
+ if (!existsSync(path)) return
72
+
73
+ try {
74
+ const raw = readFileSync(path, 'utf-8')
75
+ const { data, content } = parseFrontmatter(raw)
76
+ this.instructions.push({
77
+ path,
78
+ level,
79
+ privacy: (data.privacy as InstructionFile['privacy']) || 'project',
80
+ language: (data.language as string) || 'en-US',
81
+ content,
82
+ frontmatter: data,
83
+ })
84
+ } catch {
85
+ // Silently skip unreadable files
86
+ }
87
+ }
88
+
89
+ private tryLoadRecursive(dir: string, level: InstructionFile['level']): void {
90
+ const maxDepth = 3
91
+
92
+ const walk = (current: string, depth: number) => {
93
+ if (depth > maxDepth) return
94
+
95
+ // Check for MIPHAM.md in this directory
96
+ const miphamPath = join(current, 'MIPHAM.md')
97
+ if (existsSync(miphamPath) && current !== dir) {
98
+ this.tryLoad(miphamPath, level)
99
+ }
100
+ // Also check for CLAUDE.md
101
+ const claudeMdPath = join(current, 'CLAUDE.md')
102
+ if (existsSync(claudeMdPath) && current !== dir) {
103
+ this.tryLoad(claudeMdPath, level)
104
+ }
105
+
106
+ try {
107
+ const items = readdirSync(current, { withFileTypes: true })
108
+ for (const item of items) {
109
+ if (
110
+ item.isDirectory() &&
111
+ !item.name.startsWith('.') &&
112
+ item.name !== 'node_modules' &&
113
+ item.name !== 'dist' &&
114
+ item.name !== '.next'
115
+ ) {
116
+ walk(join(current, item.name), depth + 1)
117
+ }
118
+ }
119
+ } catch {
120
+ // skip unreadable directories
121
+ }
122
+ }
123
+
124
+ walk(dir, 0)
125
+ }
126
+ }
@@ -0,0 +1,54 @@
1
+ import type { ToolDefinition, ToolPermission, PermissionLevel } from '../shared/index.ts'
2
+
3
+ export class PermissionSystem {
4
+ private rules = new Map<string, PermissionLevel>()
5
+
6
+ constructor(private defaultLevel: ToolPermission = 'auto') {}
7
+
8
+ setDefaultLevel(level: PermissionLevel): void {
9
+ this.defaultLevel = level
10
+ }
11
+
12
+ getDefaultLevel(): PermissionLevel {
13
+ return this.defaultLevel
14
+ }
15
+
16
+ setRule(toolName: string, level: PermissionLevel): void {
17
+ this.rules.set(toolName, level)
18
+ }
19
+
20
+ removeRule(toolName: string): void {
21
+ this.rules.delete(toolName)
22
+ }
23
+
24
+ check(tool: ToolDefinition, _input: Record<string, unknown>): PermissionLevel {
25
+ const ruleLevel = this.rules.get(tool.name)
26
+ if (ruleLevel) return ruleLevel
27
+ return tool.permission
28
+ }
29
+
30
+ needsApproval(tool: ToolDefinition, input: Record<string, unknown>): boolean {
31
+ return this.check(tool, input) === 'ask'
32
+ }
33
+
34
+ isBypassed(tool: ToolDefinition, input: Record<string, unknown>): boolean {
35
+ return this.check(tool, input) === 'bypass'
36
+ }
37
+
38
+ listRules(): Map<string, PermissionLevel> {
39
+ return new Map(this.rules)
40
+ }
41
+
42
+ getByCategory(
43
+ tools: Map<string, ToolDefinition>,
44
+ category: string,
45
+ ): Array<{ name: string; level: PermissionLevel }> {
46
+ const result: Array<{ name: string; level: PermissionLevel }> = []
47
+ for (const [name, tool] of tools) {
48
+ if (tool.category === category) {
49
+ result.push({ name, level: this.check(tool, {}) })
50
+ }
51
+ }
52
+ return result
53
+ }
54
+ }
@@ -0,0 +1,136 @@
1
+ import {
2
+ mkdirSync,
3
+ readdirSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ unlinkSync,
7
+ existsSync,
8
+ } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import type { Message } from '../shared/types'
11
+
12
+ export interface SessionMetadata {
13
+ name: string
14
+ createdAt: string
15
+ updatedAt: string
16
+ provider: string
17
+ model: string
18
+ messageCount: number
19
+ }
20
+
21
+ export interface StoredSession {
22
+ metadata: SessionMetadata
23
+ messages: Message[]
24
+ }
25
+
26
+ const HOME = process.env.HOME || '~'
27
+ const SESSIONS_DIR = join(HOME, '.mipham', 'sessions')
28
+
29
+ function ensureDir(): void {
30
+ mkdirSync(SESSIONS_DIR, { recursive: true })
31
+ }
32
+
33
+ function filePath(name: string): string {
34
+ // Sanitize name for filesystem
35
+ const safe = name.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 100)
36
+ return join(SESSIONS_DIR, `${safe}.jsonl`)
37
+ }
38
+
39
+ export class SessionStore {
40
+ /**
41
+ * Save a session as JSONL (one JSON object per line).
42
+ */
43
+ static save(
44
+ name: string,
45
+ messages: Message[],
46
+ metadata?: { provider?: string; model?: string },
47
+ ): void {
48
+ ensureDir()
49
+ const path = filePath(name)
50
+
51
+ const session: StoredSession = {
52
+ metadata: {
53
+ name,
54
+ createdAt: new Date().toISOString(),
55
+ updatedAt: new Date().toISOString(),
56
+ provider: metadata?.provider || 'unknown',
57
+ model: metadata?.model || 'unknown',
58
+ messageCount: messages.length,
59
+ },
60
+ messages,
61
+ }
62
+
63
+ writeFileSync(path, JSON.stringify(session) + '\n', 'utf-8')
64
+ }
65
+
66
+ /**
67
+ * Load a saved session. Returns null if not found or unparseable.
68
+ */
69
+ static load(name: string): StoredSession | null {
70
+ const path = filePath(name)
71
+ if (!existsSync(path)) return null
72
+
73
+ try {
74
+ const raw = readFileSync(path, 'utf-8')
75
+ const session = JSON.parse(raw) as StoredSession
76
+ // Validate structure
77
+ if (!session.metadata || !Array.isArray(session.messages)) {
78
+ return null
79
+ }
80
+ return session
81
+ } catch {
82
+ return null
83
+ }
84
+ }
85
+
86
+ /**
87
+ * List all saved sessions, most recent first.
88
+ */
89
+ static list(): SessionMetadata[] {
90
+ ensureDir()
91
+ try {
92
+ const files = readdirSync(SESSIONS_DIR).filter((f) => f.endsWith('.jsonl'))
93
+
94
+ const sessions: SessionMetadata[] = []
95
+ for (const file of files) {
96
+ try {
97
+ const raw = readFileSync(join(SESSIONS_DIR, file), 'utf-8')
98
+ const session = JSON.parse(raw) as StoredSession
99
+ if (session.metadata) {
100
+ sessions.push(session.metadata)
101
+ }
102
+ } catch {
103
+ // Skip corrupt files
104
+ }
105
+ }
106
+
107
+ sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
108
+ return sessions
109
+ } catch {
110
+ return []
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Delete a saved session.
116
+ */
117
+ static delete(name: string): boolean {
118
+ const path = filePath(name)
119
+ if (!existsSync(path)) return false
120
+ try {
121
+ unlinkSync(path)
122
+ return true
123
+ } catch {
124
+ return false
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Auto-save with timestamp-based name.
130
+ */
131
+ static autoSave(messages: Message[], metadata?: { provider?: string; model?: string }): string {
132
+ const name = `session-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`
133
+ SessionStore.save(name, messages, metadata)
134
+ return name
135
+ }
136
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,93 @@
1
+ import { render } from 'ink'
2
+ import { App } from './ui/app'
3
+ import { loadConfig } from './config/loader'
4
+ import { bootstrapProviders } from './providers/bootstrap'
5
+ import { InstructionsLoader } from './core/instructions'
6
+ import { ContextManager } from './core/context'
7
+ import { QueryEngine } from './core/engine'
8
+ import { SessionStore } from './core/session-store'
9
+ import { SkillsLoader } from './skills/loader'
10
+ import { createToolRegistry } from './tools'
11
+
12
+ interface RunOptions {
13
+ model?: string
14
+ provider?: string
15
+ lang?: string
16
+ permission?: string
17
+ resume?: string
18
+ }
19
+
20
+ export async function runApp(options: RunOptions): Promise<void> {
21
+ // Set terminal window title
22
+ process.stdout.write('\x1b]0;Mipham Code\x07')
23
+
24
+ // Load configuration
25
+ const config = loadConfig()
26
+
27
+ // Bootstrap providers
28
+ const defaultProvider = options.provider || config.defaultProvider
29
+ const defaultModel = options.model || config.defaultModel
30
+ const registry = bootstrapProviders(config.providers, defaultProvider, defaultModel)
31
+
32
+ // Load instructions
33
+ const instructions = new InstructionsLoader()
34
+ instructions.loadAll(process.cwd())
35
+
36
+ // Load skills
37
+ const skillsLoader = new SkillsLoader()
38
+ skillsLoader.loadBuiltin(process.cwd())
39
+ if (config.skills?.paths) {
40
+ skillsLoader.loadExternal(config.skills.paths)
41
+ }
42
+
43
+ // Initialize context — restore saved session if available
44
+ const context = new ContextManager({ maxTokens: 200_000, compactionThreshold: 0.9 })
45
+
46
+ if (options.resume) {
47
+ const saved = SessionStore.load(options.resume)
48
+ if (saved) {
49
+ for (const msg of saved.messages) {
50
+ context.addMessage(msg)
51
+ }
52
+ context.setSystemPrompt(instructions.buildSystemPrompt())
53
+ }
54
+ }
55
+
56
+ if (context.getMessageCount() === 0) {
57
+ context.setSystemPrompt(instructions.buildSystemPrompt())
58
+ }
59
+
60
+ // Create tool registry with all 16 tools
61
+ const tools = createToolRegistry()
62
+
63
+ // Create query engine
64
+ const engine = new QueryEngine(registry, context, tools)
65
+
66
+ // Auto-save session on exit
67
+ let autoSaveName: string | undefined
68
+ const saveAndExit = () => {
69
+ if (context.getMessageCount() > 0) {
70
+ autoSaveName = SessionStore.autoSave(context.getMessages(), {
71
+ provider: defaultProvider,
72
+ model: defaultModel,
73
+ })
74
+ }
75
+ process.exit(0)
76
+ }
77
+
78
+ process.on('SIGINT', saveAndExit)
79
+ process.on('SIGTERM', saveAndExit)
80
+
81
+ const { waitUntilExit } = render(
82
+ <App
83
+ engine={engine}
84
+ config={config}
85
+ initialProvider={defaultProvider}
86
+ initialModel={defaultModel}
87
+ lang={options.lang}
88
+ skillsLoader={skillsLoader}
89
+ />,
90
+ )
91
+ await waitUntilExit()
92
+ saveAndExit()
93
+ }
@@ -0,0 +1,170 @@
1
+ import type { McpServerConfig } from '../shared/types'
2
+ import type {
3
+ ConnectionStatus,
4
+ ConnectionInfo,
5
+ ToolDefinition,
6
+ ToolCallResult,
7
+ InitializeResult,
8
+ } from './types'
9
+ import { StdioTransport } from './transport'
10
+ import { McpProtocol } from './protocol'
11
+
12
+ interface ActiveConnection {
13
+ config: McpServerConfig
14
+ transport: StdioTransport
15
+ protocol: McpProtocol
16
+ status: ConnectionStatus
17
+ tools: ToolDefinition[]
18
+ serverInfo?: { name: string; version: string }
19
+ error?: string
20
+ }
21
+
22
+ /**
23
+ * Singleton MCP client — manages connections to multiple MCP servers.
24
+ *
25
+ * Lifecycle:
26
+ * 1. connect(config) — spawn process, initialize, discover tools
27
+ * 2. callTool(server, tool, params) — execute tool on connected server
28
+ * 3. disconnect(name) or closeAll() — kill subprocess, clean up
29
+ *
30
+ * Backward-compatible with the previous stub McpClient API.
31
+ */
32
+ export class McpClient {
33
+ private static instance: McpClient | null = null
34
+ private connections = new Map<string, ActiveConnection>()
35
+
36
+ /** Get or create the singleton instance. */
37
+ static getInstance(): McpClient {
38
+ if (!McpClient.instance) {
39
+ McpClient.instance = new McpClient()
40
+ }
41
+ return McpClient.instance
42
+ }
43
+
44
+ /** Reset the singleton (useful for testing). */
45
+ static resetInstance(): void {
46
+ McpClient.instance = null
47
+ }
48
+
49
+ async connect(config: McpServerConfig): Promise<void> {
50
+ // Skip if already connected
51
+ const existing = this.connections.get(config.name)
52
+ if (existing?.status === 'connected') return
53
+
54
+ const transport = new StdioTransport()
55
+ const protocol = new McpProtocol(transport)
56
+
57
+ const connection: ActiveConnection = {
58
+ config,
59
+ transport,
60
+ protocol,
61
+ status: 'connecting',
62
+ tools: [],
63
+ }
64
+
65
+ this.connections.set(config.name, connection)
66
+
67
+ try {
68
+ const initResult: InitializeResult = await protocol.initialize(
69
+ config.command,
70
+ config.args,
71
+ config.env,
72
+ )
73
+
74
+ connection.status = 'connected'
75
+ connection.serverInfo = initResult.serverInfo
76
+
77
+ // Discover tools
78
+ if (initResult.capabilities.tools) {
79
+ connection.tools = await protocol.listTools()
80
+ }
81
+ } catch (err) {
82
+ connection.status = 'error'
83
+ connection.error = String(err)
84
+ throw err
85
+ }
86
+ }
87
+
88
+ disconnect(name: string): void {
89
+ const conn = this.connections.get(name)
90
+ if (!conn) return
91
+
92
+ try {
93
+ conn.transport.close()
94
+ } catch {
95
+ /* best effort */
96
+ }
97
+ this.connections.delete(name)
98
+ }
99
+
100
+ async closeAll(): Promise<void> {
101
+ const names = Array.from(this.connections.keys())
102
+ for (const name of names) {
103
+ try {
104
+ await this.connections.get(name)?.transport.close()
105
+ } catch {
106
+ /* best effort */
107
+ }
108
+ this.connections.delete(name)
109
+ }
110
+ McpClient.instance = null
111
+ }
112
+
113
+ getConnection(name: string): ConnectionInfo | undefined {
114
+ const conn = this.connections.get(name)
115
+ if (!conn) return undefined
116
+
117
+ return {
118
+ config: {
119
+ name: conn.config.name,
120
+ command: conn.config.command,
121
+ args: conn.config.args,
122
+ },
123
+ status: conn.status,
124
+ tools: conn.tools,
125
+ error: conn.error,
126
+ serverInfo: conn.serverInfo,
127
+ }
128
+ }
129
+
130
+ listConnections(): ConnectionInfo[] {
131
+ return Array.from(this.connections.values()).map((conn) => ({
132
+ config: {
133
+ name: conn.config.name,
134
+ command: conn.config.command,
135
+ args: conn.config.args,
136
+ },
137
+ status: conn.status,
138
+ tools: conn.tools,
139
+ error: conn.error,
140
+ serverInfo: conn.serverInfo,
141
+ }))
142
+ }
143
+
144
+ getTools(name: string): ToolDefinition[] {
145
+ return this.connections.get(name)?.tools || []
146
+ }
147
+
148
+ async callTool(
149
+ serverName: string,
150
+ toolName: string,
151
+ params?: Record<string, unknown>,
152
+ ): Promise<ToolCallResult> {
153
+ const conn = this.connections.get(serverName)
154
+ if (!conn || conn.status !== 'connected') {
155
+ return {
156
+ content: [{ type: 'text', text: `Error: MCP server "${serverName}" not connected` }],
157
+ isError: true,
158
+ }
159
+ }
160
+
161
+ try {
162
+ return await conn.protocol.callTool(toolName, params)
163
+ } catch (err) {
164
+ return {
165
+ content: [{ type: 'text', text: `MCP tool error: ${String(err)}` }],
166
+ isError: true,
167
+ }
168
+ }
169
+ }
170
+ }