@camelai/agent-runtime 0.3.0 → 0.4.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 CHANGED
@@ -18,7 +18,7 @@ Upgrade with `npm update @camelai/agent-runtime`.
18
18
 
19
19
  The SDK has one runtime dependency (`typebox`). Its message and model types come
20
20
  from Pi; for full typing of history and events, also install
21
- `@earendil-works/pi-agent-core@0.80.6` and `@earendil-works/pi-ai@0.80.6` as dev
21
+ `@earendil-works/pi-agent-core@0.87.1` and `@earendil-works/pi-ai@0.87.1` as dev
22
22
  dependencies. Without them those types resolve to `any` (with `skipLibCheck`).
23
23
 
24
24
  ## Use
@@ -59,6 +59,16 @@ const agent = await runtime.createAgent({
59
59
  await agent.prompt("Plan restocks for anything below target.");
60
60
  ```
61
61
 
62
+ Already have an MCP server? Attach it instead of (or alongside) `tools`: the SDK
63
+ talks to it in memory, and the same server can later run remotely as a definition's
64
+ `mcpServers` entry without changing its tools. Install `@modelcontextprotocol/sdk` too.
65
+
66
+ ```ts
67
+ import { fromMcpServer } from "@camelai/agent-runtime/mcp";
68
+
69
+ const agent = await runtime.createAgent({ name: "Inventory planner", mcp: await fromMcpServer(server) });
70
+ ```
71
+
62
72
  Switch models between turns with `await agent.configure({ model: "openai/gpt-5.2" })`;
63
73
  the history carries over. Your tenant needs a key for that provider.
64
74
 
@@ -80,7 +90,7 @@ The routes are `/v1/me`, `/v1/providers` (+ `/:provider/key`), `/v1/models`,
80
90
  `/v1/tokens` and `/v1/usage`; see `src/api.ts`.
81
91
 
82
92
  Save `agent.session` (it contains a scoped credential) to reconnect later with
83
- `runtime.connectAgent(session, { tools })`. Pass the same `idempotencyKey` to
93
+ `runtime.connectAgent(session, { tools })` (or `{ mcp }`). Pass the same `idempotencyKey` to
84
94
  `createAgent` to get the same agent back instead of a new one.
85
95
 
86
96
  `@camelai/agent-runtime` (without `/node`) is the portable build for Workers and
@@ -0,0 +1,13 @@
1
+ import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import type { ToolServer } from "./typescript.ts";
4
+ /**
5
+ * Attach an MCP SDK server (`McpServer` or low-level `Server`) to an agent: pass the result
6
+ * as `mcp` to `createAgent`, `connectAgent` or `configure`. The SDK talks to the server in
7
+ * memory, so the same server can later run remotely, as a definition's `mcpServers` entry,
8
+ * without changing its tools. `callId`, `toolCallId` and `origin` reach its handlers as
9
+ * `_meta["agent-runtime/…"]`.
10
+ */
11
+ export declare function fromMcpServer(server: McpServer | Server): Promise<ToolServer & {
12
+ close(): Promise<void>;
13
+ }>;
@@ -0,0 +1,38 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3
+ /** Tool names the runtime accepts; others are renamed, and calls map back. */
4
+ const safeName = (name) => (/^[A-Za-z]/.test(name) ? name : `t_${name}`).replace(/[^A-Za-z0-9_]/g, "_").slice(0, 80);
5
+ /**
6
+ * Attach an MCP SDK server (`McpServer` or low-level `Server`) to an agent: pass the result
7
+ * as `mcp` to `createAgent`, `connectAgent` or `configure`. The SDK talks to the server in
8
+ * memory, so the same server can later run remotely, as a definition's `mcpServers` entry,
9
+ * without changing its tools. `callId`, `toolCallId` and `origin` reach its handlers as
10
+ * `_meta["agent-runtime/…"]`.
11
+ */
12
+ export async function fromMcpServer(server) {
13
+ const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
14
+ await server.connect(serverSide);
15
+ const client = new Client({ name: "agent-runtime-sdk", version: "1.0.0" });
16
+ await client.connect(clientSide);
17
+ const names = new Map();
18
+ return {
19
+ async listTools() {
20
+ const tools = [];
21
+ let cursor;
22
+ do {
23
+ const page = await client.listTools(cursor ? { cursor } : {});
24
+ for (const tool of page.tools) {
25
+ names.set(safeName(tool.name), tool.name);
26
+ tools.push({ ...tool, name: safeName(tool.name) });
27
+ }
28
+ cursor = page.nextCursor;
29
+ } while (cursor);
30
+ return tools;
31
+ },
32
+ async callTool(name, args, context) {
33
+ const _meta = { "agent-runtime/callId": context.callId, ...(context.toolCallId ? { "agent-runtime/toolCallId": context.toolCallId } : {}), ...(context.origin ? { "agent-runtime/origin": context.origin } : {}) };
34
+ return await client.callTool({ name: names.get(name) ?? name, arguments: args, _meta }, undefined, { signal: context.signal });
35
+ },
36
+ async close() { await client.close(); },
37
+ };
38
+ }
@@ -1,7 +1,7 @@
1
1
  import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import type { Api, ImageContent, Model } from "@earendil-works/pi-ai";
3
3
  import { Type, type TSchema, type Static } from "typebox";
4
- import { type Outcome, type RequestMethod, type SessionCredentials, type SessionState } from "../shared/client-protocol.ts";
4
+ import { type RequestMethod, type SessionCredentials, type SessionState } from "../shared/client-protocol.ts";
5
5
  export { Type as schema };
6
6
  export type { SessionCredentials, SessionState };
7
7
  export interface ToolContext {
@@ -24,6 +24,32 @@ export declare function tool<S extends TSchema>(definition: Omit<Tool<Static<S>>
24
24
  input: S;
25
25
  }): Tool<Static<S>>;
26
26
  export type Tools = Record<string, Tool>;
27
+ /** A tool as an MCP server lists it (`tools/list`). Runtime options ride in `_meta` under "agent-runtime/". */
28
+ export interface McpTool {
29
+ name: string;
30
+ title?: string;
31
+ description?: string;
32
+ inputSchema: Record<string, unknown>;
33
+ annotations?: Record<string, unknown>;
34
+ _meta?: Record<string, unknown>;
35
+ }
36
+ /** An MCP `tools/call` result. */
37
+ export interface CallToolResult {
38
+ content: Array<Record<string, unknown>>;
39
+ structuredContent?: Record<string, unknown>;
40
+ isError?: boolean;
41
+ }
42
+ /**
43
+ * The MCP server an application attaches to its agent: the SDK relays the runtime's
44
+ * `tools/list` and `tools/call` to it over the agent's connection. Throw from `callTool`
45
+ * only when the call could not be answered; a tool's own failure is an `isError` result.
46
+ */
47
+ export interface ToolServer {
48
+ listTools(): McpTool[] | Promise<McpTool[]>;
49
+ callTool(name: string, args: Record<string, unknown>, context: ToolContext): Promise<CallToolResult>;
50
+ }
51
+ /** `tool({...})` definitions as an attached MCP server: JSON results become a text block (and structured content for objects). */
52
+ export declare function toolServer(tools: Tools): ToolServer;
27
53
  export interface RuntimeOptions {
28
54
  url?: string;
29
55
  apiKey?: string;
@@ -33,7 +59,10 @@ export interface RuntimeOptions {
33
59
  fetch?: typeof globalThis.fetch;
34
60
  }
35
61
  export interface AgentOptions {
36
- tools: Tools;
62
+ /** The application's tools, served to the agent as an attached MCP server. */
63
+ tools?: Tools;
64
+ /** Or an MCP server of the application's own (see `clients/mcp.ts` for MCP SDK servers). */
65
+ mcp?: ToolServer;
37
66
  onEvent?: (event: any, requestId?: string) => unknown | Promise<unknown>;
38
67
  onConnection?: (connected: boolean) => void;
39
68
  onError?: (error: Error) => void;
@@ -41,6 +70,12 @@ export interface AgentOptions {
41
70
  export type { ThinkingLevel };
42
71
  export interface CreateAgentOptions extends AgentOptions {
43
72
  idempotencyKey?: string;
73
+ /**
74
+ * Make the agent from a definition (GET /v1/definitions): it supplies the model, system
75
+ * prompt, thinking level and tool sources, so leave those out. `tools` (or `mcp`) are added
76
+ * as the agent's attached server.
77
+ */
78
+ definition?: string;
44
79
  /** Agent lifetime in seconds (60 to 366 days), or null to keep the agent until it is deleted. Default one day. */
45
80
  ttlSeconds?: number | null;
46
81
  systemPrompt?: string;
@@ -126,6 +161,8 @@ export interface RequestOptions {
126
161
  export declare class AgentError extends Error {
127
162
  status: number;
128
163
  requestId?: string;
164
+ /** Milliseconds the runtime asked to wait before retrying (its Retry-After), for 429 and 503. */
165
+ retryAfterMs?: number;
129
166
  constructor(message: string, status?: number, requestId?: string);
130
167
  }
131
168
  declare class Transport {
@@ -203,13 +240,10 @@ export declare class VolumeHandle {
203
240
  version?: number;
204
241
  }): Promise<any>;
205
242
  }
243
+ /** The client's event cursor, saved so a restarted client resumes where it was. */
206
244
  export type Journal = {
207
245
  version: 1;
208
246
  cursor: number;
209
- calls: Record<string, {
210
- state: "started" | "done";
211
- outcome?: Outcome;
212
- }>;
213
247
  };
214
248
  /** Stores must resolve only once the complete snapshot is committed. Use one active client per agent. */
215
249
  export interface JournalStore {
@@ -220,6 +254,7 @@ export declare function memoryJournalStore(): JournalStore;
220
254
  export declare class AgentClient {
221
255
  readonly session: SessionCredentials;
222
256
  readonly tools: Tools;
257
+ private server;
223
258
  private readonly transport;
224
259
  private readonly store;
225
260
  private journal;
@@ -227,8 +262,10 @@ export declare class AgentClient {
227
262
  private saving;
228
263
  private readonly options;
229
264
  private readonly pending;
265
+ /** Tool calls running, by JSON-RPC id, so the runtime can cancel them. */
230
266
  private readonly active;
231
- private readonly delivered;
267
+ /** The event stream's connection, named in the MCP messages this client sends back. */
268
+ private connection?;
232
269
  private stream?;
233
270
  private loop?;
234
271
  private closed;
@@ -250,8 +287,12 @@ export declare class AgentClient {
250
287
  private receive;
251
288
  private settle;
252
289
  private sync;
253
- private dispatch;
254
- private runTool;
290
+ /**
291
+ * Answer the runtime's JSON-RPC messages as the agent's attached MCP server: initialize,
292
+ * ping, tools/list and tools/call, and cancellation. The runtime runs a call once; a call
293
+ * whose answer is lost with the connection ends for the agent as "outcome unknown".
294
+ */
295
+ private mcp;
255
296
  request(method: RequestMethod, params?: Record<string, unknown>, options?: RequestOptions): Promise<any>;
256
297
  /** Observe an already accepted request. This never submits or re-executes work. */
257
298
  waitForRequest(id: string, options?: {
@@ -269,6 +310,7 @@ export declare class AgentClient {
269
310
  systemPrompt?: string;
270
311
  thinkingLevel?: ThinkingLevel;
271
312
  tools?: Tools;
313
+ mcp?: ToolServer;
272
314
  model?: string;
273
315
  }): Promise<any>;
274
316
  execute(code: string, options?: RequestOptions & {
@@ -6,14 +6,56 @@ export { Type as schema };
6
6
  export function tool(definition) {
7
7
  return { ...definition, input: definition.input };
8
8
  }
9
+ const META = "agent-runtime/";
10
+ const isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
11
+ /** `tool({...})` definitions as an attached MCP server: JSON results become a text block (and structured content for objects). */
12
+ export function toolServer(tools) {
13
+ return {
14
+ listTools: () => Object.entries(tools).map(([name, tool]) => ({
15
+ name, description: tool.description, inputSchema: tool.input,
16
+ ...(tool.exposure || tool.executionMode ? { _meta: { ...(tool.exposure ? { [`${META}exposure`]: tool.exposure } : {}), ...(tool.executionMode ? { [`${META}executionMode`]: tool.executionMode } : {}) } } : {}),
17
+ })),
18
+ async callTool(name, args, context) {
19
+ const definition = tools[name];
20
+ if (!Object.hasOwn(tools, name) || !Check(definition.input, args))
21
+ throw new Error("Tool is missing or arguments failed validation");
22
+ context.signal.throwIfAborted();
23
+ let result;
24
+ try {
25
+ result = await definition.execute(args, context);
26
+ }
27
+ catch (error) {
28
+ if (context.signal.aborted)
29
+ throw error;
30
+ return { content: [{ type: "text", text: String(error).slice(0, 2048) }], isError: true };
31
+ }
32
+ if (result === undefined || byteLength(JSON.stringify(result)) > 1024 * 1024)
33
+ throw new Error("Tool must return a bounded JSON value");
34
+ if (definition.resultFormat === "content")
35
+ return result;
36
+ return { content: [{ type: "text", text: JSON.stringify(result) }], ...(isRecord(result) ? { structuredContent: result } : {}) };
37
+ },
38
+ };
39
+ }
9
40
  export class AgentError extends Error {
10
41
  status;
11
42
  requestId;
43
+ /** Milliseconds the runtime asked to wait before retrying (its Retry-After), for 429 and 503. */
44
+ retryAfterMs;
12
45
  constructor(message, status = 0, requestId) { super(message); this.name = "AgentError"; this.status = status; this.requestId = requestId; }
13
46
  }
47
+ /** Retry-After as milliseconds (seconds or an HTTP date), capped so a bad value cannot stall a caller. */
48
+ function retryAfter(response) {
49
+ const value = response.headers.get("retry-after");
50
+ if (value === null)
51
+ return undefined;
52
+ const ms = /^\d+$/.test(value.trim()) ? Number(value) * 1000 : Date.parse(value) - Date.now();
53
+ return Number.isFinite(ms) ? Math.min(Math.max(0, ms), 60_000) : undefined;
54
+ }
55
+ /** A 429 (quota, or an agent's queue is full) was refused before anything happened, so any request may be retried after it. */
56
+ const RATE_LIMIT_ATTEMPTS = 8;
14
57
  const byteLength = (value) => new TextEncoder().encode(value).byteLength;
15
58
  const pause = (ms) => new Promise(resolve => setTimeout(resolve, ms));
16
- const definitions = (tools) => Object.entries(tools).map(([name, tool]) => ({ name, description: tool.description, parameters: tool.input, ...(tool.resultFormat ? { resultFormat: tool.resultFormat } : {}), ...(tool.exposure ? { exposure: tool.exposure } : {}), ...(tool.executionMode ? { executionMode: tool.executionMode } : {}) }));
17
59
  async function rejectRedirect(response) {
18
60
  if (response.status >= 300 && response.status < 400) {
19
61
  await response.body?.cancel();
@@ -44,15 +86,19 @@ class Transport {
44
86
  redirect: "manual", signal: AbortSignal.timeout(10_000),
45
87
  });
46
88
  await rejectRedirect(response);
47
- const value = await response.json();
89
+ const value = await (response.ok ? response.json() : response.json().catch(() => ({})));
48
90
  if (!response.ok)
49
- throw new AgentError(value.error ?? `HTTP ${response.status}`, response.status);
91
+ throw Object.assign(new AgentError(value.error ?? `HTTP ${response.status}`, response.status), { retryAfterMs: retryAfter(response) });
50
92
  return value;
51
93
  }
52
94
  catch (error) {
53
- if (!retry || attempt >= 3 || (error instanceof AgentError && error.status < 500))
95
+ const limited = error instanceof AgentError && error.status === 429;
96
+ if (limited ? attempt >= RATE_LIMIT_ATTEMPTS - 1 : !retry || attempt >= 3 || (error instanceof AgentError && error.status < 500))
54
97
  throw error;
55
- await pause(100 * 2 ** attempt);
98
+ // Honour the runtime's Retry-After, with jitter so refused callers do not return together; else back off exponentially.
99
+ const backoff = Math.min(10_000, (limited ? 500 : 100) * 2 ** attempt);
100
+ const hinted = error instanceof AgentError ? error.retryAfterMs : undefined;
101
+ await pause(hinted !== undefined ? hinted + Math.random() * Math.min(1000, backoff) : backoff);
56
102
  }
57
103
  }
58
104
  }
@@ -76,7 +122,8 @@ export class AgentRuntime {
76
122
  const key = this.options.apiKey;
77
123
  if (!key)
78
124
  throw new AgentError("Set apiKey to provision an agent");
79
- const session = await this.transport.json("/client-sessions", key, "POST", { tools: definitions(options.tools), ...(options.mounts !== undefined ? { mounts: options.mounts } : {}), ...(options.model !== undefined ? { model: options.model } : {}), ...(options.thinkingLevel !== undefined ? { thinkingLevel: options.thinkingLevel } : {}), ...(options.initialMessages !== undefined ? { initialMessages: options.initialMessages } : {}), ...(options.name !== undefined ? { name: options.name } : {}), ...(options.type !== undefined ? { type: options.type } : {}), ...(options.systemPrompt !== undefined ? { systemPrompt: options.systemPrompt } : {}), ...(options.ttlSeconds !== undefined ? { ttlSeconds: options.ttlSeconds } : {}) }, true, { "Idempotency-Key": options.idempotencyKey ?? globalThis.crypto.randomUUID() });
125
+ const server = options.mcp ?? toolServer(options.tools ?? {});
126
+ const session = await this.transport.json("/client-sessions", key, "POST", { mcp: { tools: await server.listTools() }, ...(options.definition !== undefined ? { definition: options.definition } : {}), ...(options.mounts !== undefined ? { mounts: options.mounts } : {}), ...(options.model !== undefined ? { model: options.model } : {}), ...(options.thinkingLevel !== undefined ? { thinkingLevel: options.thinkingLevel } : {}), ...(options.initialMessages !== undefined ? { initialMessages: options.initialMessages } : {}), ...(options.name !== undefined ? { name: options.name } : {}), ...(options.type !== undefined ? { type: options.type } : {}), ...(options.systemPrompt !== undefined ? { systemPrompt: options.systemPrompt } : {}), ...(options.ttlSeconds !== undefined ? { ttlSeconds: options.ttlSeconds } : {}) }, true, { "Idempotency-Key": options.idempotencyKey ?? globalThis.crypto.randomUUID() });
80
127
  return this.connectAgent(session, options);
81
128
  }
82
129
  async connectAgent(session, options) {
@@ -153,15 +200,18 @@ export function memoryJournalStore() {
153
200
  export class AgentClient {
154
201
  session;
155
202
  tools;
203
+ server;
156
204
  transport;
157
205
  store;
158
- journal = { version: 1, cursor: 0, calls: {} };
206
+ journal = { version: 1, cursor: 0 };
159
207
  loaded;
160
208
  saving = Promise.resolve();
161
209
  options;
162
210
  pending = new Map();
211
+ /** Tool calls running, by JSON-RPC id, so the runtime can cancel them. */
163
212
  active = new Map();
164
- delivered = new Set();
213
+ /** The event stream's connection, named in the MCP messages this client sends back. */
214
+ connection;
165
215
  stream;
166
216
  loop;
167
217
  closed = false;
@@ -172,6 +222,7 @@ export class AgentClient {
172
222
  throw new AgentError("Invalid session id");
173
223
  this.session = { id: session.id, token: session.token, expiresAt: session.expiresAt };
174
224
  this.tools = { ...options.tools };
225
+ this.server = options.mcp ?? toolServer(this.tools);
175
226
  this.options = options;
176
227
  this.transport = new Transport(runtime);
177
228
  this.store = runtime.journalStore ?? memoryJournalStore();
@@ -180,13 +231,13 @@ export class AgentClient {
180
231
  const journal = await this.store.load(this.session.id);
181
232
  if (!journal)
182
233
  return;
183
- if (journal.version !== 1 || !Number.isSafeInteger(journal.cursor) || journal.cursor < 0 || !journal.calls || typeof journal.calls !== "object")
234
+ if (journal.version !== 1 || !Number.isSafeInteger(journal.cursor) || journal.cursor < 0)
184
235
  throw new AgentError("Unsupported client journal");
185
- this.journal = structuredClone(journal);
236
+ this.journal = { version: 1, cursor: journal.cursor };
186
237
  }
187
238
  save() {
188
239
  const snapshot = structuredClone(this.journal);
189
- // Serialize commits so concurrent tool completions cannot overwrite newer receipts.
240
+ // Serialize commits so an older cursor never overwrites a newer one.
190
241
  this.saving = this.saving.then(() => this.store.save(this.session.id, snapshot));
191
242
  return this.saving;
192
243
  }
@@ -251,13 +302,22 @@ export class AgentClient {
251
302
  if (!data)
252
303
  continue;
253
304
  if (lines.includes("event: ready")) {
305
+ this.connection = JSON.parse(data).connection;
254
306
  await this.sync();
255
307
  backoff = 250;
256
308
  this.ready.resolve();
257
309
  this.options.onConnection?.(true);
258
310
  continue;
259
311
  }
260
- const id = Number(lines.find(line => line.startsWith("id:"))?.slice(3));
312
+ const idLine = lines.find(line => line.startsWith("id:"));
313
+ // The runtime's MCP messages are live only: no id, never replayed, no cursor.
314
+ if (!idLine) {
315
+ const event = JSON.parse(data);
316
+ if (event.type === "mcp")
317
+ void this.mcp(event.message).catch(error => this.report(error));
318
+ continue;
319
+ }
320
+ const id = Number(idLine.slice(3));
261
321
  if (!Number.isSafeInteger(id) || id <= 0)
262
322
  throw new AgentError("Invalid SSE cursor");
263
323
  if (id <= this.journal.cursor)
@@ -308,11 +368,7 @@ export class AgentClient {
308
368
  return this.transport.json(this.path('/metadata'), this.session.token, 'POST', metadata);
309
369
  }
310
370
  async receive(event) {
311
- if (event.type === "tool_call")
312
- this.dispatch(event.call);
313
- else if (event.type === "tool_cancel")
314
- this.active.get(event.id)?.controller.abort();
315
- else if (event.type === "response")
371
+ if (event.type === "response")
316
372
  this.settle(event.id, event.outcome);
317
373
  else if (event.type === "event")
318
374
  await this.options.onEvent?.(event.event, event.requestId);
@@ -332,73 +388,51 @@ export class AgentClient {
332
388
  for (const request of state.requests)
333
389
  if (request.outcome)
334
390
  this.settle(request.id, request.outcome);
335
- for (const call of state.calls)
336
- this.dispatch(call);
337
391
  return state;
338
392
  }
339
- dispatch(call) {
340
- if (this.closed || this.active.has(call.id) || this.delivered.has(call.id) || ["completed", "cancelled"].includes(call.state))
393
+ /**
394
+ * Answer the runtime's JSON-RPC messages as the agent's attached MCP server: initialize,
395
+ * ping, tools/list and tools/call, and cancellation. The runtime runs a call once; a call
396
+ * whose answer is lost with the connection ends for the agent as "outcome unknown".
397
+ */
398
+ async mcp(message) {
399
+ if (typeof message.method !== "string")
341
400
  return;
342
- const controller = new AbortController();
343
- // Defer execution until the active entry exists; replay can arrive immediately.
344
- const task = Promise.resolve().then(() => this.runTool(call, controller)).catch(error => this.report(error)).finally(() => this.active.delete(call.id));
345
- this.active.set(call.id, { controller, task });
346
- }
347
- async runTool(call, controller) {
348
- let receipt = this.journal.calls[call.id];
349
- if (receipt?.state === "done") {
350
- await this.http(`/calls/${call.id}/outcome`, "POST", receipt.outcome);
351
- this.delivered.add(call.id);
401
+ if (message.id === undefined) {
402
+ if (message.method === "notifications/cancelled")
403
+ this.active.get(String(message.params?.requestId))?.abort();
352
404
  return;
353
405
  }
354
- if (call.state === "uncertain")
355
- return; // Already settled as unknown; never execute again.
356
- let value;
357
- if (call.state === "started")
358
- value = { error: "The application lost this tool call's outcome; it may or may not have taken effect", uncertain: true };
359
- else {
360
- this.journal.calls[call.id] = { state: "started" };
361
- await this.save();
362
- try {
363
- // Claim is deliberately NOT retried. A lost acknowledgement is ambiguous.
364
- const claim = await this.http(`/calls/${call.id}/claim`, "POST", {}, false);
365
- if (!claim.execute) {
366
- if (claim.call.state !== "started")
367
- return;
368
- value = { error: "Tool execution was already claimed; outcome unknown", uncertain: true };
369
- }
370
- else {
371
- const timer = setTimeout(() => controller.abort(), Math.max(1, call.deadline - Date.now()));
372
- // A callback that ignores cancellation must not keep a closed client's process alive until the deadline.
373
- timer.unref?.();
374
- try {
375
- const definition = this.tools[call.name];
376
- if (!Object.hasOwn(this.tools, call.name) || !Check(definition.input, call.args))
377
- throw new Error("Tool is missing or arguments failed validation");
378
- controller.signal.throwIfAborted();
379
- const result = await definition.execute(call.args, { callId: call.id, toolCallId: call.toolCallId, signal: controller.signal, ...(call.origin ? { origin: call.origin } : {}) });
380
- if (result === undefined || byteLength(JSON.stringify(result)) > 1024 * 1024)
381
- throw new Error("Tool must return a bounded JSON value");
382
- value = { result };
383
- }
384
- catch (error) {
385
- value = { error: String(error).slice(0, 2048), ...(controller.signal.aborted ? { uncertain: true } : {}) };
386
- }
387
- finally {
388
- clearTimeout(timer);
389
- }
390
- }
391
- }
392
- catch (error) {
393
- value = { error: `Execution claim failed: ${String(error).slice(0, 1800)}`, uncertain: true };
394
- }
406
+ const connection = this.connection;
407
+ const reply = (answer) => this.transport.json(this.path("/mcp"), this.session.token, "POST", { jsonrpc: "2.0", id: message.id, ...answer }, true, { "X-Agent-Connection": connection ?? "" });
408
+ const params = message.params ?? {};
409
+ if (message.method === "initialize")
410
+ return reply({ result: { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "agent-runtime-sdk", version: "1.0.0" } } });
411
+ if (message.method === "ping")
412
+ return reply({ result: {} });
413
+ if (message.method === "tools/list")
414
+ return reply({ result: { tools: await this.server.listTools() } });
415
+ if (message.method !== "tools/call")
416
+ return reply({ error: { code: -32601, message: `Unknown method ${message.method}` } });
417
+ const controller = new AbortController();
418
+ const key = String(message.id);
419
+ this.active.set(key, controller);
420
+ const meta = params._meta ?? {};
421
+ try {
422
+ const result = await this.server.callTool(params.name, params.arguments ?? {}, {
423
+ callId: meta["agent-runtime/callId"] ?? key, signal: controller.signal,
424
+ ...(meta["agent-runtime/toolCallId"] ? { toolCallId: meta["agent-runtime/toolCallId"] } : {}), ...(meta["agent-runtime/origin"] ? { origin: meta["agent-runtime/origin"] } : {}),
425
+ });
426
+ if (!isRecord(result) || !Array.isArray(result.content) || byteLength(JSON.stringify(result)) > 1024 * 1024)
427
+ throw new Error("The MCP server must answer with a bounded CallToolResult");
428
+ await reply({ result });
429
+ }
430
+ catch (error) {
431
+ await reply({ error: { code: -32603, message: String(error).slice(0, 2048) } });
432
+ }
433
+ finally {
434
+ this.active.delete(key);
395
435
  }
396
- // Persist before POST; reconnect resends this receipt, never the side effect.
397
- receipt = { state: "done", outcome: value };
398
- this.journal.calls[call.id] = receipt;
399
- await this.save();
400
- await this.http(`/calls/${call.id}/outcome`, "POST", value);
401
- this.delivered.add(call.id);
402
436
  }
403
437
  async request(method, params = {}, options = {}) {
404
438
  if (this.closed || this.fatal)
@@ -464,12 +498,16 @@ export class AgentClient {
464
498
  followUp(text) { return this.request("followUp", { text }); }
465
499
  /** Change the prompt, thinking level, tools, or model ("provider/model-id") between runs. */
466
500
  async configure(options) {
467
- const result = await this.request("configure", { ...options, ...(options.tools ? { tools: definitions(options.tools) } : {}) });
468
- if (options.tools) {
501
+ const { tools, mcp, ...rest } = options;
502
+ const server = mcp ?? (tools ? toolServer(tools) : undefined);
503
+ const result = await this.request("configure", { ...rest, ...(server ? { mcp: { tools: await server.listTools() } } : {}) });
504
+ if (tools) {
469
505
  for (const key of Object.keys(this.tools))
470
506
  delete this.tools[key];
471
- Object.assign(this.tools, options.tools);
507
+ Object.assign(this.tools, tools);
472
508
  }
509
+ if (server)
510
+ this.server = mcp ?? toolServer(this.tools);
473
511
  return result;
474
512
  }
475
513
  execute(code, options) {
@@ -491,7 +529,7 @@ export class AgentClient {
491
529
  async close() {
492
530
  this.closed = true;
493
531
  this.stream?.abort();
494
- for (const { controller } of this.active.values())
532
+ for (const controller of this.active.values())
495
533
  controller.abort();
496
534
  for (const [id, waiter] of this.pending)
497
535
  waiter.reject(new AgentError("Client closed; request may still be running", 0, id));
@@ -16,22 +16,6 @@ export interface SessionCredentials {
16
16
  expiresAt: number | null;
17
17
  }
18
18
  export type RequestMethod = "prompt" | "execute" | "status" | "abort" | "history" | "continue" | "steer" | "followUp" | "configure";
19
- export type CallRecord = {
20
- id: string;
21
- toolCallId?: string;
22
- requestId?: string;
23
- createdAt?: number;
24
- name: string;
25
- args: Record<string, unknown>;
26
- deadline: number;
27
- /** `uncertain`: claimed, but the outcome was lost; the model was told it is unknown. */
28
- state: "offered" | "started" | "completed" | "cancelled" | "uncertain";
29
- outcome?: Outcome;
30
- /** A result that arrived after the call was already settled as uncertain. */
31
- lateOutcome?: Outcome;
32
- /** Where the turn came from, set by the runtime (e.g. a channel and its sender), so tools can authorize. */
33
- origin?: Record<string, unknown>;
34
- };
35
19
  export type RequestRecord = {
36
20
  id: string;
37
21
  startedAt?: number;
@@ -47,14 +31,11 @@ export type RequestRecord = {
47
31
  began?: number;
48
32
  /** Kept until the run begins, so a queued run survives a restart and runs exactly once. */
49
33
  params?: unknown;
34
+ /** Times a new owner resumed this run's turn after the node running it was lost. */
35
+ resumes?: number;
50
36
  };
37
+ /** Events on an agent's stream. `mcp` carries the runtime's JSON-RPC messages to the application's attached MCP server: live only, with no id, never replayed. */
51
38
  export type ClientEvent = {
52
- type: "tool_call";
53
- call: CallRecord;
54
- } | {
55
- type: "tool_cancel";
56
- id: string;
57
- } | {
58
39
  type: "event";
59
40
  requestId: string;
60
41
  event: any;
@@ -62,10 +43,12 @@ export type ClientEvent = {
62
43
  type: "response";
63
44
  id: string;
64
45
  outcome: Outcome;
46
+ } | {
47
+ type: "mcp";
48
+ message: Record<string, unknown>;
65
49
  };
66
50
  export type SessionState = {
67
51
  cursor: number;
68
- calls: CallRecord[];
69
52
  requests: RequestRecord[];
70
53
  };
71
54
  /** A tool an application offers its agent; shared by the SDKs and the runtime. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camelai/agent-runtime",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "SDK for the camelAI hosted agent runtime: define tools in your app, and the runtime runs the model loop, history and sandbox.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,6 +24,10 @@
24
24
  "./node": {
25
25
  "types": "./dist/clients/node.d.ts",
26
26
  "default": "./dist/clients/node.js"
27
+ },
28
+ "./mcp": {
29
+ "types": "./dist/clients/mcp.d.ts",
30
+ "default": "./dist/clients/mcp.js"
27
31
  }
28
32
  },
29
33
  "files": [
@@ -38,5 +42,13 @@
38
42
  "dependencies": {
39
43
  "typebox": "1.1.38"
40
44
  },
41
- "homepage": "https://agents.camelai.dev"
45
+ "homepage": "https://agents.camelai.dev",
46
+ "peerDependencies": {
47
+ "@modelcontextprotocol/sdk": "^1.30.1"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "@modelcontextprotocol/sdk": {
51
+ "optional": true
52
+ }
53
+ }
42
54
  }