kira-glasses-bridge 2.32.0 → 2.35.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/agents/claude-calls.mjs +1 -0
- package/src/agents/claude.mjs +17 -1
- package/src/agents/codex-items.mjs +2 -0
- package/src/agents/codex-subagents.mjs +60 -0
- package/src/agents/codex.mjs +13 -3
- package/src/agents/protocol.mjs +6 -0
- package/src/agents/subagents.mjs +121 -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/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.35.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",
|
|
@@ -58,6 +58,7 @@ export function describeAction(name, input, cwd) {
|
|
|
58
58
|
return act('fetch', 'Fetch', short(i.url, 200))
|
|
59
59
|
case 'WebSearch':
|
|
60
60
|
return act('fetch', 'Search the web', short(i.query, 160))
|
|
61
|
+
case 'Agent':
|
|
61
62
|
case 'Task':
|
|
62
63
|
return act('delegate', 'Subtask', short(i.description ?? i.prompt, 160))
|
|
63
64
|
case 'TodoWrite':
|
package/src/agents/claude.mjs
CHANGED
|
@@ -15,6 +15,8 @@ import { planLimits } from './claude-local.mjs'
|
|
|
15
15
|
import { contextFrom, limitsFromEvent, rejectedWindow, stoppedBy } from './claude-usage.mjs'
|
|
16
16
|
import { sdkOptionsFor } from './settings.mjs'
|
|
17
17
|
import { kiraPluginOptions } from '../kira-plugin.mjs'
|
|
18
|
+
import { git } from '../git.mjs'
|
|
19
|
+
import { isAgentCall, subagentFeed } from './subagents.mjs'
|
|
18
20
|
|
|
19
21
|
/** Kira's MCP server as Claude Code connects to it: Streamable HTTP, the token as the bearer. */
|
|
20
22
|
const kiraServer = ({ url, token }) => ({ type: 'http', url, headers: { Authorization: `Bearer ${token}` } })
|
|
@@ -100,6 +102,9 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
|
|
|
100
102
|
const env = { ...process.env, CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1', ENABLE_CLAUDEAI_MCP_SERVERS: 'false' }
|
|
101
103
|
/** Background tasks the model still managed to start (an older CLI ignoring the flag): tool_use id -> description. */
|
|
102
104
|
const orphans = new Map()
|
|
105
|
+
/** The agents it starts, each with where it works (subagents.mjs). */
|
|
106
|
+
const branchOf = async path => (await git(path, ['branch', '--show-current']))?.trim() || null
|
|
107
|
+
const agents = subagentFeed(e => io.emit(e), branchOf)
|
|
103
108
|
|
|
104
109
|
try {
|
|
105
110
|
const q = query({
|
|
@@ -144,6 +149,9 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
|
|
|
144
149
|
if (m.is_backgrounded && m.tool_use_id) orphans.set(m.tool_use_id, m.description ?? m.subagent_type ?? 'task')
|
|
145
150
|
} else if (m.type === 'system' && m.subtype === 'task_notification') {
|
|
146
151
|
if (m.tool_use_id) orphans.delete(m.tool_use_id)
|
|
152
|
+
if (m.tool_use_id) agents.ended(m.tool_use_id, m.status, m.summary)
|
|
153
|
+
} else if (m.type === 'system' && m.subtype === 'task_progress') {
|
|
154
|
+
if (m.tool_use_id) agents.progress(m.tool_use_id, m.summary)
|
|
147
155
|
} else if (m.type === 'assistant') {
|
|
148
156
|
// A new turn of the main thread: how full the context is now, as /context counts it (no model call).
|
|
149
157
|
if (!nested && m.message?.id && m.message.id !== turn) {
|
|
@@ -153,7 +161,13 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
|
|
|
153
161
|
for (const b of m.message?.content ?? []) {
|
|
154
162
|
// A subagent's narration is for its parent, not the user; its actions still show what is going on while the Subtask runs.
|
|
155
163
|
if (b.type === 'text' && b.text?.trim() && !nested) io.emit({ kind: 'message', text: short(b.text, TEXT_CAP) })
|
|
156
|
-
else if (b.type === 'tool_use')
|
|
164
|
+
else if (b.type === 'tool_use') {
|
|
165
|
+
// A step inside an agent says whose it is.
|
|
166
|
+
const whose = nested ? { agent: m.parent_tool_use_id } : {}
|
|
167
|
+
io.emit({ kind: 'action', id: b.id, tool: b.name, ...describeAction(b.name, b.input, cwd), ...whose })
|
|
168
|
+
if (isAgentCall(b.name)) agents.called(b.id, b.input, m.parent_tool_use_id)
|
|
169
|
+
if (nested) agents.touched(m.parent_tool_use_id, b.input)
|
|
170
|
+
}
|
|
157
171
|
}
|
|
158
172
|
} else if (m.type === 'user') {
|
|
159
173
|
for (const b of m.message?.content ?? []) {
|
|
@@ -161,10 +175,12 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
|
|
|
161
175
|
const t = typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.map(c => (c.type === 'text' ? c.text : '')).join('\n') : ''
|
|
162
176
|
if (/^Async agent launched successfully/.test(t.trim()) && !orphans.has(b.tool_use_id)) orphans.set(b.tool_use_id, 'subagent')
|
|
163
177
|
io.emit({ kind: 'action_result', id: b.tool_use_id, ok: !b.is_error, preview: short(t.trim(), 240) })
|
|
178
|
+
if (agents.has(b.tool_use_id)) agents.returned(b.tool_use_id, m.tool_use_result, b.is_error)
|
|
164
179
|
}
|
|
165
180
|
}
|
|
166
181
|
} else if (m.type === 'result') {
|
|
167
182
|
session = m.session_id ?? session
|
|
183
|
+
agents.stopAll()
|
|
168
184
|
// A turn that ended on an API error (a usage limit among them) is a success carrying the error.
|
|
169
185
|
const limit = { limits: usage.known, rejected, limitPrefixes: USAGE_LIMIT_ERROR_PREFIXES }
|
|
170
186
|
if (m.subtype === 'success' && !m.is_error) text = String(m.result ?? '')
|
|
@@ -79,6 +79,8 @@ export function itemAction(item, cwd) {
|
|
|
79
79
|
case 'plan':
|
|
80
80
|
return act('plan', 'Plan', short(item.text, 160), { tool: 'Plan' })
|
|
81
81
|
case 'collabAgentToolCall':
|
|
82
|
+
// Spawning one is a step; waiting on or messaging its agents moves the agent's row (codex-subagents.mjs).
|
|
83
|
+
if (item.tool !== 'spawnAgent') return null
|
|
82
84
|
return act('delegate', 'Subtask', short(item.prompt ?? '', 160), { tool: 'Subtask' })
|
|
83
85
|
case 'enteredReviewMode':
|
|
84
86
|
return act('read', 'Review', short(item.review ?? '', 160), { tool: 'Review' })
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The agents Codex spawns inside a turn (its collab tools), as protocol `agent` events, the way
|
|
2
|
+
// subagents.mjs does Claude Code's. A spawn is a collabAgentToolCall naming the new agent's thread;
|
|
3
|
+
// that thread's items then arrive on the same app server, so each one says whose it is (`whose`), and
|
|
4
|
+
// later collab calls and subAgentActivity items say how each agent stands. Codex's agents work in the
|
|
5
|
+
// turn's own folder.
|
|
6
|
+
import { short } from './claude-calls.mjs'
|
|
7
|
+
|
|
8
|
+
const STATES = {
|
|
9
|
+
completed: 'done',
|
|
10
|
+
errored: 'failed',
|
|
11
|
+
interrupted: 'stopped',
|
|
12
|
+
shutdown: 'stopped',
|
|
13
|
+
notFound: 'stopped',
|
|
14
|
+
}
|
|
15
|
+
const ACTIVITY = { completed: 'done', interrupted: 'stopped' }
|
|
16
|
+
|
|
17
|
+
/** The last part of an agent's path ("/root/explorer" → "explorer"): the name Codex gave it. */
|
|
18
|
+
const nameOf = path => String(path ?? '').split('/').filter(Boolean).pop() || null
|
|
19
|
+
|
|
20
|
+
export function codexAgentFeed(emit) {
|
|
21
|
+
const agents = new Map()
|
|
22
|
+
/** Each agent's thread, to the spawn call that made it. */
|
|
23
|
+
const threads = new Map()
|
|
24
|
+
const send = a => {
|
|
25
|
+
agents.set(a.id, a)
|
|
26
|
+
emit(a)
|
|
27
|
+
}
|
|
28
|
+
const settle = (threadId, status, name) => {
|
|
29
|
+
const a = agents.get(threads.get(threadId))
|
|
30
|
+
if (!a) return
|
|
31
|
+
const named = name && !a.name ? { name } : {}
|
|
32
|
+
if (status && a.status === 'running') send({ ...a, status, ...named })
|
|
33
|
+
else if (named.name) send({ ...a, ...named })
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
/** The agent a thread's items belong to, if the thread is one Codex spawned. */
|
|
37
|
+
whose: threadId => threads.get(threadId) ?? null,
|
|
38
|
+
started(item) {
|
|
39
|
+
if (item.type !== 'collabAgentToolCall' || item.tool !== 'spawnAgent') return
|
|
40
|
+
const description = short(item.prompt ?? '', 200)
|
|
41
|
+
send({ kind: 'agent', id: item.id, type: 'agent', description, status: 'running' })
|
|
42
|
+
},
|
|
43
|
+
completed(item) {
|
|
44
|
+
if (item.type === 'subAgentActivity') {
|
|
45
|
+
return settle(item.agentThreadId, ACTIVITY[item.kind], nameOf(item.agentPath))
|
|
46
|
+
}
|
|
47
|
+
if (item.type !== 'collabAgentToolCall') return
|
|
48
|
+
if (item.tool === 'spawnAgent') {
|
|
49
|
+
for (const t of item.receiverThreadIds ?? []) threads.set(t, item.id)
|
|
50
|
+
const a = agents.get(item.id)
|
|
51
|
+
if (a && item.status === 'failed') send({ ...a, status: 'failed' })
|
|
52
|
+
}
|
|
53
|
+
for (const [t, state] of Object.entries(item.agentsStates ?? {})) settle(t, STATES[state?.status])
|
|
54
|
+
},
|
|
55
|
+
/** The turn ended: what still runs ended with it (`status`: done, or stopped when it was cut short). */
|
|
56
|
+
endAll(status) {
|
|
57
|
+
for (const a of agents.values()) if (a.status === 'running') send({ ...a, status })
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/agents/codex.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { codexBin, openCodex } from './codex-rpc.mjs'
|
|
|
9
9
|
import { codexInput } from './codex-input.mjs'
|
|
10
10
|
import { turnSteering } from './codex-steer.mjs'
|
|
11
11
|
import { codexStream } from './codex-live.mjs'
|
|
12
|
+
import { codexAgentFeed } from './codex-subagents.mjs'
|
|
12
13
|
import { liveEmitter } from './live.mjs'
|
|
13
14
|
import { kiraDecides } from './checks.mjs'
|
|
14
15
|
import { CODEX_ASKS, askPerson, refusedByPolicy, said } from './codex-ask.mjs'
|
|
@@ -51,6 +52,8 @@ async function run({ prompt, attachments, sessionId, cwd, persona, settings, abo
|
|
|
51
52
|
/** What is still being written (live.mjs): sent as it comes, the final form of each kept. */
|
|
52
53
|
const live = liveEmitter(io.emit)
|
|
53
54
|
const stream = codexStream()
|
|
55
|
+
/** The agents it spawns, and which thread is whose (codex-subagents.mjs). */
|
|
56
|
+
const agents = codexAgentFeed(e => io.emit(e))
|
|
54
57
|
let finished
|
|
55
58
|
const ended = new Promise(resolve => (finished = resolve))
|
|
56
59
|
// What the person says while a turn runs goes into it (`codex` is the app server, opened below).
|
|
@@ -86,16 +89,22 @@ async function run({ prompt, attachments, sessionId, cwd, persona, settings, abo
|
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
const onNotify = (method, p) => {
|
|
92
|
+
// An item from a thread Codex spawned is its agent's: its steps say so, and what it says is for Codex.
|
|
93
|
+
const whose = p?.threadId ? agents.whose(p.threadId) : null
|
|
89
94
|
const update = stream.update(method, p)
|
|
90
|
-
if (update) return live.update(update)
|
|
95
|
+
if (update) return whose ? undefined : live.update(update)
|
|
91
96
|
if (method === 'item/started') {
|
|
92
97
|
if (p.item.type === 'fileChange') changes.set(p.item.id, p.item.changes ?? [])
|
|
98
|
+
agents.started(p.item)
|
|
93
99
|
const action = itemAction(p.item, cwd)
|
|
94
|
-
if (action) io.emit({ kind: 'action', id: p.item.id, ...action })
|
|
100
|
+
if (action) io.emit({ kind: 'action', id: p.item.id, ...action, ...(whose ? { agent: whose } : {}) })
|
|
95
101
|
} else if (method === 'item/completed') {
|
|
102
|
+
agents.completed(p.item)
|
|
96
103
|
const reviewed = review.completed(p.item)
|
|
97
104
|
if (reviewed) io.emit(reviewed)
|
|
98
|
-
if (p.item.type === 'agentMessage'
|
|
105
|
+
if (whose && p.item.type === 'agentMessage') {
|
|
106
|
+
// Its agent's answer goes to Codex, not the person.
|
|
107
|
+
} else if (p.item.type === 'agentMessage' && p.item.text?.trim()) {
|
|
99
108
|
text = p.item.text
|
|
100
109
|
if (!review.repeats(text)) live.final({ kind: 'message', id: p.item.id, text: short(p.item.text, TEXT_CAP) })
|
|
101
110
|
} else if (itemAction(p.item, cwd)) {
|
|
@@ -117,6 +126,7 @@ async function run({ prompt, attachments, sessionId, cwd, persona, settings, abo
|
|
|
117
126
|
steering.ended()
|
|
118
127
|
if (t.status === 'failed') error = usage.stopped(stoppedBy(t.error ?? { message: 'failed' }, usage.limits))
|
|
119
128
|
if (t.status === 'interrupted') error = 'Stopped by the user.'
|
|
129
|
+
agents.endAll(t.status === 'completed' ? 'done' : 'stopped')
|
|
120
130
|
finished({ durationMs: t.durationMs ?? undefined })
|
|
121
131
|
}
|
|
122
132
|
}
|
package/src/agents/protocol.mjs
CHANGED
|
@@ -10,7 +10,13 @@
|
|
|
10
10
|
// action { kind, id, type, tool, label, target, note?, detail? }
|
|
11
11
|
// type: read | search | edit | write | run | fetch | delegate | plan | other
|
|
12
12
|
// detail: { edit: {old,new,all} | edits: [...] | content | command }
|
|
13
|
+
// agent: the id of the agent (below) whose step it is, when an agent the turn started took it
|
|
13
14
|
// action_result { kind, id, ok, preview? }
|
|
15
|
+
// agent { kind, id, type, description, status, name?, isolated?, background?, summary?,
|
|
16
|
+
// worktree?: { path, branch? }, stats?: { tools?, ms?, adds?, dels? }, agent? }
|
|
17
|
+
// an agent the turn started (Claude Code's Agent tool, Codex's spawnAgent), sent again whole
|
|
18
|
+
// each time it changes; status: running | done | failed | stopped; worktree: its own, when it
|
|
19
|
+
// has one (subagents.mjs, codex-subagents.mjs)
|
|
14
20
|
// request { kind, request: AgentRequest } needs a person; the job waits
|
|
15
21
|
// request_resolved { kind, id, answer, by } by: voice | lens | phone | portal | timeout | policy
|
|
16
22
|
// usage { kind, context: {used,window} | null, limits: AgentLimits | null } how full the thread
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The agents Claude Code starts inside a turn (its Agent tool, once called Task), as protocol `agent`
|
|
2
|
+
// events: what kind each is, what it was asked, how it's going, and where it works: the job's folder,
|
|
3
|
+
// or a worktree of its own (`isolation: "worktree"`, under <repo>/.claude/worktrees/<name>). Its own
|
|
4
|
+
// steps carry its id as `agent` (claude.mjs); the worktree shows up in the first path one of them
|
|
5
|
+
// touches, and the finished call names it too. Each change of an agent is the whole event again, so
|
|
6
|
+
// the last one with an id is where it stands.
|
|
7
|
+
import { display } from '../git.mjs'
|
|
8
|
+
|
|
9
|
+
/** Claude Code's tool for starting an agent, by its current name and its old one. */
|
|
10
|
+
export const isAgentCall = name => name === 'Agent' || name === 'Task'
|
|
11
|
+
|
|
12
|
+
const WORKTREE = /^(.*\/\.claude\/worktrees\/[^/]+)(?:\/|$)/
|
|
13
|
+
|
|
14
|
+
/** The agent worktree a path is inside, if any. */
|
|
15
|
+
export function worktreeOf(path) {
|
|
16
|
+
const m = WORKTREE.exec(String(path ?? ''))
|
|
17
|
+
return m ? m[1] : null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Every absolute path a tool call names, for finding where an agent works. */
|
|
21
|
+
export function pathsIn(input) {
|
|
22
|
+
const i = input ?? {}
|
|
23
|
+
const named = [i.file_path, i.notebook_path, i.path].filter(p => typeof p === 'string')
|
|
24
|
+
const cd = /(?:^|&&|;)\s*cd\s+("[^"]+"|'[^']+'|\S+)/.exec(String(i.command ?? ''))
|
|
25
|
+
if (cd) named.push(cd[1].replace(/^["']|["']$/g, ''))
|
|
26
|
+
return named.filter(p => p.startsWith('/'))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** An Agent call as the agent it starts. */
|
|
30
|
+
export function agentFrom(id, input, parent) {
|
|
31
|
+
const i = input ?? {}
|
|
32
|
+
return {
|
|
33
|
+
kind: 'agent',
|
|
34
|
+
id,
|
|
35
|
+
type: String(i.subagent_type || 'general-purpose'),
|
|
36
|
+
description: String(i.description || i.prompt || '').slice(0, 200),
|
|
37
|
+
status: 'running',
|
|
38
|
+
...(i.name ? { name: String(i.name) } : {}),
|
|
39
|
+
...(i.isolation === 'worktree' ? { isolated: true } : {}),
|
|
40
|
+
...(parent ? { agent: parent } : {}),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A finished Agent call's result (the SDK's tool_use_result): how it went, where, and what it did. */
|
|
45
|
+
export function finished(agent, result, failed) {
|
|
46
|
+
const r = result && typeof result === 'object' ? result : {}
|
|
47
|
+
if (r.status === 'async_launched') return { ...agent, background: true }
|
|
48
|
+
const s = r.toolStats
|
|
49
|
+
const stats = {
|
|
50
|
+
...(typeof r.totalToolUseCount === 'number' ? { tools: r.totalToolUseCount } : {}),
|
|
51
|
+
...(typeof r.totalDurationMs === 'number' ? { ms: r.totalDurationMs } : {}),
|
|
52
|
+
...(s ? { adds: s.linesAdded ?? 0, dels: s.linesRemoved ?? 0 } : {}),
|
|
53
|
+
}
|
|
54
|
+
// Claude Code removes an agent's worktree when it changed nothing, and then doesn't name it.
|
|
55
|
+
const removed = r.status === 'completed' && agent.isolated
|
|
56
|
+
const worktree = r.worktreePath
|
|
57
|
+
? { path: display(r.worktreePath), ...(r.worktreeBranch ? { branch: r.worktreeBranch } : {}) }
|
|
58
|
+
: removed
|
|
59
|
+
? null
|
|
60
|
+
: agent.worktree
|
|
61
|
+
const { worktree: _, ...rest } = agent
|
|
62
|
+
return {
|
|
63
|
+
...rest,
|
|
64
|
+
status: failed ? 'failed' : 'done',
|
|
65
|
+
...(worktree ? { worktree } : {}),
|
|
66
|
+
...(Object.keys(stats).length ? { stats } : {}),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ENDED = { completed: 'done', failed: 'failed', stopped: 'stopped' }
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A turn's agents as they go. `emit` sends each change; `branchOf(path)` reads a worktree's branch
|
|
74
|
+
* (git, or a stand-in in tests).
|
|
75
|
+
*/
|
|
76
|
+
export function subagentFeed(emit, branchOf) {
|
|
77
|
+
const agents = new Map()
|
|
78
|
+
const send = (a, more = {}) => {
|
|
79
|
+
agents.set(a.id, a)
|
|
80
|
+
emit({ ...a, ...more })
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
has: id => agents.has(id),
|
|
84
|
+
/** The Agent call: it starts. */
|
|
85
|
+
called(id, input, parent) {
|
|
86
|
+
send(agentFrom(id, input, parent))
|
|
87
|
+
},
|
|
88
|
+
/** One of its own calls: the first path inside a worktree says which one it works in. */
|
|
89
|
+
touched(id, input) {
|
|
90
|
+
const a = agents.get(id)
|
|
91
|
+
if (!a || a.worktree) return
|
|
92
|
+
const path = pathsIn(input).map(worktreeOf).find(Boolean)
|
|
93
|
+
if (!path) return
|
|
94
|
+
send({ ...a, worktree: { path: display(path) } })
|
|
95
|
+
void Promise.resolve(branchOf(path)).then(branch => {
|
|
96
|
+
const now = agents.get(id)
|
|
97
|
+
if (branch && now?.worktree && !now.worktree.branch) send({ ...now, worktree: { ...now.worktree, branch } })
|
|
98
|
+
})
|
|
99
|
+
},
|
|
100
|
+
/** A one-line summary of where it's at, while it works: shown live, never stored. */
|
|
101
|
+
progress(id, summary) {
|
|
102
|
+
const a = agents.get(id)
|
|
103
|
+
if (a && summary) emit({ ...a, summary: String(summary).slice(0, 200), transient: true })
|
|
104
|
+
},
|
|
105
|
+
/** Its call's result: done, or still going in the background. */
|
|
106
|
+
returned(id, result, failed) {
|
|
107
|
+
const a = agents.get(id)
|
|
108
|
+
if (a) send(finished(a, result, failed))
|
|
109
|
+
},
|
|
110
|
+
/** A background agent ended (task_notification). */
|
|
111
|
+
ended(id, status, summary) {
|
|
112
|
+
const a = agents.get(id)
|
|
113
|
+
if (a?.status !== 'running') return
|
|
114
|
+
send({ ...a, status: ENDED[status] ?? 'done', ...(summary ? { summary: String(summary).slice(0, 200) } : {}) })
|
|
115
|
+
},
|
|
116
|
+
/** The turn ended: whatever still runs stops with it. */
|
|
117
|
+
stopAll() {
|
|
118
|
+
for (const a of agents.values()) if (a.status === 'running') send({ ...a, status: 'stopped' })
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|
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/version.mjs
ADDED