nebula-ai-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 +69 -0
- package/src/brain/compaction.ts +131 -0
- package/src/brain/frozen-prefix.ts +320 -0
- package/src/brain/history-persist.ts +154 -0
- package/src/brain/index.ts +43 -0
- package/src/brain/openai-brain.ts +533 -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 +75 -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.ts +255 -0
- package/src/config.ts +213 -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.ts +63 -0
- package/src/events/router.ts +42 -0
- package/src/events/types.ts +28 -0
- package/src/format.ts +12 -0
- package/src/identity/agent-card.ts +110 -0
- package/src/identity/deployments.ts +20 -0
- package/src/identity/erc8004.ts +161 -0
- package/src/identity/index.ts +29 -0
- package/src/identity/keystore-blob.ts +60 -0
- package/src/identity/receipt.ts +27 -0
- package/src/identity/stub.ts +29 -0
- package/src/identity/types.ts +20 -0
- package/src/index.ts +372 -0
- package/src/locks.ts +233 -0
- package/src/mcp/discovery.ts +150 -0
- package/src/mcp/index.ts +10 -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.ts +53 -0
- package/src/memory/encryption.ts +88 -0
- package/src/memory/fs-util.ts +15 -0
- package/src/memory/index-file.ts +74 -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.ts +120 -0
- package/src/memory/pack-gather.ts +112 -0
- package/src/memory/parser.ts +20 -0
- package/src/memory/read-tool.ts +198 -0
- package/src/memory/save-tool.ts +189 -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.ts +127 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/keychain.ts +53 -0
- package/src/operator/keystore-file.ts +33 -0
- package/src/operator/privkey-base.ts +60 -0
- package/src/operator/raw-privkey.ts +39 -0
- package/src/operator/signer.ts +46 -0
- package/src/operator/walletconnect.ts +454 -0
- package/src/pairing.ts +285 -0
- package/src/paths.ts +70 -0
- package/src/permission/dangerous.ts +108 -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/service.ts +191 -0
- package/src/plugins/context.ts +225 -0
- package/src/plugins/hooks.ts +81 -0
- package/src/plugins/index.ts +24 -0
- package/src/plugins/tool-search.ts +49 -0
- package/src/public/card.ts +67 -0
- package/src/runtime/activity.ts +29 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/runtime.ts +113 -0
- package/src/sandbox/credentials.ts +25 -0
- package/src/sandbox/docker.ts +396 -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/seatbelt-profile.ts +139 -0
- package/src/sandbox/types.ts +129 -0
- package/src/skills/index.ts +8 -0
- package/src/skills/scanner.ts +257 -0
- package/src/skills/triggers.ts +78 -0
- package/src/skills/types.ts +37 -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.ts +95 -0
- package/src/storage/types.ts +21 -0
- package/src/tools/escalation.ts +200 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/registry.ts +152 -0
- package/src/tools/types.ts +65 -0
- package/src/tools/zod-helpers.ts +36 -0
- package/src/tools/zod-schema.ts +99 -0
- package/src/wallet/drain.ts +79 -0
- package/src/wallet/eoa.ts +51 -0
- package/src/wallet/index.ts +47 -0
- package/src/wallet/keystore.ts +50 -0
- package/src/wallet/operator-keystore-crypto.ts +530 -0
- package/src/wallet/operator-session.ts +344 -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 ~/.nebula/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(), '.nebula', '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,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 ~/.nebula/.mcp.json (nebula-native MCP servers). */
|
|
28
|
+
nebulaMcpPath?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function discoverMcpServers(
|
|
32
|
+
opts: McpDiscoveryOptions = {},
|
|
33
|
+
): Promise<McpDiscoveryResult> {
|
|
34
|
+
const importsClaudeCode = opts.importsClaudeCode ?? true
|
|
35
|
+
const nebulaMcpPath = opts.nebulaMcpPath ?? join(homedir(), '.nebula', '.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(nebulaMcpPath, 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,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
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from 'node:child_process'
|
|
2
|
+
import type { McpServerStdio } from './types'
|
|
3
|
+
|
|
4
|
+
interface JsonRpcRequest {
|
|
5
|
+
jsonrpc: '2.0'
|
|
6
|
+
id: number
|
|
7
|
+
method: string
|
|
8
|
+
params?: unknown
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface JsonRpcResponse {
|
|
12
|
+
jsonrpc: '2.0'
|
|
13
|
+
id: number
|
|
14
|
+
result?: unknown
|
|
15
|
+
error?: { code: number; message: string; data?: unknown }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface JsonRpcNotification {
|
|
19
|
+
jsonrpc: '2.0'
|
|
20
|
+
method: string
|
|
21
|
+
params?: unknown
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type JsonRpcMessage = JsonRpcResponse | JsonRpcNotification
|
|
25
|
+
|
|
26
|
+
const PROTOCOL_VERSION = '2024-11-05'
|
|
27
|
+
const CLIENT_INFO = { name: 'nebula', version: '0.8.1' }
|
|
28
|
+
|
|
29
|
+
export class McpStdioClient {
|
|
30
|
+
private proc: ChildProcess | null = null
|
|
31
|
+
private nextId = 1
|
|
32
|
+
private buffer = ''
|
|
33
|
+
private readonly pending = new Map<
|
|
34
|
+
number,
|
|
35
|
+
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
|
|
36
|
+
>()
|
|
37
|
+
private starting: Promise<void> | null = null
|
|
38
|
+
|
|
39
|
+
constructor(public readonly server: McpServerStdio) {}
|
|
40
|
+
|
|
41
|
+
async ensureStarted(timeoutMs = 10_000): Promise<void> {
|
|
42
|
+
if (this.proc) return
|
|
43
|
+
if (this.starting) return this.starting
|
|
44
|
+
this.starting = this.spawnAndInitialize(timeoutMs)
|
|
45
|
+
try {
|
|
46
|
+
await this.starting
|
|
47
|
+
} finally {
|
|
48
|
+
this.starting = null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private async spawnAndInitialize(timeoutMs: number): Promise<void> {
|
|
53
|
+
const proc = spawn(this.server.command, this.server.args ?? [], {
|
|
54
|
+
env: { ...process.env, ...(this.server.env ?? {}) },
|
|
55
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
56
|
+
})
|
|
57
|
+
this.proc = proc
|
|
58
|
+
proc.stdout?.setEncoding('utf8')
|
|
59
|
+
proc.stdout?.on('data', chunk => this.onStdout(chunk as string))
|
|
60
|
+
proc.on('error', err => this.failAll(err))
|
|
61
|
+
proc.on('exit', () => this.failAll(new Error(`mcp server '${this.server.name}' exited`)))
|
|
62
|
+
// Initialize handshake
|
|
63
|
+
const initPromise = this.request('initialize', {
|
|
64
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
65
|
+
capabilities: {},
|
|
66
|
+
clientInfo: CLIENT_INFO,
|
|
67
|
+
})
|
|
68
|
+
const timeout = new Promise<never>((_, reject) =>
|
|
69
|
+
setTimeout(() => reject(new Error(`mcp init timeout (${timeoutMs}ms)`)), timeoutMs),
|
|
70
|
+
)
|
|
71
|
+
await Promise.race([initPromise, timeout])
|
|
72
|
+
this.notify('notifications/initialized', {})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async listTools(): Promise<{ name: string; description?: string; inputSchema?: unknown }[]> {
|
|
76
|
+
await this.ensureStarted()
|
|
77
|
+
const result = (await this.request('tools/list', {})) as {
|
|
78
|
+
tools?: { name: string; description?: string; inputSchema?: unknown }[]
|
|
79
|
+
}
|
|
80
|
+
return result.tools ?? []
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async callTool(name: string, args: unknown): Promise<unknown> {
|
|
84
|
+
await this.ensureStarted()
|
|
85
|
+
return await this.request('tools/call', { name, arguments: args })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
request(method: string, params: unknown): Promise<unknown> {
|
|
89
|
+
if (!this.proc?.stdin) throw new Error('mcp server not started')
|
|
90
|
+
const id = this.nextId++
|
|
91
|
+
const req: JsonRpcRequest = { jsonrpc: '2.0', id, method, params }
|
|
92
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
93
|
+
this.pending.set(id, { resolve, reject })
|
|
94
|
+
this.proc!.stdin!.write(`${JSON.stringify(req)}\n`, err => {
|
|
95
|
+
if (err) {
|
|
96
|
+
this.pending.delete(id)
|
|
97
|
+
reject(err)
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
notify(method: string, params: unknown): void {
|
|
104
|
+
if (!this.proc?.stdin) return
|
|
105
|
+
const note: JsonRpcNotification = { jsonrpc: '2.0', method, params }
|
|
106
|
+
this.proc.stdin.write(`${JSON.stringify(note)}\n`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
close(): void {
|
|
110
|
+
if (!this.proc) return
|
|
111
|
+
try {
|
|
112
|
+
this.proc.stdin?.end()
|
|
113
|
+
} catch {}
|
|
114
|
+
try {
|
|
115
|
+
this.proc.kill('SIGTERM')
|
|
116
|
+
} catch {}
|
|
117
|
+
this.proc = null
|
|
118
|
+
this.failAll(new Error('mcp client closed'))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private onStdout(chunk: string): void {
|
|
122
|
+
this.buffer += chunk
|
|
123
|
+
let nl = this.buffer.indexOf('\n')
|
|
124
|
+
while (nl !== -1) {
|
|
125
|
+
const line = this.buffer.slice(0, nl).trim()
|
|
126
|
+
this.buffer = this.buffer.slice(nl + 1)
|
|
127
|
+
nl = this.buffer.indexOf('\n')
|
|
128
|
+
if (!line) continue
|
|
129
|
+
let parsed: JsonRpcMessage
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(line) as JsonRpcMessage
|
|
132
|
+
} catch {
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
this.dispatch(parsed)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private dispatch(msg: JsonRpcMessage): void {
|
|
140
|
+
if ('id' in msg) {
|
|
141
|
+
const pending = this.pending.get(msg.id)
|
|
142
|
+
if (!pending) return
|
|
143
|
+
this.pending.delete(msg.id)
|
|
144
|
+
if (msg.error) pending.reject(new Error(`${msg.error.code}: ${msg.error.message}`))
|
|
145
|
+
else pending.resolve(msg.result ?? null)
|
|
146
|
+
}
|
|
147
|
+
// Notifications from the server are ignored for now.
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private failAll(err: Error): void {
|
|
151
|
+
for (const { reject } of this.pending.values()) reject(err)
|
|
152
|
+
this.pending.clear()
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/mcp/types.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server config shape (compatible with Claude Code's `.mcp.json`).
|
|
3
|
+
*
|
|
4
|
+
* stdio: `{ command, args?, env? }`. nebula spawns a subprocess and speaks
|
|
5
|
+
* JSON-RPC over its stdin/stdout.
|
|
6
|
+
*
|
|
7
|
+
* http: `{ type: 'http', url, headers? }`. nebula posts JSON-RPC to the URL.
|
|
8
|
+
* Phase 9.2 ships stdio only; HTTP lands in 9.4 polish.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface McpServerStdio {
|
|
12
|
+
/** Server name used as the prefix in tool ids (`mcp.<name>.<tool>`). */
|
|
13
|
+
name: string
|
|
14
|
+
type?: 'stdio'
|
|
15
|
+
command: string
|
|
16
|
+
args?: string[]
|
|
17
|
+
env?: Record<string, string>
|
|
18
|
+
/** Replacement value for `${CLAUDE_PLUGIN_ROOT}` in args. Set when scanning a plugin cache. */
|
|
19
|
+
pluginRoot?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface McpServerHttp {
|
|
23
|
+
name: string
|
|
24
|
+
type: 'http'
|
|
25
|
+
url: string
|
|
26
|
+
headers?: Record<string, string>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type McpServerConfig = McpServerStdio | McpServerHttp
|
|
30
|
+
|
|
31
|
+
export interface McpToolMeta {
|
|
32
|
+
/** server name */
|
|
33
|
+
server: string
|
|
34
|
+
/** original (unprefixed) tool name advertised by the MCP server */
|
|
35
|
+
toolName: string
|
|
36
|
+
description: string
|
|
37
|
+
inputSchema: unknown
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface McpDiscoveryResult {
|
|
41
|
+
servers: McpServerConfig[]
|
|
42
|
+
/** Source path the server was discovered from (debug output only). */
|
|
43
|
+
sources: { server: string; path: string }[]
|
|
44
|
+
}
|