lyra-core 0.1.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.
Files changed (131) hide show
  1. package/README.md +24 -0
  2. package/package.json +65 -0
  3. package/src/brain/compaction.test.ts +156 -0
  4. package/src/brain/compaction.ts +131 -0
  5. package/src/brain/demo-endpoint.ts +13 -0
  6. package/src/brain/frozen-prefix.test.ts +235 -0
  7. package/src/brain/frozen-prefix.ts +320 -0
  8. package/src/brain/history-persist.test.ts +129 -0
  9. package/src/brain/history-persist.ts +154 -0
  10. package/src/brain/index.ts +44 -0
  11. package/src/brain/openai-brain.test.ts +61 -0
  12. package/src/brain/openai-brain.ts +544 -0
  13. package/src/brain/sanitize.test.ts +27 -0
  14. package/src/brain/sanitize.ts +23 -0
  15. package/src/brain/stub.ts +20 -0
  16. package/src/brain/types.ts +129 -0
  17. package/src/chain.ts +35 -0
  18. package/src/claude-plugins/discovery.test.ts +71 -0
  19. package/src/claude-plugins/discovery.ts +152 -0
  20. package/src/claude-plugins/index.ts +6 -0
  21. package/src/claude-plugins/types.ts +38 -0
  22. package/src/commands/index.ts +16 -0
  23. package/src/commands/registry.test.ts +186 -0
  24. package/src/commands/registry.ts +255 -0
  25. package/src/config.ts +201 -0
  26. package/src/economy/index.ts +6 -0
  27. package/src/events/index.ts +4 -0
  28. package/src/events/listeners.ts +37 -0
  29. package/src/events/queue.test.ts +43 -0
  30. package/src/events/queue.ts +63 -0
  31. package/src/events/router.ts +42 -0
  32. package/src/events/types.ts +28 -0
  33. package/src/format.ts +13 -0
  34. package/src/index.test.ts +6 -0
  35. package/src/index.ts +314 -0
  36. package/src/locks.test.ts +259 -0
  37. package/src/locks.ts +233 -0
  38. package/src/mcp/discovery.test.ts +97 -0
  39. package/src/mcp/discovery.ts +150 -0
  40. package/src/mcp/index.ts +10 -0
  41. package/src/mcp/manager.test.ts +97 -0
  42. package/src/mcp/manager.ts +110 -0
  43. package/src/mcp/stdio-client.ts +154 -0
  44. package/src/mcp/types.ts +44 -0
  45. package/src/memory/edit.test.ts +41 -0
  46. package/src/memory/edit.ts +53 -0
  47. package/src/memory/encryption.test.ts +68 -0
  48. package/src/memory/encryption.ts +94 -0
  49. package/src/memory/fs-util.ts +15 -0
  50. package/src/memory/index-file.ts +74 -0
  51. package/src/memory/index-sync.test.ts +81 -0
  52. package/src/memory/index-sync.ts +99 -0
  53. package/src/memory/index.ts +58 -0
  54. package/src/memory/list-tool.ts +105 -0
  55. package/src/memory/pack-blob.test.ts +90 -0
  56. package/src/memory/pack-blob.ts +120 -0
  57. package/src/memory/pack-gather.test.ts +110 -0
  58. package/src/memory/pack-gather.ts +112 -0
  59. package/src/memory/parser.test.ts +29 -0
  60. package/src/memory/parser.ts +20 -0
  61. package/src/memory/path-drift.test.ts +71 -0
  62. package/src/memory/read-tool-fallback.test.ts +54 -0
  63. package/src/memory/read-tool.ts +198 -0
  64. package/src/memory/save-tool.test.ts +193 -0
  65. package/src/memory/save-tool.ts +189 -0
  66. package/src/memory/scan.test.ts +24 -0
  67. package/src/memory/scan.ts +63 -0
  68. package/src/memory/topic.ts +32 -0
  69. package/src/memory/types.ts +49 -0
  70. package/src/migration/index.ts +6 -0
  71. package/src/migration/option3-crypto.test.ts +106 -0
  72. package/src/migration/option3-crypto.ts +143 -0
  73. package/src/pairing.test.ts +212 -0
  74. package/src/pairing.ts +285 -0
  75. package/src/paths.ts +70 -0
  76. package/src/permission/dangerous.ts +105 -0
  77. package/src/permission/env-redact.ts +54 -0
  78. package/src/permission/index.ts +16 -0
  79. package/src/permission/path-guard.ts +114 -0
  80. package/src/permission/permission.test.ts +299 -0
  81. package/src/permission/service.ts +191 -0
  82. package/src/plugins/context.ts +226 -0
  83. package/src/plugins/hooks.ts +81 -0
  84. package/src/plugins/index.ts +24 -0
  85. package/src/plugins/plugins.test.ts +196 -0
  86. package/src/plugins/tool-search.ts +49 -0
  87. package/src/public/card.test.ts +70 -0
  88. package/src/public/card.ts +67 -0
  89. package/src/runtime/activity.ts +29 -0
  90. package/src/runtime/index.ts +7 -0
  91. package/src/runtime/runtime.test.ts +55 -0
  92. package/src/runtime/runtime.ts +126 -0
  93. package/src/sandbox/credentials.ts +25 -0
  94. package/src/sandbox/docker.ts +389 -0
  95. package/src/sandbox/factory.ts +99 -0
  96. package/src/sandbox/index.ts +15 -0
  97. package/src/sandbox/linux.ts +141 -0
  98. package/src/sandbox/local.ts +19 -0
  99. package/src/sandbox/macos.ts +71 -0
  100. package/src/sandbox/sandbox.test.ts +386 -0
  101. package/src/sandbox/seatbelt-profile.ts +139 -0
  102. package/src/sandbox/types.ts +129 -0
  103. package/src/skills/index.ts +8 -0
  104. package/src/skills/scanner.test.ts +137 -0
  105. package/src/skills/scanner.ts +257 -0
  106. package/src/skills/triggers.test.ts +77 -0
  107. package/src/skills/triggers.ts +78 -0
  108. package/src/skills/types.ts +37 -0
  109. package/src/storage/encryption.test.ts +30 -0
  110. package/src/storage/encryption.ts +87 -0
  111. package/src/storage/factory.ts +31 -0
  112. package/src/storage/index.ts +11 -0
  113. package/src/storage/local-stub.ts +70 -0
  114. package/src/storage/sqlite.test.ts +29 -0
  115. package/src/storage/sqlite.ts +95 -0
  116. package/src/storage/types.ts +21 -0
  117. package/src/tools/escalation.test.ts +348 -0
  118. package/src/tools/escalation.ts +200 -0
  119. package/src/tools/index.ts +11 -0
  120. package/src/tools/registry.test.ts +70 -0
  121. package/src/tools/registry.ts +152 -0
  122. package/src/tools/types.ts +65 -0
  123. package/src/tools/zod-helpers.test.ts +63 -0
  124. package/src/tools/zod-helpers.ts +36 -0
  125. package/src/tools/zod-schema.ts +99 -0
  126. package/src/wallet/drain.test.ts +41 -0
  127. package/src/wallet/drain.ts +76 -0
  128. package/src/wallet/eoa.ts +61 -0
  129. package/src/wallet/index.ts +14 -0
  130. package/src/wallet/keystore.test.ts +17 -0
  131. package/src/wallet/keystore.ts +50 -0
