kira-glasses-bridge 2.34.0 → 2.36.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-glasses-bridge",
3
- "version": "2.34.0",
3
+ "version": "2.36.0",
4
4
  "description": "Connects your Mac to Kira, the assistant for Even G2 glasses: Claude Code sessions, files, folders and apps, by voice.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,8 +19,10 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@anthropic-ai/claude-agent-sdk": "^0.3.278",
22
+ "@modelcontextprotocol/sdk": "^1.30.0",
22
23
  "smol-toml": "1.9.0",
23
- "ws": "^8.18.0"
24
+ "ws": "^8.18.0",
25
+ "zod": "^4.6.5"
24
26
  },
25
27
  "keywords": [
26
28
  "even-realities",
@@ -5,29 +5,22 @@ import { getSessionInfo, getSessionMessages, listSessions as sdkListSessions } f
5
5
  import { open, readdir, stat } from 'node:fs/promises'
6
6
  import { homedir } from 'node:os'
7
7
  import { basename, join } from 'node:path'
8
+ import { doingOf, itemsOf, lastTurns, turnsOf } from './claude-transcript.mjs'
8
9
 
9
10
  const ACTIVE_WINDOW_MS = 3 * 60 * 1000
11
+ const TEXT_CAP = 2000
10
12
 
11
- function textOf(message) {
12
- const c = message?.content
13
- if (typeof c === 'string') return c
14
- if (Array.isArray(c)) return c.filter(b => b?.type === 'text').map(b => b.text).join('\n')
15
- return ''
16
- }
17
-
18
- /** The conversation as said: user and Claude text, without tool results, system blocks or subagents. */
19
- async function turnsOf(sessionId, dir) {
13
+ /** The session as it happened: its turns and the tool calls between them (claude-transcript.mjs). */
14
+ async function itemsFor(sessionId, dir) {
20
15
  const messages = await getSessionMessages(sessionId, dir ? { dir } : undefined)
21
- const turns = []
22
- for (const m of messages) {
23
- if (m.parent_tool_use_id) continue
24
- const text = textOf(m.message).trim()
25
- if (!text || text.startsWith('<')) continue // tool results / system blocks
26
- turns.push({ role: m.type === 'user' ? 'user' : 'claude', text, at: m.timestamp ?? null })
27
- }
28
- return turns
16
+ return itemsOf(messages, dir ?? homedir())
29
17
  }
30
18
 
19
+ /** The branch, unless the checkout is detached ("HEAD"). */
20
+ const branchOf = s => (s.gitBranch && s.gitBranch !== 'HEAD' ? s.gitBranch : null)
21
+
22
+ const capText = item => (item.kind === 'turn' ? { ...item, text: item.text.slice(0, TEXT_CAP) } : item)
23
+
31
24
  /**
32
25
  * The SDK reads a session's folder from the start of its transcript, and misses it when the
33
26
  * transcript opens with large snapshot entries. Then: the first "cwd" in the first MB of the file.
@@ -59,18 +52,22 @@ export async function listSessions(max = 10) {
59
52
  for (const s of await sdkListSessions({ limit: n })) {
60
53
  try {
61
54
  const cwd = await cwdOf(s)
62
- const turns = await turnsOf(s.sessionId, cwd)
63
- const lastUser = [...turns].reverse().find(t => t.role === 'user')
64
- const lastClaude = [...turns].reverse().find(t => t.role === 'claude')
55
+ const items = await itemsFor(s.sessionId, cwd)
56
+ const turns = turnsOf(items)
57
+ const lastUser = turns.findLast(t => t.role === 'user')
58
+ const lastClaude = turns.findLast(t => t.role === 'claude')
59
+ const active = activeOf(s)
65
60
  sessions.push({
66
61
  session_id: s.sessionId,
67
62
  title: titleOf(s) ?? (lastUser ? lastUser.text.slice(0, 60) : '(untitled)'),
68
63
  project: cwd ? cwd.split('/').slice(-2).join('/') : null,
69
64
  cwd,
70
- active: activeOf(s),
65
+ active,
71
66
  last_active_at: new Date(s.lastModified).toISOString(),
72
67
  last_user_message: lastUser ? lastUser.text.slice(0, 200) : null,
73
68
  last_claude_message: lastClaude ? lastClaude.text.slice(0, 200) : null,
69
+ branch: branchOf(s),
70
+ doing: active ? doingOf(items) : null,
74
71
  })
75
72
  } catch {
76
73
  /* unreadable transcript */
@@ -79,13 +76,28 @@ export async function listSessions(max = 10) {
79
76
  return { count: sessions.length, sessions }
80
77
  }
81
78
 
82
- export async function readSession(sessionId, turns = 6) {
79
+ /**
80
+ * A session's last `turns` turns; with `items`, the tool calls between them too (the phone asks for
81
+ * them; Kira's own reads don't, to keep them short).
82
+ */
83
+ export async function readSession(sessionId, turns = 6, { items: withItems = false } = {}) {
83
84
  const info = await getSessionInfo(sessionId).catch(() => undefined)
84
85
  if (!info) return { error: `No session ${sessionId} on this machine` }
85
86
  const cwd = await cwdOf(info)
86
- const all = await turnsOf(sessionId, cwd)
87
+ const items = await itemsFor(sessionId, cwd)
87
88
  const n = Math.min(Math.max(Number(turns) || 6, 1), 30)
88
- return { session_id: info.sessionId, title: titleOf(info), cwd, active: activeOf(info), last_active_at: new Date(info.lastModified).toISOString(), turns: all.slice(-n).map(t => ({ ...t, text: t.text.slice(0, 2000) })) }
89
+ const recent = lastTurns(items, n).map(capText)
90
+ return {
91
+ session_id: info.sessionId,
92
+ title: titleOf(info),
93
+ cwd,
94
+ active: activeOf(info),
95
+ last_active_at: new Date(info.lastModified).toISOString(),
96
+ turns: turnsOf(recent),
97
+ ...(withItems ? { items: recent } : {}),
98
+ branch: branchOf(info),
99
+ doing: activeOf(info) ? doingOf(items) : null,
100
+ }
89
101
  }
90
102
 
91
103
  /** Project folders Claude Code has been used in (that still exist), newest first. */
@@ -0,0 +1,114 @@
1
+ // A Claude Code transcript (the Agent SDK's session messages) as the conversation shows it: what was
2
+ // said and, between the turns, the tool calls Claude made, described the way its jobs describe them
3
+ // (agents/claude-calls.mjs). The shape is @kira/genui's claudeSessionItems.ts.
4
+ import { describeAction, short } from './agents/claude-calls.mjs'
5
+
6
+ /** How much of an edit, a write or a command a call carries for its detail. */
7
+ const DETAIL_CAP = 1500
8
+
9
+ function textOf(message) {
10
+ const c = message?.content
11
+ if (typeof c === 'string') return c
12
+ if (Array.isArray(c)) return c.filter(b => b?.type === 'text').map(b => b.text).join('\n')
13
+ return ''
14
+ }
15
+
16
+ /** An edit's lines: the old and new text without the lines they share at the start and the end. */
17
+ function diffCounts(old, now) {
18
+ const a = String(old ?? '').split('\n')
19
+ const b = String(now ?? '').split('\n')
20
+ let start = 0
21
+ while (start < a.length && start < b.length && a[start] === b[start]) start++
22
+ let end = 0
23
+ while (end < a.length - start && end < b.length - start && a[a.length - 1 - end] === b[b.length - 1 - end]) end++
24
+ return { adds: b.length - start - end, dels: a.length - start - end }
25
+ }
26
+
27
+ /** Lines an edit or a write adds and takes out; null for any other call. */
28
+ export function lineCounts(name, input) {
29
+ const i = input ?? {}
30
+ if (name === 'Write') return { adds: i.content ? String(i.content).split('\n').length : 0, dels: 0 }
31
+ if (name === 'Edit') return diffCounts(i.old_string, i.new_string)
32
+ if (name !== 'MultiEdit') return null
33
+ const each = (i.edits ?? []).map(e => diffCounts(e.old_string, e.new_string))
34
+ return { adds: each.reduce((n, c) => n + c.adds, 0), dels: each.reduce((n, c) => n + c.dels, 0) }
35
+ }
36
+
37
+ /**
38
+ * A change's detail, shorter than a job's: the conversation polls it every few seconds. A command
39
+ * has none (its target is the command).
40
+ */
41
+ function shortDetail(d) {
42
+ const cut = e => ({ ...e, old: short(e.old, DETAIL_CAP), new: short(e.new, DETAIL_CAP) })
43
+ if (d.edit) return { edit: cut(d.edit) }
44
+ if (d.edits) return { edits: d.edits.slice(0, 5).map(cut) }
45
+ return d.content !== undefined ? { content: short(d.content, DETAIL_CAP) } : undefined
46
+ }
47
+
48
+ /** One tool_use block as a call. */
49
+ function toolCall(block, cwd) {
50
+ const act = describeAction(block.name, block.input, cwd)
51
+ const detail = act.detail ? shortDetail(act.detail) : undefined
52
+ return {
53
+ id: block.id,
54
+ type: act.type,
55
+ label: act.label,
56
+ target: act.target,
57
+ ...(lineCounts(block.name, block.input) ?? {}),
58
+ ...(detail ? { detail } : {}),
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Everything said and done, in order: the user's and Claude's turns, and each run of tool calls
64
+ * between them as one item. Subagents' own messages and system blocks (text starting "<") are left out.
65
+ */
66
+ export function itemsOf(messages, cwd) {
67
+ const items = []
68
+ const calls = new Map()
69
+ let group = null
70
+ const say = (role, text, at) => {
71
+ group = null
72
+ if (text && !text.startsWith('<')) items.push({ kind: 'turn', role, text, at })
73
+ }
74
+ for (const m of messages) {
75
+ if (m.parent_tool_use_id) continue
76
+ const at = m.timestamp ?? null
77
+ const content = Array.isArray(m.message?.content) ? m.message.content : []
78
+ if (m.type === 'assistant') {
79
+ for (const b of content) {
80
+ if (b?.type === 'text') say('claude', String(b.text ?? '').trim(), at)
81
+ if (b?.type !== 'tool_use') continue
82
+ if (!group) items.push((group = { kind: 'tools', at, calls: [] }))
83
+ const call = toolCall(b, cwd)
84
+ calls.set(call.id, call)
85
+ group.calls.push(call)
86
+ }
87
+ } else if (m.type === 'user') {
88
+ for (const b of content) {
89
+ if (b?.type === 'tool_result' && b.is_error && calls.has(b.tool_use_id)) calls.get(b.tool_use_id).failed = true
90
+ }
91
+ const text = textOf(m.message).trim()
92
+ if (text) say('user', text, at)
93
+ }
94
+ }
95
+ return items
96
+ }
97
+
98
+ /** Just the turns (what read_claude_session has always returned). */
99
+ export const turnsOf = items => items.filter(i => i.kind === 'turn').map(({ kind, ...turn }) => turn)
100
+
101
+ /** The last `n` turns and the tool calls among and after them. */
102
+ export function lastTurns(items, n) {
103
+ let seen = 0
104
+ for (let i = items.length - 1; i >= 0; i--) if (items[i].kind === 'turn' && ++seen === n) return items.slice(i)
105
+ return items
106
+ }
107
+
108
+ /** The last call when nothing was said after it: what the session is doing (or did last). */
109
+ export function doingOf(items) {
110
+ const last = items.at(-1)
111
+ if (last?.kind !== 'tools') return null
112
+ const { detail, ...call } = last.calls.at(-1)
113
+ return call
114
+ }
package/src/cli.mjs CHANGED
@@ -4,14 +4,11 @@
4
4
  // npx kira-glasses-bridge --token <token> save the token and install the background service
5
5
  // kira-bridge run run in the foreground (what the service runs)
6
6
  // kira-bridge install | uninstall | status | logs
7
- import { createRequire } from 'node:module'
8
7
  import { spawnSync } from 'node:child_process'
9
8
  import { resolve as resolveConfig, writeConfig, readConfig, configExists, LOG_PATH, DEFAULT_URL } from './config.mjs'
10
9
  import { install, uninstall, status } from './service.mjs'
11
10
  import { startBridge } from './index.mjs'
12
-
13
- const require = createRequire(import.meta.url)
14
- const { version } = require('../package.json')
11
+ import { BRIDGE_VERSION as version } from './version.mjs'
15
12
 
16
13
  const args = process.argv.slice(2)
17
14
  const flags = {}
@@ -68,7 +65,9 @@ async function main() {
68
65
  case 'run':
69
66
  return startBridge(resolveConfig(flags))
70
67
  case 'install': {
71
- if (!resolveConfig(flags).token) throw new Error('no token saved yet; run: npx kira-glasses-bridge --token <token>')
68
+ if (!resolveConfig(flags).token) {
69
+ throw new Error('no token saved yet; run: npx kira-glasses-bridge --token <token>')
70
+ }
72
71
  const r = install()
73
72
  console.log(`Installed and started (${r.plist}). Log: ${r.log}`)
74
73
  return
@@ -81,8 +80,13 @@ async function main() {
81
80
  const c = resolveConfig(flags)
82
81
  console.log(`token: ${c.token ? 'saved' : 'missing'}`)
83
82
  console.log(`server: ${c.url}`)
84
- if (!s.installed) console.log('service: not installed (run `kira-bridge install`, or `kira-bridge run` for the foreground)')
85
- else console.log(`service: ${s.running ? 'running' : 'installed but not running'}\nlog: ${s.log}${s.lastLog ? '\n\n' + s.lastLog : ''}`)
83
+ if (!s.installed) {
84
+ console.log('service: not installed (run `kira-bridge install`, or `kira-bridge run` for the foreground)')
85
+ return
86
+ }
87
+ console.log(`service: ${s.running ? 'running' : 'installed but not running'}`)
88
+ console.log(`log: ${s.log}`)
89
+ if (s.lastLog) console.log(`\n${s.lastLog}`)
86
90
  return
87
91
  }
88
92
  case 'logs':
package/src/index.mjs CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // Kira bridge. Connects outbound to the Kira API and runs tools on this
3
3
  // machine on the user's behalf. Env: KIRA_BRIDGE_URL, KIRA_BRIDGE_TOKEN.
4
- import { createRequire } from 'node:module'
5
4
  import { agentTools } from './agent-files.mjs'
6
5
  import { appolloBridgeTools } from './appollo/index.mjs'
7
6
  import { AGENTS } from './agents/index.mjs'
@@ -15,9 +14,10 @@ import { jobTools, runningJobIds } from './job-tools.mjs'
15
14
  import { macTools } from './mac-tools.mjs'
16
15
  import { sessionTools } from './session-tools.mjs'
17
16
  import { skillTools } from './skill-files.mjs'
17
+ import { closeAll as closeMcpServers } from './mcp/servers.mjs'
18
+ import { mcpTools } from './mcp/tools.mjs'
18
19
  import { initTerminals } from './terminals.mjs'
19
-
20
- const { version: BRIDGE_VERSION } = createRequire(import.meta.url)('../package.json')
20
+ import { BRIDGE_VERSION } from './version.mjs'
21
21
 
22
22
  const tools = {
23
23
  ...fileTools,
@@ -30,6 +30,7 @@ const tools = {
30
30
  ...skillTools,
31
31
  ...agentTools,
32
32
  ...appolloBridgeTools,
33
+ ...mcpTools,
33
34
  }
34
35
 
35
36
  /** What this bridge build implements, told to the API after its hello. */
@@ -61,6 +62,7 @@ export function startBridge(config, hostHooks = {}) {
61
62
  /** Disconnect and stop reconnecting (the app calls this on sign-out or a token change). */
62
63
  export function stopBridge() {
63
64
  disconnect()
65
+ closeMcpServers()
64
66
  }
65
67
 
66
68
  export const bridgeVersion = BRIDGE_VERSION
@@ -0,0 +1,61 @@
1
+ // How a local MCP server is launched: its arguments checked, its command found, the environment it
2
+ // gets and the key its warm process is kept under. Pure, so the decisions are tested (mcp-launch.test).
3
+ import { createHash } from 'node:crypto'
4
+ import { delimiter, isAbsolute, join } from 'node:path'
5
+
6
+ /** Where installers put command-line tools; launchd gives the bridge a PATH without most of them. */
7
+ const INSTALL_DIRS = ['.cargo/bin', '.local/bin']
8
+ const SYSTEM_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin']
9
+
10
+ /** `{ server: { command, args }, env }` from a tool call, or a plain-English error. */
11
+ export function parseServerArgs(args) {
12
+ const server = args?.server
13
+ const command = typeof server?.command === 'string' ? server.command.trim() : ''
14
+ if (!command) throw new Error('server.command is required')
15
+ const list = server.args ?? []
16
+ if (!Array.isArray(list) || list.some(a => typeof a !== 'string')) {
17
+ throw new Error('server.args must be a list of strings')
18
+ }
19
+ const env = args.env ?? {}
20
+ const envOk = typeof env === 'object' && !Array.isArray(env) && Object.values(env).every(v => typeof v === 'string')
21
+ if (!envOk) throw new Error('env must map names to strings')
22
+ return { server: { command, args: list }, env }
23
+ }
24
+
25
+ /** `{ command }` from mcp_installed, or a plain-English error. */
26
+ export function parseCommandArg(args) {
27
+ const command = typeof args?.command === 'string' ? args.command.trim() : ''
28
+ if (!command) throw new Error('command is required')
29
+ return command
30
+ }
31
+
32
+ /** The directories searched for a command, in order: the bridge's PATH, then the usual install places. */
33
+ export function searchDirs(pathEnv, home) {
34
+ const fromPath = String(pathEnv ?? '').split(delimiter)
35
+ const installs = home ? INSTALL_DIRS.map(dir => join(home, dir)) : []
36
+ const all = [...fromPath, ...installs, ...SYSTEM_DIRS].filter(Boolean)
37
+ return [...new Set(all)]
38
+ }
39
+
40
+ /** The command's full path (the first executable match), or null when it isn't installed. */
41
+ export function resolveCommand(command, dirs, isExecutable) {
42
+ if (command.includes('/')) return isAbsolute(command) && isExecutable(command) ? command : null
43
+ return dirs.map(dir => join(dir, command)).find(isExecutable) ?? null
44
+ }
45
+
46
+ /** The child's environment: a safe base plus what the caller passed, never the bridge's own secrets. */
47
+ export function childEnv(base, passed) {
48
+ return { PATH: base.path, HOME: base.home, LANG: base.lang || 'en_US.UTF-8', ...passed }
49
+ }
50
+
51
+ /** The pool key: same command, args and env share a process; any change starts a fresh one. */
52
+ export function serverKey(server, env) {
53
+ const entries = Object.entries(env).sort(([a], [b]) => a.localeCompare(b))
54
+ const identity = JSON.stringify([server.command, server.args, entries])
55
+ return createHash('sha256').update(identity).digest('hex')
56
+ }
57
+
58
+ /** The same server under another key (its env changed): the old process is replaced. */
59
+ export function serverId(server) {
60
+ return JSON.stringify([server.command, server.args])
61
+ }
@@ -0,0 +1,49 @@
1
+ // What a local MCP server's failures say, and when a warm one is closed. Pure (mcp-messages.test).
2
+ // Its environment carries the person's keys, so every message passes through redact() first.
3
+ import { basename } from 'node:path'
4
+
5
+ /** Shorter values ("1", "yes") would blank out ordinary words; real secrets are longer. */
6
+ const MIN_SECRET_LENGTH = 4
7
+ const TAIL_LINES = 5
8
+
9
+ /** The command's name as people know it ("kraken", not "/Users/…/.cargo/bin/kraken"). */
10
+ export function commandName(command) {
11
+ return basename(command)
12
+ }
13
+
14
+ export function notInstalledMessage(command) {
15
+ return `not_installed: ${commandName(command)} isn't installed on this Mac`
16
+ }
17
+
18
+ /** `text` with every env value replaced, longest first so one secret inside another goes whole. */
19
+ export function redact(text, env) {
20
+ const secrets = Object.values(env)
21
+ .filter(v => v.length >= MIN_SECRET_LENGTH)
22
+ .sort((a, b) => b.length - a.length)
23
+ return secrets.reduce((out, secret) => out.split(secret).join('***'), String(text))
24
+ }
25
+
26
+ /** The last few non-empty lines of what the server wrote to stderr. */
27
+ export function lastLines(text, count = TAIL_LINES) {
28
+ const lines = String(text)
29
+ .split('\n')
30
+ .map(line => line.trimEnd())
31
+ .filter(Boolean)
32
+ return lines.slice(-count).join('\n')
33
+ }
34
+
35
+ /** A server that stopped or never finished its handshake: why, and what it last said. */
36
+ export function stoppedMessage(command, { reason, stderr }, env) {
37
+ const tail = lastLines(stderr)
38
+ const said = tail ? `. It said:\n${tail}` : ''
39
+ return redact(`${commandName(command)} stopped (${reason})${said}`, env)
40
+ }
41
+
42
+ export function timeoutMessage(command, ms) {
43
+ return `${commandName(command)} didn't answer within ${Math.round(ms / 1000)}s`
44
+ }
45
+
46
+ /** The warm servers to close: no call running or waiting (`busy` counts them) and unused for `idleMs`. */
47
+ export function idleKeys(entries, now, idleMs) {
48
+ return entries.filter(e => !e.busy && now - e.lastUsed >= idleMs).map(e => e.key)
49
+ }
@@ -0,0 +1,141 @@
1
+ // Local (stdio) MCP servers, kept warm: one process per command, args and env, reached through the
2
+ // official MCP client. Calls to one server take turns; an idle one closes, a crashed one restarts on
3
+ // next use. What the API's broker reaches through the bridge for servers that only run locally.
4
+ import { accessSync, constants } from 'node:fs'
5
+ import { homedir } from 'node:os'
6
+ import { delimiter } from 'node:path'
7
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
8
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
9
+ import { log } from '../host.mjs'
10
+ import { BRIDGE_VERSION } from '../version.mjs'
11
+ import { childEnv, resolveCommand, searchDirs, serverId, serverKey } from './launch.mjs'
12
+ import { commandName, idleKeys, notInstalledMessage, redact, stoppedMessage, timeoutMessage } from './messages.mjs'
13
+
14
+ /** Under the API's 25-second wait for a bridge call, so the person hears why rather than a timeout. */
15
+ const CALL_TIMEOUT_MS = 20_000
16
+ const IDLE_MS = 5 * 60_000
17
+ const SWEEP_MS = 60_000
18
+ const STDERR_KEEP = 4000
19
+ const TIMEOUT_CODE = -32001
20
+
21
+ const pool = new Map()
22
+ let sweeper = null
23
+
24
+ /** Where this Mac has `command` (null when it doesn't), and the directories that were searched. */
25
+ export function locate(command) {
26
+ const dirs = searchDirs(process.env.PATH, homedir())
27
+ return { path: resolveCommand(command, dirs, isExecutable), dirs }
28
+ }
29
+
30
+ function isExecutable(file) {
31
+ try {
32
+ accessSync(file, constants.X_OK)
33
+ return true
34
+ } catch {
35
+ return false
36
+ }
37
+ }
38
+
39
+ /** Run `work(client, options)` on the server's warm process, after any call already waiting on it. */
40
+ export function withServer(server, env, work) {
41
+ const entry = acquire(server, env)
42
+ // Counts calls waiting as well as running, so a server with work queued is never closed under it.
43
+ entry.busy++
44
+ const run = entry.queue.then(() => use(entry, work))
45
+ entry.queue = run.catch(() => {})
46
+ return run
47
+ }
48
+
49
+ /** Close every server (the bridge is stopping). */
50
+ export function closeAll() {
51
+ for (const entry of [...pool.values()]) close(entry, 'bridge stopped')
52
+ }
53
+
54
+ async function use(entry, work) {
55
+ try {
56
+ await entry.ready
57
+ return await work(entry.client, { timeout: CALL_TIMEOUT_MS })
58
+ } catch (err) {
59
+ throw failure(entry, err)
60
+ } finally {
61
+ entry.busy--
62
+ entry.lastUsed = Date.now()
63
+ }
64
+ }
65
+
66
+ /** A stopped or wedged server is dropped (the next call starts it again); a refused call is just reported. */
67
+ function failure(entry, err) {
68
+ const { command, env } = entry
69
+ if (err?.code === TIMEOUT_CODE) {
70
+ close(entry, 'timed out')
71
+ return new Error(timeoutMessage(command, CALL_TIMEOUT_MS))
72
+ }
73
+ if (entry.closed || !entry.connected) {
74
+ // "Connection closed" is the SDK's way of saying the process ended; anything else is its own reason.
75
+ const reason = entry.closed ? 'it exited' : (err?.message ?? 'it failed to start')
76
+ close(entry, 'failed')
77
+ return new Error(stoppedMessage(command, { reason, stderr: entry.stderr }, env))
78
+ }
79
+ return new Error(redact(err?.message ?? String(err), env))
80
+ }
81
+
82
+ function acquire(server, env) {
83
+ const key = serverKey(server, env)
84
+ const warm = pool.get(key)
85
+ if (warm && !warm.closed) return warm
86
+ const { path: command, dirs } = locate(server.command)
87
+ if (!command) throw new Error(notInstalledMessage(server.command))
88
+ replaceOlder(server)
89
+ const entry = start(key, server, command, childEnv({ path: dirs.join(delimiter), home: homedir() }, env), env)
90
+ pool.set(key, entry)
91
+ sweeper ??= setInterval(sweep, SWEEP_MS).unref()
92
+ return entry
93
+ }
94
+
95
+ /** The same server under an old env (a key was changed): closed unless it's mid-call (then it idles out). */
96
+ function replaceOlder(server) {
97
+ const id = serverId(server)
98
+ for (const entry of pool.values()) if (entry.id === id && !entry.busy) close(entry, 'replaced')
99
+ }
100
+
101
+ function start(key, server, command, childEnvironment, env) {
102
+ // The SDK adds a few harmless variables of its own (USER, SHELL, TERM); nothing else of the bridge's.
103
+ const transport = new StdioClientTransport({ command, args: server.args, env: childEnvironment, stderr: 'pipe' })
104
+ const client = new Client({ name: 'kira-bridge', version: BRIDGE_VERSION })
105
+ const entry = { key, id: serverId(server), command, env, client, stderr: '', busy: 0, lastUsed: Date.now() }
106
+ entry.queue = Promise.resolve()
107
+ entry.closed = false
108
+ entry.connected = false
109
+ transport.stderr?.on('data', chunk => {
110
+ entry.stderr = (entry.stderr + chunk).slice(-STDERR_KEEP)
111
+ })
112
+ client.onclose = () => {
113
+ if (!entry.closed) log(`local MCP ${commandName(command)} exited`)
114
+ forget(entry)
115
+ }
116
+ entry.ready = client.connect(transport, { timeout: CALL_TIMEOUT_MS }).then(() => {
117
+ entry.connected = true
118
+ })
119
+ entry.ready.catch(() => {}) // a failed start is reported to the call waiting on it
120
+ log(`local MCP ${commandName(command)} started`)
121
+ return entry
122
+ }
123
+
124
+ function close(entry, why) {
125
+ if (entry.closed) return
126
+ log(`local MCP ${commandName(entry.command)} closed (${why})`)
127
+ forget(entry)
128
+ entry.client.close().catch(() => {})
129
+ }
130
+
131
+ function forget(entry) {
132
+ entry.closed = true
133
+ if (pool.get(entry.key) === entry) pool.delete(entry.key)
134
+ if (pool.size || !sweeper) return
135
+ clearInterval(sweeper)
136
+ sweeper = null
137
+ }
138
+
139
+ function sweep() {
140
+ for (const key of idleKeys([...pool.values()], Date.now(), IDLE_MS)) close(pool.get(key), 'idle')
141
+ }
@@ -0,0 +1,53 @@
1
+ // The bridge's tools for local MCP servers: whether one is installed (mcp_installed), what it offers
2
+ // (mcp_list) and calling one of its tools (mcp_call). Results are the server's own MCP shapes; Kira's
3
+ // broker reads them as it would a hosted one's.
4
+ import { parseCommandArg, parseServerArgs } from './launch.mjs'
5
+ import { locate, withServer } from './servers.mjs'
6
+
7
+ /** Every page of the server's tools, as the API needs them. */
8
+ async function allTools(client, options) {
9
+ const tools = []
10
+ let cursor
11
+ do {
12
+ const page = await client.listTools(cursor ? { cursor } : undefined, options)
13
+ tools.push(
14
+ ...page.tools.map(({ name, description, inputSchema, annotations }) => ({
15
+ name,
16
+ description,
17
+ inputSchema,
18
+ annotations,
19
+ })),
20
+ )
21
+ cursor = page.nextCursor
22
+ } while (cursor)
23
+ return tools
24
+ }
25
+
26
+ export const mcpTools = {
27
+ /** `{ command }` → `{ installed, path? }`: found the way mcp_list and mcp_call find it, nothing started. */
28
+ async mcp_installed(args) {
29
+ const { path } = locate(parseCommandArg(args))
30
+ return path ? { installed: true, path } : { installed: false }
31
+ },
32
+
33
+ /** `{ server: { command, args }, env }` → `{ tools, instructions? }`. */
34
+ async mcp_list(args) {
35
+ const { server, env } = parseServerArgs(args)
36
+ return withServer(server, env, async (client, options) => {
37
+ const tools = await allTools(client, options)
38
+ const instructions = client.getInstructions()
39
+ return instructions ? { tools, instructions } : { tools }
40
+ })
41
+ },
42
+
43
+ /** `{ server, env, tool, arguments }` → the server's CallToolResult, as it returned it. */
44
+ async mcp_call(args) {
45
+ const { server, env } = parseServerArgs(args)
46
+ const name = typeof args.tool === 'string' ? args.tool : ''
47
+ if (!name) throw new Error('tool is required')
48
+ const toolArgs = args.arguments ?? {}
49
+ return withServer(server, env, (client, options) =>
50
+ client.callTool({ name, arguments: toolArgs }, undefined, options),
51
+ )
52
+ },
53
+ }
@@ -15,6 +15,6 @@ export const sessionTools = {
15
15
  return listSessions(args.max)
16
16
  },
17
17
  async read_claude_session(args) {
18
- return readSession(String(args.session_id ?? ''), args.turns)
18
+ return readSession(String(args.session_id ?? ''), args.turns, { items: args.items === true })
19
19
  },
20
20
  }
@@ -0,0 +1,4 @@
1
+ // This bridge build's version (from its package.json), for the API, the CLI and the MCP servers it starts.
2
+ import { createRequire } from 'node:module'
3
+
4
+ export const { version: BRIDGE_VERSION } = createRequire(import.meta.url)('../package.json')