@falkenslab/agent-kit 0.32.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/chat/chatController.d.ts +5 -0
- package/dist/chat/chatController.js +13 -0
- package/dist/core/mcpDepth.d.ts +9 -0
- package/dist/core/mcpDepth.js +12 -0
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/serveMcp.d.ts +28 -0
- package/dist/mcp/serveMcp.js +177 -0
- package/package.json +13 -1
package/README.md
CHANGED
|
@@ -120,7 +120,7 @@ See [everything he can do](https://falkenslab.github.io/agent-kit/docs/examples/
|
|
|
120
120
|
| 🧩 Extensions | Claude Code plugins with MCP servers (local, npm, remote with OAuth), skills, commands and subagents; scopes, a lock, workspace trust, options, marketplaces and a catalog |
|
|
121
121
|
| 🛡️ Security | A file scope gate, denied paths, subagent gates, a remote-servers gate, isolation from the runner's own Claude Code, redacted transcripts |
|
|
122
122
|
| 🤖 Subagents | Declared types only, in the foreground, without Bash for the main agent |
|
|
123
|
-
| 🖥️ Interfaces | A full-screen Ink chat, a plain readline chat, a progress view, a wizard, themes; a chat controller for desktop apps and web pages |
|
|
123
|
+
| 🖥️ Interfaces | A full-screen Ink chat, a plain readline chat, a progress view, a wizard, themes; a chat controller for desktop apps and web pages; an MCP server, so other agents can ask it things |
|
|
124
124
|
| 🌐 Languages | English, Spanish, French and German, for the interface, the replies and the extensions' servers |
|
|
125
125
|
|
|
126
126
|
Every export, with what it takes and what it gives, is in the [API reference](https://falkenslab.github.io/agent-kit/docs/api).
|
|
@@ -246,6 +246,11 @@ export declare function createChatController(options: Options | SessionOpener, s
|
|
|
246
246
|
* resolved when it ends. An unknown slash command is caught before it reaches the model.
|
|
247
247
|
*/
|
|
248
248
|
send(raw: string): Promise<"exit" | void>;
|
|
249
|
+
/**
|
|
250
|
+
* A turn that doesn't come from the person's keyboard (another agent's, over MCP): the text
|
|
251
|
+
* goes to the agent as it is, never read as one of the chat's commands (`/extensions`, `/resume`).
|
|
252
|
+
*/
|
|
253
|
+
turn(raw: string): Promise<void>;
|
|
249
254
|
/** Stops the turn running, if any. */
|
|
250
255
|
interrupt(): void;
|
|
251
256
|
/** Switches to a mode the session allows; says so when it can't. */
|
|
@@ -493,6 +493,19 @@ export async function createChatController(options, settings = {}) {
|
|
|
493
493
|
}
|
|
494
494
|
await runTurn(line);
|
|
495
495
|
},
|
|
496
|
+
/**
|
|
497
|
+
* A turn that doesn't come from the person's keyboard (another agent's, over MCP): the text
|
|
498
|
+
* goes to the agent as it is, never read as one of the chat's commands (`/extensions`, `/resume`).
|
|
499
|
+
*/
|
|
500
|
+
async turn(raw) {
|
|
501
|
+
const line = raw.trim();
|
|
502
|
+
if (!line)
|
|
503
|
+
return;
|
|
504
|
+
log(`${promptLabel}${line}\n`);
|
|
505
|
+
addEntry({ id: nextId++, kind: "user", text: line });
|
|
506
|
+
emit({ type: "user", text: line });
|
|
507
|
+
await runTurn(line);
|
|
508
|
+
},
|
|
496
509
|
/** Stops the turn running, if any. */
|
|
497
510
|
interrupt() {
|
|
498
511
|
if (!state.busy || interrupted)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How deep in a chain of agents served over MCP this process is (ADR-027): an agent that serves
|
|
3
|
+
* itself reads it, and passes it on one higher to the servers it starts, so a loop (A asks B asks
|
|
4
|
+
* A) stops instead of spawning agents forever. An installed extension's server never gets it: its
|
|
5
|
+
* environment is stripped, and no server should depend on the kit's variables.
|
|
6
|
+
*/
|
|
7
|
+
export declare const MCP_DEPTH_ENV = "AGENT_KIT_MCP_DEPTH";
|
|
8
|
+
/** The depth this process runs at: 0 when no agent served over MCP started it. */
|
|
9
|
+
export declare function mcpDepth(): number;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How deep in a chain of agents served over MCP this process is (ADR-027): an agent that serves
|
|
3
|
+
* itself reads it, and passes it on one higher to the servers it starts, so a loop (A asks B asks
|
|
4
|
+
* A) stops instead of spawning agents forever. An installed extension's server never gets it: its
|
|
5
|
+
* environment is stripped, and no server should depend on the kit's variables.
|
|
6
|
+
*/
|
|
7
|
+
export const MCP_DEPTH_ENV = "AGENT_KIT_MCP_DEPTH";
|
|
8
|
+
/** The depth this process runs at: 0 when no agent served over MCP started it. */
|
|
9
|
+
export function mcpDepth() {
|
|
10
|
+
const depth = Number(process.env[MCP_DEPTH_ENV]);
|
|
11
|
+
return Number.isInteger(depth) && depth > 0 ? depth : 0;
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { serveMcp, type ServeMcpSettings } from "./serveMcp.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { serveMcp } from "./serveMcp.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
2
|
+
import type { AgentIdentity } from "../core/agentSpec.js";
|
|
3
|
+
import { type ChatSettings } from "../chat/chatController.js";
|
|
4
|
+
import { type SessionOpener } from "../chat/runs.js";
|
|
5
|
+
/** What `serveMcp()` needs besides how to open a session. */
|
|
6
|
+
export interface ServeMcpSettings {
|
|
7
|
+
/** Where the runs are: a `session_id` names one of them. */
|
|
8
|
+
runsDir: string;
|
|
9
|
+
/** Who the agent is: the server's name and version, and what the `ask` tool says it does. */
|
|
10
|
+
identity: AgentIdentity;
|
|
11
|
+
/** How many agents served over MCP may chain before `ask` refuses (default 3). */
|
|
12
|
+
maxDepth?: number;
|
|
13
|
+
/** How often a turn reports it's still running, so a client's tool timeout doesn't cut it (default 15 s). */
|
|
14
|
+
progressIntervalMs?: number;
|
|
15
|
+
/** The transport: stdio by default. */
|
|
16
|
+
transport?: Transport;
|
|
17
|
+
/** Runs the agent: the SDK's by default; another for tests. */
|
|
18
|
+
runQuery?: ChatSettings["runQuery"];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Serves an agent as an MCP server (ADR-027), so another agent (Claude Code, Claude Desktop,
|
|
22
|
+
* another one on the kit) drives it whole: its prompt, extensions and guardrails stay in its own
|
|
23
|
+
* process. One tool, `ask`; a `session_id` names a run, so a conversation goes on across calls.
|
|
24
|
+
*
|
|
25
|
+
* Autonomous mode only: nobody can answer a checkpoint here, and the calling model must never
|
|
26
|
+
* answer for the human. Resolves when the client disconnects.
|
|
27
|
+
*/
|
|
28
|
+
export declare function serveMcp(opener: SessionOpener, settings: ServeMcpSettings): Promise<void>;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { getInteractionPort, setInteractionPort } from "../core/interaction.js";
|
|
6
|
+
import { MCP_DEPTH_ENV, mcpDepth } from "../core/mcpDepth.js";
|
|
7
|
+
import { createRunFolder, readRunSession } from "../core/runs.js";
|
|
8
|
+
import { createChatController } from "../chat/chatController.js";
|
|
9
|
+
import { openSession } from "../chat/runs.js";
|
|
10
|
+
const DEFAULT_MAX_DEPTH = 3;
|
|
11
|
+
const DEFAULT_PROGRESS_INTERVAL_MS = 15_000;
|
|
12
|
+
/** Sessions kept open between turns: each is a process. One closed is opened again by its `session_id`. */
|
|
13
|
+
const MAX_OPEN_SESSIONS = 4;
|
|
14
|
+
/**
|
|
15
|
+
* Serves an agent as an MCP server (ADR-027), so another agent (Claude Code, Claude Desktop,
|
|
16
|
+
* another one on the kit) drives it whole: its prompt, extensions and guardrails stay in its own
|
|
17
|
+
* process. One tool, `ask`; a `session_id` names a run, so a conversation goes on across calls.
|
|
18
|
+
*
|
|
19
|
+
* Autonomous mode only: nobody can answer a checkpoint here, and the calling model must never
|
|
20
|
+
* answer for the human. Resolves when the client disconnects.
|
|
21
|
+
*/
|
|
22
|
+
export async function serveMcp(opener, settings) {
|
|
23
|
+
const { runsDir, identity } = settings;
|
|
24
|
+
const maxDepth = settings.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
25
|
+
const interval = settings.progressIntervalMs ?? DEFAULT_PROGRESS_INTERVAL_MS;
|
|
26
|
+
const depth = mcpDepth();
|
|
27
|
+
// What this function takes over in the process, given back when it ends. Over stdio, stdout
|
|
28
|
+
// belongs to the transport: whatever else prints goes to stderr.
|
|
29
|
+
const previousDepth = process.env[MCP_DEPTH_ENV];
|
|
30
|
+
const previousPort = getInteractionPort();
|
|
31
|
+
const { log, info, debug } = console;
|
|
32
|
+
const stdio = !settings.transport;
|
|
33
|
+
process.env[MCP_DEPTH_ENV] = String(depth + 1);
|
|
34
|
+
setInteractionPort(null); // the response file alone: no keyboard here
|
|
35
|
+
if (stdio)
|
|
36
|
+
console.log = console.info = console.debug = console.error;
|
|
37
|
+
const restore = () => {
|
|
38
|
+
if (previousDepth === undefined)
|
|
39
|
+
delete process.env[MCP_DEPTH_ENV];
|
|
40
|
+
else
|
|
41
|
+
process.env[MCP_DEPTH_ENV] = previousDepth;
|
|
42
|
+
setInteractionPort(previousPort);
|
|
43
|
+
if (stdio)
|
|
44
|
+
Object.assign(console, { log, info, debug });
|
|
45
|
+
};
|
|
46
|
+
async function open(run) {
|
|
47
|
+
const opened = await openSession(opener, run);
|
|
48
|
+
if (opened.modeControl?.mode !== "autonomous")
|
|
49
|
+
throw new Error(`serveMcp() serves autonomous mode only, and this session opened in ${opened.modeControl?.mode ?? "no known mode"}: nobody can answer a checkpoint over MCP.`);
|
|
50
|
+
return opened;
|
|
51
|
+
}
|
|
52
|
+
// Opened at the start to refuse a wrong mode at once; the first new conversation uses it.
|
|
53
|
+
let spare;
|
|
54
|
+
try {
|
|
55
|
+
spare = await open(await createRunFolder(runsDir));
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
restore();
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
const sessions = new Map();
|
|
62
|
+
const busy = new Set();
|
|
63
|
+
async function controllerOf(opened) {
|
|
64
|
+
return await createChatController(opened.options, {
|
|
65
|
+
sessionLogPath: path.join(opened.run.dir, "session.log"),
|
|
66
|
+
promptSuggestions: false,
|
|
67
|
+
...(opened.modeControl ? { modeControl: opened.modeControl } : {}),
|
|
68
|
+
...(opened.toolLabels ? { toolLabels: opened.toolLabels } : {}),
|
|
69
|
+
...(settings.runQuery ? { runQuery: settings.runQuery } : {}),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** The session a `session_id` names, opened again if it isn't: only a run of this agent's. */
|
|
73
|
+
async function reopen(id) {
|
|
74
|
+
const running = sessions.get(id);
|
|
75
|
+
if (running)
|
|
76
|
+
return running;
|
|
77
|
+
const dir = path.join(runsDir, id);
|
|
78
|
+
const sessionId = /^[\w-]+$/.test(id) ? await readRunSession(dir) : null;
|
|
79
|
+
if (!sessionId)
|
|
80
|
+
throw new Error(`Unknown session_id: ${id}.`);
|
|
81
|
+
return await controllerOf(await open({ dir, sessionId }));
|
|
82
|
+
}
|
|
83
|
+
function closeIdleSessions() {
|
|
84
|
+
for (const [id, controller] of sessions) {
|
|
85
|
+
if (sessions.size <= MAX_OPEN_SESSIONS)
|
|
86
|
+
return;
|
|
87
|
+
if (busy.has(id))
|
|
88
|
+
continue;
|
|
89
|
+
controller.close();
|
|
90
|
+
sessions.delete(id);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const server = new McpServer({ name: identity.name, version: identity.version ?? "0.0.0" });
|
|
94
|
+
server.registerTool("ask", {
|
|
95
|
+
description: `Ask ${identity.name}${identity.description ? `, ${identity.description},` : ""} to do something, and get its reply. Without session_id it starts a new conversation; with the session_id of an earlier reply it goes on with that one.`,
|
|
96
|
+
inputSchema: { message: z.string().min(1).describe("What to ask, in plain words."), session_id: z.string().optional().describe("The session_id of an earlier reply, to go on with that conversation.") },
|
|
97
|
+
}, async ({ message, session_id: requested }, extra) => {
|
|
98
|
+
if (depth >= maxDepth)
|
|
99
|
+
throw new Error(`Too many agents chained over MCP (the limit is ${maxDepth}).`);
|
|
100
|
+
if (requested !== undefined && busy.has(requested))
|
|
101
|
+
throw new Error(`Session ${requested} is busy with another turn; wait for it to end.`);
|
|
102
|
+
if (requested !== undefined)
|
|
103
|
+
busy.add(requested);
|
|
104
|
+
let id = requested;
|
|
105
|
+
try {
|
|
106
|
+
let controller;
|
|
107
|
+
if (requested !== undefined)
|
|
108
|
+
controller = await reopen(requested);
|
|
109
|
+
else {
|
|
110
|
+
const opened = spare ?? (await open(await createRunFolder(runsDir)));
|
|
111
|
+
spare = null;
|
|
112
|
+
id = path.basename(opened.run.dir);
|
|
113
|
+
busy.add(id);
|
|
114
|
+
controller = await controllerOf(opened);
|
|
115
|
+
}
|
|
116
|
+
// Last in the map: the least recently used are closed first.
|
|
117
|
+
sessions.delete(id);
|
|
118
|
+
sessions.set(id, controller);
|
|
119
|
+
const token = extra._meta?.progressToken;
|
|
120
|
+
let progress = 0;
|
|
121
|
+
let activity;
|
|
122
|
+
const report = () => {
|
|
123
|
+
if (token === undefined)
|
|
124
|
+
return;
|
|
125
|
+
void extra.sendNotification({ method: "notifications/progress", params: { progressToken: token, progress: ++progress, ...(activity ? { message: activity } : {}) } }).catch(() => undefined);
|
|
126
|
+
};
|
|
127
|
+
let failure = null;
|
|
128
|
+
const stopListening = controller.onEvent((chat) => {
|
|
129
|
+
if (chat.type !== "agent")
|
|
130
|
+
return;
|
|
131
|
+
const event = chat.event;
|
|
132
|
+
if (event.type === "action" || event.type === "subagent-action") {
|
|
133
|
+
activity = controller.formatAction(event.toolName, event.input);
|
|
134
|
+
report();
|
|
135
|
+
}
|
|
136
|
+
else if (event.type === "turn-end" && event.failed)
|
|
137
|
+
failure = event.errorText || "The turn failed.";
|
|
138
|
+
});
|
|
139
|
+
const heartbeat = setInterval(report, interval);
|
|
140
|
+
const interrupt = () => controller.interrupt();
|
|
141
|
+
extra.signal.addEventListener("abort", interrupt, { once: true });
|
|
142
|
+
try {
|
|
143
|
+
await controller.turn(message);
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
clearInterval(heartbeat);
|
|
147
|
+
stopListening();
|
|
148
|
+
extra.signal.removeEventListener("abort", interrupt);
|
|
149
|
+
}
|
|
150
|
+
if (failure)
|
|
151
|
+
throw new Error(failure);
|
|
152
|
+
return { content: [{ type: "text", text: controller.lastReply() }, { type: "text", text: `session_id: ${id}` }] };
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
if (id !== undefined)
|
|
156
|
+
busy.delete(id);
|
|
157
|
+
closeIdleSessions();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
return await new Promise((resolve, reject) => {
|
|
161
|
+
server.server.onclose = () => {
|
|
162
|
+
for (const controller of sessions.values())
|
|
163
|
+
controller.close();
|
|
164
|
+
sessions.clear();
|
|
165
|
+
restore();
|
|
166
|
+
resolve();
|
|
167
|
+
};
|
|
168
|
+
const transport = settings.transport ?? new StdioServerTransport();
|
|
169
|
+
// The stdio transport doesn't see its client leave: stdin ending is how it does.
|
|
170
|
+
if (stdio)
|
|
171
|
+
process.stdin.once("end", () => void server.close());
|
|
172
|
+
server.connect(transport).catch((error) => {
|
|
173
|
+
restore();
|
|
174
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@falkenslab/agent-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "Library for building an agent on top of the Claude Agent SDK",
|
|
5
5
|
"author": "Francisco Vargas Ruiz",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,6 +30,17 @@
|
|
|
30
30
|
},
|
|
31
31
|
"main": "./dist/index.js",
|
|
32
32
|
"types": "./dist/index.d.ts",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"default": "./dist/index.js"
|
|
37
|
+
},
|
|
38
|
+
"./mcp": {
|
|
39
|
+
"types": "./dist/mcp/index.d.ts",
|
|
40
|
+
"default": "./dist/mcp/index.js"
|
|
41
|
+
},
|
|
42
|
+
"./package.json": "./package.json"
|
|
43
|
+
},
|
|
33
44
|
"files": [
|
|
34
45
|
"dist",
|
|
35
46
|
"assets",
|
|
@@ -52,6 +63,7 @@
|
|
|
52
63
|
"@anthropic-ai/claude-agent-sdk": "^0.3.300",
|
|
53
64
|
"@inkjs/ui": "^2.0.0",
|
|
54
65
|
"@inquirer/prompts": "^8.5.2",
|
|
66
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
55
67
|
"@types/react": "^19.3.0",
|
|
56
68
|
"ink": "^6.8.0",
|
|
57
69
|
"isomorphic-git": "^1.44.5",
|