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.
- package/README.md +24 -0
- package/package.json +65 -0
- package/src/brain/compaction.test.ts +156 -0
- package/src/brain/compaction.ts +131 -0
- package/src/brain/demo-endpoint.ts +13 -0
- package/src/brain/frozen-prefix.test.ts +235 -0
- package/src/brain/frozen-prefix.ts +320 -0
- package/src/brain/history-persist.test.ts +129 -0
- package/src/brain/history-persist.ts +154 -0
- package/src/brain/index.ts +44 -0
- package/src/brain/openai-brain.test.ts +61 -0
- package/src/brain/openai-brain.ts +544 -0
- package/src/brain/sanitize.test.ts +27 -0
- package/src/brain/sanitize.ts +23 -0
- package/src/brain/stub.ts +20 -0
- package/src/brain/types.ts +129 -0
- package/src/chain.ts +35 -0
- package/src/claude-plugins/discovery.test.ts +71 -0
- package/src/claude-plugins/discovery.ts +152 -0
- package/src/claude-plugins/index.ts +6 -0
- package/src/claude-plugins/types.ts +38 -0
- package/src/commands/index.ts +16 -0
- package/src/commands/registry.test.ts +186 -0
- package/src/commands/registry.ts +255 -0
- package/src/config.ts +201 -0
- package/src/economy/index.ts +6 -0
- package/src/events/index.ts +4 -0
- package/src/events/listeners.ts +37 -0
- package/src/events/queue.test.ts +43 -0
- package/src/events/queue.ts +63 -0
- package/src/events/router.ts +42 -0
- package/src/events/types.ts +28 -0
- package/src/format.ts +13 -0
- package/src/index.test.ts +6 -0
- package/src/index.ts +314 -0
- package/src/locks.test.ts +259 -0
- package/src/locks.ts +233 -0
- package/src/mcp/discovery.test.ts +97 -0
- package/src/mcp/discovery.ts +150 -0
- package/src/mcp/index.ts +10 -0
- package/src/mcp/manager.test.ts +97 -0
- package/src/mcp/manager.ts +110 -0
- package/src/mcp/stdio-client.ts +154 -0
- package/src/mcp/types.ts +44 -0
- package/src/memory/edit.test.ts +41 -0
- package/src/memory/edit.ts +53 -0
- package/src/memory/encryption.test.ts +68 -0
- package/src/memory/encryption.ts +94 -0
- package/src/memory/fs-util.ts +15 -0
- package/src/memory/index-file.ts +74 -0
- package/src/memory/index-sync.test.ts +81 -0
- package/src/memory/index-sync.ts +99 -0
- package/src/memory/index.ts +58 -0
- package/src/memory/list-tool.ts +105 -0
- package/src/memory/pack-blob.test.ts +90 -0
- package/src/memory/pack-blob.ts +120 -0
- package/src/memory/pack-gather.test.ts +110 -0
- package/src/memory/pack-gather.ts +112 -0
- package/src/memory/parser.test.ts +29 -0
- package/src/memory/parser.ts +20 -0
- package/src/memory/path-drift.test.ts +71 -0
- package/src/memory/read-tool-fallback.test.ts +54 -0
- package/src/memory/read-tool.ts +198 -0
- package/src/memory/save-tool.test.ts +193 -0
- package/src/memory/save-tool.ts +189 -0
- package/src/memory/scan.test.ts +24 -0
- package/src/memory/scan.ts +63 -0
- package/src/memory/topic.ts +32 -0
- package/src/memory/types.ts +49 -0
- package/src/migration/index.ts +6 -0
- package/src/migration/option3-crypto.test.ts +106 -0
- package/src/migration/option3-crypto.ts +143 -0
- package/src/pairing.test.ts +212 -0
- package/src/pairing.ts +285 -0
- package/src/paths.ts +70 -0
- package/src/permission/dangerous.ts +105 -0
- package/src/permission/env-redact.ts +54 -0
- package/src/permission/index.ts +16 -0
- package/src/permission/path-guard.ts +114 -0
- package/src/permission/permission.test.ts +299 -0
- package/src/permission/service.ts +191 -0
- package/src/plugins/context.ts +226 -0
- package/src/plugins/hooks.ts +81 -0
- package/src/plugins/index.ts +24 -0
- package/src/plugins/plugins.test.ts +196 -0
- package/src/plugins/tool-search.ts +49 -0
- package/src/public/card.test.ts +70 -0
- package/src/public/card.ts +67 -0
- package/src/runtime/activity.ts +29 -0
- package/src/runtime/index.ts +7 -0
- package/src/runtime/runtime.test.ts +55 -0
- package/src/runtime/runtime.ts +126 -0
- package/src/sandbox/credentials.ts +25 -0
- package/src/sandbox/docker.ts +389 -0
- package/src/sandbox/factory.ts +99 -0
- package/src/sandbox/index.ts +15 -0
- package/src/sandbox/linux.ts +141 -0
- package/src/sandbox/local.ts +19 -0
- package/src/sandbox/macos.ts +71 -0
- package/src/sandbox/sandbox.test.ts +386 -0
- package/src/sandbox/seatbelt-profile.ts +139 -0
- package/src/sandbox/types.ts +129 -0
- package/src/skills/index.ts +8 -0
- package/src/skills/scanner.test.ts +137 -0
- package/src/skills/scanner.ts +257 -0
- package/src/skills/triggers.test.ts +77 -0
- package/src/skills/triggers.ts +78 -0
- package/src/skills/types.ts +37 -0
- package/src/storage/encryption.test.ts +30 -0
- package/src/storage/encryption.ts +87 -0
- package/src/storage/factory.ts +31 -0
- package/src/storage/index.ts +11 -0
- package/src/storage/local-stub.ts +70 -0
- package/src/storage/sqlite.test.ts +29 -0
- package/src/storage/sqlite.ts +95 -0
- package/src/storage/types.ts +21 -0
- package/src/tools/escalation.test.ts +348 -0
- package/src/tools/escalation.ts +200 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/registry.test.ts +70 -0
- package/src/tools/registry.ts +152 -0
- package/src/tools/types.ts +65 -0
- package/src/tools/zod-helpers.test.ts +63 -0
- package/src/tools/zod-helpers.ts +36 -0
- package/src/tools/zod-schema.ts +99 -0
- package/src/wallet/drain.test.ts +41 -0
- package/src/wallet/drain.ts +76 -0
- package/src/wallet/eoa.ts +61 -0
- package/src/wallet/index.ts +14 -0
- package/src/wallet/keystore.test.ts +17 -0
- 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
|
+
}
|
package/src/mcp/index.ts
ADDED
|
@@ -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
|
+
}
|