package/src/locks.ts ADDED
@@ -0,0 +1,233 @@
1
+ // Process-scoped advisory locks via PID-file pattern.
2
+ //
3
+ // Used by long-running listeners (telegram bot poller, etc.) to ensure only
4
+ // one process per scope+identity holds the resource. Mirrors hermes's
5
+ // acquire_scoped_lock from gateway/status.py.
6
+ //
7
+ // Lock file lives at ~/.lyra/locks/<scope>-<sha256(identity).slice(0,16)>.lock
8
+ // O_CREAT|O_EXCL atomic create. Stale-detection via process.kill(pid, 0).
9
+ // TTL eviction is a belt-and-suspenders fallback against crashed holders that
10
+ // the kernel hasn't reaped yet (rare but real on macOS).
11
+
12
+ import { createHash } from 'node:crypto'
13
+ import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync } from 'node:fs'
14
+ import { homedir } from 'node:os'
15
+ import { join } from 'node:path'
16
+
17
+ export interface AcquireScopedLockOpts {
18
+ scope: string
19
+ identity: string
20
+ ttl?: number
21
+ rootDir?: string
22
+ }
23
+
24
+ export interface ScopedLockHandle {
25
+ releaseFn: () => void
26
+ refreshFn: () => boolean
27
+ }
28
+
29
+ export interface AcquireScopedLockResult {
30
+ acquired: boolean
31
+ handle?: ScopedLockHandle
32
+ existing?: { pid: number; startedAt: number; updatedAt: number }
33
+ }
34
+
35
+ export const DEFAULT_LOCK_TTL_SECONDS = 300
36
+
37
+ interface LockRecord {
38
+ pid: number
39
+ scope: string
40
+ identityHash: string
41
+ startedAt: number
42
+ updatedAt: number
43
+ ttl: number
44
+ }
45
+
46
+ function lockDir(rootDir?: string): string {
47
+ return rootDir ?? join(homedir(), '.lyra', 'locks')
48
+ }
49
+
50
+ function lockPath(scope: string, identity: string, rootDir?: string): string {
51
+ const hash = createHash('sha256').update(identity).digest('hex').slice(0, 16)
52
+ return join(lockDir(rootDir), `${scope}-${hash}.lock`)
53
+ }
54
+
55
+ function readLock(path: string): LockRecord | null {
56
+ try {
57
+ return JSON.parse(readFileSync(path, 'utf8')) as LockRecord
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+
63
+ // process.kill(pid, 0) succeeds against zombie (defunct) processes on Linux
64
+ // because the kernel keeps the PID slot until the parent reaps it. A zombie
65
+ // can never refresh its lock or service work, so we treat it as gone. See
66
+ // feedback-tg-token-lock-zombie-after-upgrade.md. Exported for tests; not
67
+ // public API.
68
+ export function isZombieLinux(pid: number): boolean {
69
+ try {
70
+ const status = readFileSync(`/proc/${pid}/status`, 'utf8')
71
+ const m = status.match(/^State:\s+(\S)/m)
72
+ return m?.[1] === 'Z'
73
+ } catch {
74
+ return false
75
+ }
76
+ }
77
+
78
+ type StaleReason = 'live' | 'pid-dead' | 'zombie' | 'ttl'
79
+
80
+ // Single source of truth for "is this lock record dead, and if so, why?"
81
+ // `attemptOnce` only cares whether it can reclaim; `clearStaleScopedLock`
82
+ // reports the reason back to operators. Both go through this so the policy
83
+ // (TTL > kill(0) ESRCH > linux zombie) stays in one place.
84
+ function classifyStale(record: LockRecord, now: number): StaleReason {
85
+ if (now - record.updatedAt > record.ttl) return 'ttl'
86
+ if (record.pid === process.pid) return 'live'
87
+ try {
88
+ process.kill(record.pid, 0)
89
+ } catch (e) {
90
+ const code = (e as NodeJS.ErrnoException).code
91
+ if (code === 'EPERM') return 'live'
92
+ return 'pid-dead'
93
+ }
94
+ if (process.platform === 'linux' && isZombieLinux(record.pid)) return 'zombie'
95
+ return 'live'
96
+ }
97
+
98
+ function isStale(record: LockRecord, now: number): boolean {
99
+ return classifyStale(record, now) !== 'live'
100
+ }
101
+
102
+ function attemptOnce(
103
+ path: string,
104
+ scope: string,
105
+ identityHash: string,
106
+ ttl: number,
107
+ ): AcquireScopedLockResult {
108
+ let fd: number
109
+ try {
110
+ fd = openSync(path, 'wx')
111
+ } catch {
112
+ const existing = readLock(path)
113
+ const now = Math.floor(Date.now() / 1000)
114
+ if (!existing || isStale(existing, now)) {
115
+ try {
116
+ unlinkSync(path)
117
+ } catch {
118
+ /* race with another reclaimer */
119
+ }
120
+ return { acquired: false }
121
+ }
122
+ return {
123
+ acquired: false,
124
+ existing: {
125
+ pid: existing.pid,
126
+ startedAt: existing.startedAt,
127
+ updatedAt: existing.updatedAt,
128
+ },
129
+ }
130
+ }
131
+
132
+ const now = Math.floor(Date.now() / 1000)
133
+ const record: LockRecord = {
134
+ pid: process.pid,
135
+ scope,
136
+ identityHash,
137
+ startedAt: now,
138
+ updatedAt: now,
139
+ ttl,
140
+ }
141
+ try {
142
+ writeSync(fd, JSON.stringify(record))
143
+ } finally {
144
+ closeSync(fd)
145
+ }
146
+
147
+ let released = false
148
+ const releaseFn = (): void => {
149
+ if (released) return
150
+ released = true
151
+ try {
152
+ const current = readLock(path)
153
+ if (current?.pid === process.pid) unlinkSync(path)
154
+ } catch {
155
+ /* best-effort */
156
+ }
157
+ }
158
+ const refreshFn = (): boolean => {
159
+ if (released) return false
160
+ try {
161
+ const current = readLock(path)
162
+ if (current?.pid !== process.pid) return false
163
+ const next: LockRecord = { ...current, updatedAt: Math.floor(Date.now() / 1000) }
164
+ const fd2 = openSync(path, 'w')
165
+ try {
166
+ writeSync(fd2, JSON.stringify(next))
167
+ } finally {
168
+ closeSync(fd2)
169
+ }
170
+ return true
171
+ } catch {
172
+ return false
173
+ }
174
+ }
175
+ return { acquired: true, handle: { releaseFn, refreshFn } }
176
+ }
177
+
178
+ export function acquireScopedLock(opts: AcquireScopedLockOpts): AcquireScopedLockResult {
179
+ const ttl = opts.ttl ?? DEFAULT_LOCK_TTL_SECONDS
180
+ const path = lockPath(opts.scope, opts.identity, opts.rootDir)
181
+ mkdirSync(lockDir(opts.rootDir), { recursive: true })
182
+ const identityHash = createHash('sha256').update(opts.identity).digest('hex').slice(0, 16)
183
+
184
+ for (let i = 0; i < 3; i++) {
185
+ const result = attemptOnce(path, opts.scope, identityHash, ttl)
186
+ if (result.acquired) return result
187
+ if (result.existing) return result
188
+ }
189
+ return { acquired: false }
190
+ }
191
+
192
+ export type ClearStaleScopedLockReason =
193
+ | 'no-lock'
194
+ | 'alive-pid'
195
+ | 'cleared-stale'
196
+ | 'cleared-zombie'
197
+ | 'cleared-ttl'
198
+ | 'cleared-unreadable'
199
+
200
+ export interface ClearStaleScopedLockResult {
201
+ cleared: boolean
202
+ reason: ClearStaleScopedLockReason
203
+ }
204
+
205
+ /**
206
+ * Inspect the lock file at `(scope, identity)` and remove it iff it's stale
207
+ * (PID dead, zombie on Linux, or TTL expired). Never deletes a lock held by a
208
+ * live foreign PID — caller must wait for it.
209
+ *
210
+ * Used at gateway boot to proactively reap zombie/crashed listener locks so
211
+ * the new TG listener can acquire its bot-token slot without the 30s/6min
212
+ * retry waltz from `recovery.ts:scheduleStartRetry`.
213
+ */
214
+ export function clearStaleScopedLock(opts: AcquireScopedLockOpts): ClearStaleScopedLockResult {
215
+ const path = lockPath(opts.scope, opts.identity, opts.rootDir)
216
+ const existing = readLock(path)
217
+ if (!existing) return { cleared: false, reason: 'no-lock' }
218
+ const reason = classifyStale(existing, Math.floor(Date.now() / 1000))
219
+ if (reason === 'live') return { cleared: false, reason: 'alive-pid' }
220
+ try {
221
+ unlinkSync(path)
222
+ } catch {
223
+ return { cleared: false, reason: 'cleared-unreadable' }
224
+ }
225
+ switch (reason) {
226
+ case 'zombie':
227
+ return { cleared: true, reason: 'cleared-zombie' }
228
+ case 'pid-dead':
229
+ return { cleared: true, reason: 'cleared-stale' }
230
+ case 'ttl':
231
+ return { cleared: true, reason: 'cleared-ttl' }
232
+ }
233
+ }
@@ -0,0 +1,97 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { discoverMcpServers } from './discovery'
6
+
7
+ let scratch: string
8
+
9
+ beforeEach(async () => {
10
+ scratch = await mkdtemp(join(tmpdir(), 'lyra-mcp-'))
11
+ })
12
+
13
+ afterEach(async () => {
14
+ await rm(scratch, { recursive: true, force: true })
15
+ })
16
+
17
+ describe('discoverMcpServers', () => {
18
+ it('reads ~/.lyra/.mcp.json + ~/.claude/.mcp.json', async () => {
19
+ const lyraPath = join(scratch, 'lyra.mcp.json')
20
+ const claudePath = join(scratch, 'claude.mcp.json')
21
+ await writeFile(
22
+ lyraPath,
23
+ JSON.stringify({
24
+ mcpServers: {
25
+ alpha: { command: 'bun', args: ['run', 'a.ts'] },
26
+ },
27
+ }),
28
+ )
29
+ await writeFile(
30
+ claudePath,
31
+ JSON.stringify({
32
+ mcpServers: {
33
+ beta: { type: 'http', url: 'https://example.com/mcp', headers: { 'X-Foo': '1' } },
34
+ },
35
+ }),
36
+ )
37
+ const out = await discoverMcpServers({
38
+ lyraMcpPath: lyraPath,
39
+ claudeMcpPath: claudePath,
40
+ claudePluginsCacheRoot: join(scratch, 'doesnotexist'),
41
+ importsClaudeCode: true,
42
+ })
43
+ expect(out.servers).toHaveLength(2)
44
+ const alpha = out.servers.find(s => s.name === 'alpha')
45
+ expect(alpha?.type).toBe('stdio')
46
+ if (alpha?.type === 'stdio') {
47
+ expect(alpha.command).toBe('bun')
48
+ expect(alpha.args).toEqual(['run', 'a.ts'])
49
+ }
50
+ const beta = out.servers.find(s => s.name === 'beta')
51
+ expect(beta?.type).toBe('http')
52
+ if (beta?.type === 'http') expect(beta.url).toBe('https://example.com/mcp')
53
+ })
54
+
55
+ it('substitutes ${CLAUDE_PLUGIN_ROOT} from plugin cache layout', async () => {
56
+ const cacheRoot = join(scratch, 'cache')
57
+ const versionDir = join(cacheRoot, 'mp', 'plug', '1.0.0')
58
+ await mkdir(versionDir, { recursive: true })
59
+ await writeFile(
60
+ join(versionDir, '.mcp.json'),
61
+ JSON.stringify({
62
+ mcpServers: {
63
+ plug: {
64
+ command: 'bun',
65
+ args: ['run', '--cwd', '${CLAUDE_PLUGIN_ROOT}', 'start'],
66
+ env: { PLUG_ROOT: '${CLAUDE_PLUGIN_ROOT}/data' },
67
+ },
68
+ },
69
+ }),
70
+ )
71
+ const out = await discoverMcpServers({
72
+ lyraMcpPath: join(scratch, 'doesnotexist'),
73
+ claudeMcpPath: join(scratch, 'doesnotexist'),
74
+ claudePluginsCacheRoot: cacheRoot,
75
+ importsClaudeCode: true,
76
+ })
77
+ expect(out.servers).toHaveLength(1)
78
+ const s = out.servers[0]!
79
+ expect(s.type).toBe('stdio')
80
+ if (s.type === 'stdio') {
81
+ expect(s.args).toEqual(['run', '--cwd', versionDir, 'start'])
82
+ expect(s.env?.PLUG_ROOT).toBe(`${versionDir}/data`)
83
+ }
84
+ })
85
+
86
+ it('honors importsClaudeCode=false (only lyra path scanned)', async () => {
87
+ const claudePath = join(scratch, 'claude.mcp.json')
88
+ await writeFile(claudePath, JSON.stringify({ mcpServers: { foo: { command: 'bun' } } }))
89
+ const out = await discoverMcpServers({
90
+ lyraMcpPath: join(scratch, 'doesnotexist'),
91
+ claudeMcpPath: claudePath,
92
+ claudePluginsCacheRoot: join(scratch, 'doesnotexist'),
93
+ importsClaudeCode: false,
94
+ })
95
+ expect(out.servers).toEqual([])
96
+ })
97
+ })
@@ -0,0 +1,150 @@
1
+ import type { Dirent } from 'node:fs'
2
+ import { readFile, readdir, stat } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import type { McpDiscoveryResult, McpServerConfig } from './types'
6
+
7
+ interface RawMcpFile {
8
+ mcpServers?: Record<string, RawMcpServer>
9
+ }
10
+
11
+ interface RawMcpServer {
12
+ command?: string
13
+ args?: string[]
14
+ env?: Record<string, string>
15
+ type?: 'stdio' | 'http'
16
+ url?: string
17
+ headers?: Record<string, string>
18
+ }
19
+
20
+ export interface McpDiscoveryOptions {
21
+ /** Whether to scan ~/.claude/.mcp.json + ~/.claude/plugins/cache/. Default true. */
22
+ importsClaudeCode?: boolean
23
+ /** Override for ~/.claude/.mcp.json. */
24
+ claudeMcpPath?: string
25
+ /** Override for ~/.claude/plugins/cache/. */
26
+ claudePluginsCacheRoot?: string
27
+ /** Override for ~/.lyra/.mcp.json (lyra-native MCP servers). */
28
+ lyraMcpPath?: string
29
+ }
30
+
31
+ export async function discoverMcpServers(
32
+ opts: McpDiscoveryOptions = {},
33
+ ): Promise<McpDiscoveryResult> {
34
+ const importsClaudeCode = opts.importsClaudeCode ?? true
35
+ const lyraMcpPath = opts.lyraMcpPath ?? join(homedir(), '.lyra', '.mcp.json')
36
+ const claudeMcpPath = opts.claudeMcpPath ?? join(homedir(), '.claude', '.mcp.json')
37
+ const claudePluginsCacheRoot =
38
+ opts.claudePluginsCacheRoot ?? join(homedir(), '.claude', 'plugins', 'cache')
39
+
40
+ const sources: McpDiscoveryResult['sources'] = []
41
+ const collected = new Map<string, McpServerConfig>()
42
+ await loadFromFile(lyraMcpPath, undefined, collected, sources)
43
+ if (importsClaudeCode) {
44
+ await loadFromFile(claudeMcpPath, undefined, collected, sources)
45
+ await loadFromCache(claudePluginsCacheRoot, collected, sources)
46
+ }
47
+ return { servers: [...collected.values()], sources }
48
+ }
49
+
50
+ async function loadFromFile(
51
+ path: string,
52
+ pluginRoot: string | undefined,
53
+ out: Map<string, McpServerConfig>,
54
+ sources: McpDiscoveryResult['sources'],
55
+ ): Promise<void> {
56
+ let raw: string
57
+ try {
58
+ raw = await readFile(path, 'utf8')
59
+ } catch {
60
+ return
61
+ }
62
+ let parsed: RawMcpFile
63
+ try {
64
+ parsed = JSON.parse(raw) as RawMcpFile
65
+ } catch {
66
+ return
67
+ }
68
+ if (!parsed.mcpServers) return
69
+ for (const [name, server] of Object.entries(parsed.mcpServers)) {
70
+ const config = normalize(name, server, pluginRoot)
71
+ if (!config) continue
72
+ if (out.has(name)) continue
73
+ out.set(name, config)
74
+ sources.push({ server: name, path })
75
+ }
76
+ }
77
+
78
+ async function loadFromCache(
79
+ cacheRoot: string,
80
+ out: Map<string, McpServerConfig>,
81
+ sources: McpDiscoveryResult['sources'],
82
+ ): Promise<void> {
83
+ let marketplaces: Dirent[]
84
+ try {
85
+ const s = await stat(cacheRoot)
86
+ if (!s.isDirectory()) return
87
+ marketplaces = (await readdir(cacheRoot, { withFileTypes: true })) as Dirent[]
88
+ } catch {
89
+ return
90
+ }
91
+ for (const market of marketplaces) {
92
+ if (!market.isDirectory()) continue
93
+ const marketDir = join(cacheRoot, market.name)
94
+ let plugins: Dirent[]
95
+ try {
96
+ plugins = (await readdir(marketDir, { withFileTypes: true })) as Dirent[]
97
+ } catch {
98
+ continue
99
+ }
100
+ for (const plugin of plugins) {
101
+ if (!plugin.isDirectory()) continue
102
+ const pluginDir = join(marketDir, plugin.name)
103
+ let versions: Dirent[]
104
+ try {
105
+ versions = (await readdir(pluginDir, { withFileTypes: true })) as Dirent[]
106
+ } catch {
107
+ continue
108
+ }
109
+ const versionDirs = versions.filter(v => v.isDirectory()).map(v => v.name)
110
+ // Pick the newest version dir (lexicographic, sufficient for semver).
111
+ versionDirs.sort()
112
+ const latest = versionDirs[versionDirs.length - 1]
113
+ if (!latest) continue
114
+ const versionDir = join(pluginDir, latest)
115
+ const mcpPath = join(versionDir, '.mcp.json')
116
+ await loadFromFile(mcpPath, versionDir, out, sources)
117
+ }
118
+ }
119
+ }
120
+
121
+ function normalize(
122
+ name: string,
123
+ raw: RawMcpServer,
124
+ pluginRoot: string | undefined,
125
+ ): McpServerConfig | null {
126
+ if (raw.type === 'http') {
127
+ if (!raw.url) return null
128
+ return { name, type: 'http', url: raw.url, headers: raw.headers }
129
+ }
130
+ if (!raw.command) return null
131
+ return {
132
+ name,
133
+ type: 'stdio',
134
+ command: raw.command,
135
+ args: raw.args?.map(a => substitutePluginRoot(a, pluginRoot)),
136
+ env: raw.env
137
+ ? Object.fromEntries(
138
+ Object.entries(raw.env).map(([k, v]) => [k, substitutePluginRoot(v, pluginRoot)]),
139
+ )
140
+ : undefined,
141
+ pluginRoot,
142
+ }
143
+ }
144
+
145
+ function substitutePluginRoot(s: string, pluginRoot: string | undefined): string {
146
+ if (!pluginRoot) return s
147
+ return s
148
+ .replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot)
149
+ .replaceAll('$CLAUDE_PLUGIN_ROOT', pluginRoot)
150
+ }
@@ -0,0 +1,10 @@
1
+ export { discoverMcpServers, type McpDiscoveryOptions } from './discovery'
2
+ export { McpStdioClient } from './stdio-client'
3
+ export { McpManager } from './manager'
4
+ export type {
5
+ McpServerConfig,
6
+ McpServerStdio,
7
+ McpServerHttp,
8
+ McpToolMeta,
9
+ McpDiscoveryResult,
10
+ } from './types'
@@ -0,0 +1,97 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { ToolRegistry } from '../tools/registry'
6
+ import type { ToolDef } from '../tools/types'
7
+ import { McpManager } from './manager'
8
+
9
+ let scratch: string
10
+ let serverPath: string
11
+
12
+ beforeEach(async () => {
13
+ scratch = await mkdtemp(join(tmpdir(), 'lyra-mcp-server-'))
14
+ serverPath = join(scratch, 'server.ts')
15
+ // Minimal stdio MCP server: handles initialize, tools/list, tools/call.
16
+ await writeFile(
17
+ serverPath,
18
+ `process.stdin.setEncoding('utf8')
19
+ let buf = ''
20
+ process.stdin.on('data', chunk => {
21
+ buf += chunk
22
+ let nl = buf.indexOf('\\n')
23
+ while (nl !== -1) {
24
+ const line = buf.slice(0, nl).trim()
25
+ buf = buf.slice(nl + 1)
26
+ nl = buf.indexOf('\\n')
27
+ if (!line) continue
28
+ const msg = JSON.parse(line)
29
+ if (msg.method === 'initialize') {
30
+ process.stdout.write(JSON.stringify({
31
+ jsonrpc: '2.0', id: msg.id,
32
+ result: { protocolVersion: '2024-11-05', capabilities: {}, serverInfo: { name: 'fake', version: '1.0.0' } },
33
+ }) + '\\n')
34
+ } else if (msg.method === 'tools/list') {
35
+ process.stdout.write(JSON.stringify({
36
+ jsonrpc: '2.0', id: msg.id,
37
+ result: { tools: [{ name: 'echo', description: 'echoes input', inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] } }] },
38
+ }) + '\\n')
39
+ } else if (msg.method === 'tools/call') {
40
+ process.stdout.write(JSON.stringify({
41
+ jsonrpc: '2.0', id: msg.id,
42
+ result: { content: [{ type: 'text', text: msg.params.arguments.text }] },
43
+ }) + '\\n')
44
+ }
45
+ }
46
+ })
47
+ `,
48
+ )
49
+ })
50
+
51
+ afterEach(async () => {
52
+ await rm(scratch, { recursive: true, force: true })
53
+ })
54
+
55
+ describe('McpManager', () => {
56
+ it('spawns stdio server, registers tool, dispatches via registry', async () => {
57
+ const mgr = new McpManager([
58
+ { name: 'fake', type: 'stdio', command: 'bun', args: ['run', serverPath] },
59
+ ])
60
+ const reg = new ToolRegistry()
61
+ const result = await mgr.registerAll(def => reg.register(def))
62
+ expect(result.registered).toBe(1)
63
+ expect(result.failed).toEqual([])
64
+
65
+ const tool = reg.find('mcp.fake.echo') as ToolDef
66
+ expect(tool).toBeDefined()
67
+ expect(tool.shouldDefer).toBe(true)
68
+ expect(tool.parametersOverride?.properties).toEqual({ text: { type: 'string' } })
69
+
70
+ const out = await reg.dispatch({ id: '1', name: 'mcp.fake.echo', args: { text: 'hi' } })
71
+ expect(out.ok).toBe(true)
72
+ expect(out.data).toMatchObject({ content: [{ type: 'text', text: 'hi' }] })
73
+
74
+ mgr.closeAll()
75
+ })
76
+
77
+ it('reports failed servers without throwing', async () => {
78
+ const mgr = new McpManager([{ name: 'broken', type: 'stdio', command: '/does/not/exist' }])
79
+ const reg = new ToolRegistry()
80
+ const result = await mgr.registerAll(def => reg.register(def))
81
+ expect(result.registered).toBe(0)
82
+ expect(result.failed).toHaveLength(1)
83
+ expect(result.failed[0]!.server).toBe('broken')
84
+ mgr.closeAll()
85
+ })
86
+
87
+ it('flags http servers as not yet supported', async () => {
88
+ const mgr = new McpManager([
89
+ { name: 'http-thing', type: 'http', url: 'https://example.com/mcp' },
90
+ ])
91
+ const reg = new ToolRegistry()
92
+ const result = await mgr.registerAll(def => reg.register(def))
93
+ expect(result.registered).toBe(0)
94
+ expect(result.failed[0]!.error).toContain('http MCP')
95
+ mgr.closeAll()
96
+ })
97
+ })
@@ -0,0 +1,110 @@
1
+ import { z } from 'zod'
2
+ import type { JSONSchema, ToolDef } from '../tools/types'
3
+ import { McpStdioClient } from './stdio-client'
4
+ import type { McpServerConfig, McpToolMeta } from './types'
5
+
6
+ /**
7
+ * Lifecycle manager for one or more MCP servers. Spawns each subprocess at
8
+ * registration time, calls `tools/list`, and registers each remote tool as
9
+ * `mcp.<server>.<tool>` with `shouldDefer: true` so the brain only sees the
10
+ * full schema after `tool.search`.
11
+ *
12
+ * Lifecycle: caller invokes `closeAll()` on session end (chat exit).
13
+ */
14
+ export class McpManager {
15
+ private readonly clients = new Map<string, McpStdioClient>()
16
+ private readonly toolMeta = new Map<string, McpToolMeta>()
17
+
18
+ constructor(public readonly servers: readonly McpServerConfig[]) {}
19
+
20
+ async registerAll(register: (def: ToolDef) => void): Promise<{
21
+ registered: number
22
+ failed: { server: string; error: string }[]
23
+ }> {
24
+ const failed: { server: string; error: string }[] = []
25
+ let registered = 0
26
+ await Promise.all(
27
+ this.servers.map(async server => {
28
+ if (server.type === 'http') {
29
+ failed.push({ server: server.name, error: 'http MCP not yet supported (Phase 9.4)' })
30
+ return
31
+ }
32
+ try {
33
+ const client = new McpStdioClient(server)
34
+ this.clients.set(server.name, client)
35
+ const tools = await client.listTools()
36
+ for (const t of tools) {
37
+ const id = `mcp.${server.name}.${t.name}`
38
+ const meta: McpToolMeta = {
39
+ server: server.name,
40
+ toolName: t.name,
41
+ description: t.description ?? '',
42
+ inputSchema: t.inputSchema ?? defaultSchema(),
43
+ }
44
+ this.toolMeta.set(id, meta)
45
+ register(this.makeToolDef(id, meta))
46
+ registered++
47
+ }
48
+ } catch (e) {
49
+ failed.push({ server: server.name, error: (e as Error).message })
50
+ const dead = this.clients.get(server.name)
51
+ dead?.close()
52
+ this.clients.delete(server.name)
53
+ }
54
+ }),
55
+ )
56
+ return { registered, failed }
57
+ }
58
+
59
+ closeAll(): void {
60
+ for (const client of this.clients.values()) client.close()
61
+ this.clients.clear()
62
+ }
63
+
64
+ private makeToolDef(id: string, meta: McpToolMeta): ToolDef {
65
+ const head = meta.description ? `${meta.description.trim()}\n\n` : ''
66
+ const description = `${head}(MCP tool from server '${meta.server}', mapped to '${meta.toolName}'.)`
67
+ return {
68
+ name: id,
69
+ description,
70
+ shouldDefer: true,
71
+ searchHint: `mcp ${meta.server} ${meta.toolName}`,
72
+ schema: z.unknown(),
73
+ parametersOverride: toJsonSchemaShape(meta.inputSchema),
74
+ handler: async args => {
75
+ const client = this.clients.get(meta.server)
76
+ if (!client) {
77
+ return { ok: false, error: `mcp server '${meta.server}' not running` }
78
+ }
79
+ try {
80
+ const result = await client.callTool(meta.toolName, args ?? {})
81
+ return { ok: true, data: result }
82
+ } catch (e) {
83
+ return { ok: false, error: (e as Error).message }
84
+ }
85
+ },
86
+ }
87
+ }
88
+ }
89
+
90
+ function defaultSchema(): JSONSchema {
91
+ return {
92
+ type: 'object',
93
+ properties: {},
94
+ additionalProperties: true,
95
+ }
96
+ }
97
+
98
+ function toJsonSchemaShape(raw: unknown): JSONSchema {
99
+ if (!raw || typeof raw !== 'object') return defaultSchema()
100
+ const r = raw as Record<string, unknown>
101
+ if (r.type !== 'object' && !('properties' in r)) return defaultSchema()
102
+ return {
103
+ type: 'object',
104
+ properties: (r.properties as Record<string, unknown>) ?? {},
105
+ required: Array.isArray(r.required) ? (r.required as string[]) : undefined,
106
+ additionalProperties:
107
+ typeof r.additionalProperties === 'boolean' ? (r.additionalProperties as boolean) : true,
108
+ description: typeof r.description === 'string' ? (r.description as string) : undefined,
109
+ }
110
+ }