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 +4 -2
- package/src/claude-sessions.mjs +36 -24
- package/src/claude-transcript.mjs +114 -0
- package/src/cli.mjs +11 -7
- package/src/index.mjs +5 -3
- package/src/mcp/launch.mjs +61 -0
- package/src/mcp/messages.mjs +49 -0
- package/src/mcp/servers.mjs +141 -0
- package/src/mcp/tools.mjs +53 -0
- package/src/session-tools.mjs +1 -1
- package/src/version.mjs +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kira-glasses-bridge",
|
|
3
|
-
"version": "2.
|
|
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",
|
package/src/claude-sessions.mjs
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
63
|
-
const
|
|
64
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
87
|
+
const items = await itemsFor(sessionId, cwd)
|
|
87
88
|
const n = Math.min(Math.max(Number(turns) || 6, 1), 30)
|
|
88
|
-
|
|
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)
|
|
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)
|
|
85
|
-
|
|
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
|
+
}
|
package/src/session-tools.mjs
CHANGED
|
@@ -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
|
}
|
package/src/version.mjs
ADDED