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.
Files changed (109) hide show
  1. package/README.md +24 -0
  2. package/package.json +69 -0
  3. package/src/brain/compaction.ts +131 -0
  4. package/src/brain/frozen-prefix.ts +320 -0
  5. package/src/brain/history-persist.ts +154 -0
  6. package/src/brain/index.ts +43 -0
  7. package/src/brain/openai-brain.ts +533 -0
  8. package/src/brain/sanitize.ts +23 -0
  9. package/src/brain/stub.ts +20 -0
  10. package/src/brain/types.ts +129 -0
  11. package/src/chain.ts +75 -0
  12. package/src/claude-plugins/discovery.ts +152 -0
  13. package/src/claude-plugins/index.ts +6 -0
  14. package/src/claude-plugins/types.ts +38 -0
  15. package/src/commands/index.ts +16 -0
  16. package/src/commands/registry.ts +255 -0
  17. package/src/config.ts +213 -0
  18. package/src/economy/index.ts +6 -0
  19. package/src/events/index.ts +4 -0
  20. package/src/events/listeners.ts +37 -0
  21. package/src/events/queue.ts +63 -0
  22. package/src/events/router.ts +42 -0
  23. package/src/events/types.ts +28 -0
  24. package/src/format.ts +12 -0
  25. package/src/identity/agent-card.ts +110 -0
  26. package/src/identity/deployments.ts +20 -0
  27. package/src/identity/erc8004.ts +161 -0
  28. package/src/identity/index.ts +29 -0
  29. package/src/identity/keystore-blob.ts +60 -0
  30. package/src/identity/receipt.ts +27 -0
  31. package/src/identity/stub.ts +29 -0
  32. package/src/identity/types.ts +20 -0
  33. package/src/index.ts +372 -0
  34. package/src/locks.ts +233 -0
  35. package/src/mcp/discovery.ts +150 -0
  36. package/src/mcp/index.ts +10 -0
  37. package/src/mcp/manager.ts +110 -0
  38. package/src/mcp/stdio-client.ts +154 -0
  39. package/src/mcp/types.ts +44 -0
  40. package/src/memory/edit.ts +53 -0
  41. package/src/memory/encryption.ts +88 -0
  42. package/src/memory/fs-util.ts +15 -0
  43. package/src/memory/index-file.ts +74 -0
  44. package/src/memory/index-sync.ts +99 -0
  45. package/src/memory/index.ts +58 -0
  46. package/src/memory/list-tool.ts +105 -0
  47. package/src/memory/pack-blob.ts +120 -0
  48. package/src/memory/pack-gather.ts +112 -0
  49. package/src/memory/parser.ts +20 -0
  50. package/src/memory/read-tool.ts +198 -0
  51. package/src/memory/save-tool.ts +189 -0
  52. package/src/memory/scan.ts +63 -0
  53. package/src/memory/topic.ts +32 -0
  54. package/src/memory/types.ts +49 -0
  55. package/src/migration/index.ts +6 -0
  56. package/src/migration/option3-crypto.ts +127 -0
  57. package/src/operator/index.ts +9 -0
  58. package/src/operator/keychain.ts +53 -0
  59. package/src/operator/keystore-file.ts +33 -0
  60. package/src/operator/privkey-base.ts +60 -0
  61. package/src/operator/raw-privkey.ts +39 -0
  62. package/src/operator/signer.ts +46 -0
  63. package/src/operator/walletconnect.ts +454 -0
  64. package/src/pairing.ts +285 -0
  65. package/src/paths.ts +70 -0
  66. package/src/permission/dangerous.ts +108 -0
  67. package/src/permission/env-redact.ts +54 -0
  68. package/src/permission/index.ts +16 -0
  69. package/src/permission/path-guard.ts +114 -0
  70. package/src/permission/service.ts +191 -0
  71. package/src/plugins/context.ts +225 -0
  72. package/src/plugins/hooks.ts +81 -0
  73. package/src/plugins/index.ts +24 -0
  74. package/src/plugins/tool-search.ts +49 -0
  75. package/src/public/card.ts +67 -0
  76. package/src/runtime/activity.ts +29 -0
  77. package/src/runtime/index.ts +2 -0
  78. package/src/runtime/runtime.ts +113 -0
  79. package/src/sandbox/credentials.ts +25 -0
  80. package/src/sandbox/docker.ts +396 -0
  81. package/src/sandbox/factory.ts +99 -0
  82. package/src/sandbox/index.ts +15 -0
  83. package/src/sandbox/linux.ts +141 -0
  84. package/src/sandbox/local.ts +19 -0
  85. package/src/sandbox/macos.ts +71 -0
  86. package/src/sandbox/seatbelt-profile.ts +139 -0
  87. package/src/sandbox/types.ts +129 -0
  88. package/src/skills/index.ts +8 -0
  89. package/src/skills/scanner.ts +257 -0
  90. package/src/skills/triggers.ts +78 -0
  91. package/src/skills/types.ts +37 -0
  92. package/src/storage/encryption.ts +87 -0
  93. package/src/storage/factory.ts +31 -0
  94. package/src/storage/index.ts +11 -0
  95. package/src/storage/local-stub.ts +70 -0
  96. package/src/storage/sqlite.ts +95 -0
  97. package/src/storage/types.ts +21 -0
  98. package/src/tools/escalation.ts +200 -0
  99. package/src/tools/index.ts +11 -0
  100. package/src/tools/registry.ts +152 -0
  101. package/src/tools/types.ts +65 -0
  102. package/src/tools/zod-helpers.ts +36 -0
  103. package/src/tools/zod-schema.ts +99 -0
  104. package/src/wallet/drain.ts +79 -0
  105. package/src/wallet/eoa.ts +51 -0
  106. package/src/wallet/index.ts +47 -0
  107. package/src/wallet/keystore.ts +50 -0
  108. package/src/wallet/operator-keystore-crypto.ts +530 -0
  109. 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
+ }
@@ -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
+ }
@@ -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
+ }