a2a-exposed 0.8.37 → 0.8.38

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/lib/main.mjs CHANGED
@@ -315,6 +315,8 @@ Outbound (agents you call)
315
315
  poll --to <alias|url> <taskId> [--proto 0.3|1.0]
316
316
  send/poll need only peer config; owner history/task storage is optional.
317
317
  Storage failures warn on stderr; the peer response still prints and exits 0
318
+ Self-hosted MCP: poll_outbound fetches current state; cancel_outbound asks the peer
319
+ to cancel a sent task. Ask the human before canceling consequential work
318
320
  outbound <taskId> stored state of a task you sent (incl. pushed updates)
319
321
 
320
322
  Owner
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.37",
3
+ "version": "0.8.38",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: a Cloudflare Worker, a wake webhook, or scheduled checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2197,7 +2197,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
2197
2197
  }
2198
2198
  }
2199
2199
  if (seg[0] === "pairing") return await ownerPairing(req, ctx, ectx, m, seg.slice(1), body, trusted);
2200
- if (seg[0] === "mcp" && seg[1] && !seg[2] && m === "POST" && (seg[1] === "send" || seg[1] === "reply" || seg[1] === "mark_working" || seg[1] === "announce_address")) {
2200
+ if (seg[0] === "mcp" && seg[1] && !seg[2] && m === "POST" && (seg[1] === "send" || seg[1] === "reply" || seg[1] === "mark_working" || seg[1] === "announce_address" || seg[1] === "poll_outbound" || seg[1] === "cancel_outbound")) {
2201
2201
  const checked = ownerRpcBody(`POST /owner/mcp/${seg[1]}`, await body());
2202
2202
  if (!checked.ok) return json({ error: checked.error, ...(checked.code ? { code: checked.code } : {}) }, 400);
2203
2203
  try {
@@ -3567,8 +3567,15 @@ async function peerRpc(ctx: TenantContext, ectx: ExecutionContext, peer: { alias
3567
3567
  const text = (await res.text()).slice(0, 1 << 20);
3568
3568
  let data: Json = null;
3569
3569
  try { data = JSON.parse(text); } catch { /* below */ }
3570
- if (res.status !== 200 || !data || typeof data !== "object" || "error" in data)
3571
- throw new ToolError(`peer "${peer.alias}" answered HTTP ${res.status}${data?.error ? `: ${String(data.error.message || JSON.stringify(data.error)).slice(0, 300)}` : ""}`);
3570
+ if (res.status !== 200 || !data || typeof data !== "object" || "error" in data) {
3571
+ const rpc = data?.error;
3572
+ const code = Number.isSafeInteger(rpc?.code) ? `JSON-RPC ${rpc.code}` : "JSON-RPC error";
3573
+ const detail = typeof rpc?.message === "string" ? rpc.message.replace(/[\x00-\x1f\x7f]/g, " ") : "The peer returned an invalid response";
3574
+ const reason = rpc ? `${code}: ${detail}` : detail;
3575
+ const status = res.status === 200 ? "" : `HTTP ${res.status}: `;
3576
+ const safe = peer.token ? reason.split(peer.token).join("[redacted]") : reason;
3577
+ throw new ToolError(`peer "${peer.alias}" answered ${status}${safe.slice(0, 300)}`);
3578
+ }
3572
3579
  return data.result;
3573
3580
  }
3574
3581
 
@@ -3717,17 +3724,19 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
3717
3724
  return M.toolText(tid ? `Sent to ${peer.alias}: task ${tid} (${M.plainState(obj)}), context ${sentContext}. Check it later with poll_outbound.${warn} ${UNTRUSTED}`
3718
3725
  : `Sent to ${peer.alias}; it answered directly (context ${sentContext}). ${UNTRUSTED}`, obj);
3719
3726
  }
3720
- case "poll_outbound": {
3727
+ case "poll_outbound":
3728
+ case "cancel_outbound": {
3729
+ const cancel = name === "cancel_outbound";
3721
3730
  const tid = A.checkId(argStr(a, "taskId"), "taskId");
3722
3731
  const rec = must(await ownerCall(ctx, ectx, "GET", `/owner/outbound/${enc(tid)}`));
3723
3732
  const peer = await outboundPeer(ctx, rec.peer);
3724
3733
  if (!rec.endpoint) throw new ToolError("this task has no recorded endpoint");
3725
- const res = await peerRpc(ctx, ectx, peer, rec.endpoint, rec.protocol || "0.3", "tasks/get", "GetTask", { id: tid });
3734
+ const res = await peerRpc(ctx, ectx, peer, rec.endpoint, rec.protocol || "0.3", cancel ? "tasks/cancel" : "tasks/get", cancel ? "CancelTask" : "GetTask", { id: tid });
3726
3735
  await ownerCall(ctx, ectx, "PUT", `/owner/outbound/${enc(tid)}`, { task: res });
3727
3736
  let txt = A.textOf((res?.status || {}).message || {});
3728
3737
  const arts = (res?.artifacts || []).map((x: Json) => A.textOf(x)).join("\n");
3729
3738
  if (arts && arts !== txt) txt = (txt ? txt + "\n" : "") + arts;
3730
- await ownerCall(ctx, ectx, "POST", "/owner/history", { contextId: rec.contextId, dir: "in", peer: peer.alias, taskId: tid, event: "poll", state: M.plainState(res), text: txt });
3739
+ await ownerCall(ctx, ectx, "POST", "/owner/history", { contextId: rec.contextId, dir: "in", peer: peer.alias, taskId: tid, event: cancel ? "cancel" : "poll", state: M.plainState(res), text: txt });
3731
3740
  return M.toolText(`Task ${tid} at ${peer.alias}: ${M.plainState(res)}. ${UNTRUSTED}`, res);
3732
3741
  }
3733
3742
  case "list_peers": {
package/worker/src/mcp.ts CHANGED
@@ -354,6 +354,9 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
354
354
  { name: "poll_outbound", title: "Check a sent task", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
355
355
  description: `Fetch the current state of a task you sent to a peer (from send) and record it in the history. ${RULES}`,
356
356
  inputSchema: { type: "object", properties: { taskId: str("Task id returned by send") }, required: ["taskId"], additionalProperties: false } },
357
+ { name: "cancel_outbound", title: "Cancel a sent task", annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
358
+ description: `Ask the peer to cancel a task returned by send. Uses the recorded peer and endpoint, records the returned state in history, and does not cancel an inbound task. The peer can refuse cancellation. Ask the human before canceling consequential work. ${RULES}`,
359
+ inputSchema: { type: "object", properties: { taskId: str("Task id returned by send") }, required: ["taskId"], additionalProperties: false } },
357
360
  { name: "list_peers", title: "List peers", annotations: ro,
358
361
  description: "Peers you can send to (outbound, synced with `peers sync`) and the labels of agents holding a token for this A2A endpoint (inbound; who can message you). Token values are never shown. An inbound peer with no card URL has no callback yet.",
359
362
  inputSchema: { type: "object", properties: {}, additionalProperties: false } },
@@ -4,7 +4,7 @@
4
4
  // PUT /owner/outbound-peers/:alias is the one call that accepts a peer token. handleOwner seals it.
5
5
  // The response is { ok, alias, hasToken } and the token is not logged.
6
6
  // GET /owner/export is here only because runOwnerRpc redacts it first.
7
- // POST /owner/mcp/send, reply, and mark_working take a checked argument object. A peer token is not one of those arguments.
7
+ // POST /owner/mcp/send, reply, mark_working, poll_outbound, and cancel_outbound take a checked argument object. A peer token is not one of those arguments.
8
8
  // Self-host never calls this.
9
9
 
10
10
  import { ID_RE } from "./a2a.ts";
@@ -169,6 +169,8 @@ const ROUTES: RouteDef[] = [
169
169
  { method: "DELETE", name: "DELETE /owner/mcp/:label", take: (s) => s.length === 3 && s[1] === "mcp" && MCP_LABEL.test(s[2]!), search: noQuery, path: (s) => `/owner/mcp/${s[2]}` },
170
170
  { method: "POST", name: "POST /owner/mcp/send", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "send", search: noQuery, path: () => "/owner/mcp/send" },
171
171
  { method: "POST", name: "POST /owner/mcp/reply", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "reply", search: noQuery, path: () => "/owner/mcp/reply" },
172
+ { method: "POST", name: "POST /owner/mcp/poll_outbound", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "poll_outbound", search: noQuery, path: () => "/owner/mcp/poll_outbound" },
173
+ { method: "POST", name: "POST /owner/mcp/cancel_outbound", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "cancel_outbound", search: noQuery, path: () => "/owner/mcp/cancel_outbound" },
172
174
  { method: "POST", name: "POST /owner/mcp/mark_working", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "mark_working", search: noQuery, path: () => "/owner/mcp/mark_working" },
173
175
  { method: "POST", name: "POST /owner/mcp/announce_address", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "announce_address", search: noQuery, path: () => "/owner/mcp/announce_address" },
174
176
  { method: "GET", name: "GET /owner/outbound-peers", take: (s) => s.length === 2 && s[1] === "outbound-peers", search: noQuery, path: () => "/owner/outbound-peers" },
@@ -204,6 +206,8 @@ const REPLY_STATES = new Set(["completed", "input-required", "working", "failed"
204
206
  const MCP_ARGS: Record<string, { required: readonly string[]; optional: readonly string[] }> = {
205
207
  "POST /owner/mcp/send": { required: ["to", "text"], optional: ["contextId", "taskId"] },
206
208
  "POST /owner/mcp/reply": { required: ["taskId", "text"], optional: ["state"] },
209
+ "POST /owner/mcp/poll_outbound": { required: ["taskId"], optional: [] },
210
+ "POST /owner/mcp/cancel_outbound": { required: ["taskId"], optional: [] },
207
211
  "POST /owner/mcp/mark_working": { required: ["taskId"], optional: [] },
208
212
  };
209
213
 
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.37";
2
+ export const VERSION = "0.8.38";
@@ -329,7 +329,7 @@ test("scope: the MCP token is not an owner or peer token, and peer/owner tokens
329
329
  assert.equal((await s.call("GET", "/mcp", { headers: { authorization: `Bearer ${a.access_token}` } })).status, 405);
330
330
  // tools can't administer tokens or pairings
331
331
  const names = (await s.mcp(a.access_token, "tools/list")).data.result.tools.map((x: any) => x.name);
332
- assert.deepEqual(names, ["inbox", "show_task", "history", "mark_working", "reply", "send", "poll_outbound", "list_peers", "announce_address", "pairing_requests", "send_feedback"]);
332
+ assert.deepEqual(names, ["inbox", "show_task", "history", "mark_working", "reply", "send", "poll_outbound", "cancel_outbound", "list_peers", "announce_address", "pairing_requests", "send_feedback"]);
333
333
  assert.ok(!names.some((n: string) => /approve|deny|token|revoke|issue|password/.test(n)));
334
334
  const unknown = await s.mcp(a.access_token, "tools/call", { name: "approve_pairing", arguments: { code: "X" } });
335
335
  assert.equal(unknown.data.error.code, -32602);
@@ -21,17 +21,27 @@ test("hosted control plane send, reply, and mark_working", async (t) => {
21
21
  config: { AGENT_NAME: "Carol" },
22
22
  });
23
23
  const peerCalls: { host: string; auth: string | null; body: any }[] = [];
24
+ let modern = true;
25
+ let cancelRefused = false;
24
26
  const origFetch = globalThis.fetch;
25
27
  globalThis.fetch = (async (input: any, init: any = {}) => {
26
28
  const u = new URL(String(input));
27
29
  if (u.pathname === "/.well-known/agent-card.json")
28
- return Response.json({ name: u.host, supportedInterfaces: [{ url: `https://${u.host}/a2a`, protocolBinding: "JSONRPC", protocolVersion: "1.0" }] });
30
+ return modern ? Response.json({ name: u.host, supportedInterfaces: [{ url: `https://${u.host}/a2a`, protocolBinding: "JSONRPC", protocolVersion: "1.0" }] }) : Response.json({ url: `https://${u.host}/a2a`, protocolVersion: "0.3" });
29
31
  if (u.pathname === "/a2a") {
30
32
  const body = JSON.parse(init.body);
31
33
  const auth = new Headers(init.headers).get("authorization");
32
34
  peerCalls.push({ host: u.host, auth, body });
33
35
  if (auth !== `Bearer tok-${u.host.split(".")[0]}`) return new Response("no", { status: 401 });
34
- if (body.method === "SendMessage") return Response.json({ jsonrpc: "2.0", id: body.id, result: { task: { id: `pt-${u.host.split(".")[0]}`, contextId: "ctx-out-1", status: { state: "TASK_STATE_SUBMITTED" } } } });
36
+ if (body.method === "GetTask" || body.method === "tasks/get" || body.method === "CancelTask" || body.method === "tasks/cancel") {
37
+ assert.equal(new Headers(init.headers).get("a2a-version"), modern ? "1.0" : "0.3");
38
+ const cancel = body.method === "CancelTask" || body.method === "tasks/cancel";
39
+ if (cancel && cancelRefused) return Response.json({ jsonrpc: "2.0", id: body.id, error: { code: -32002, message: "Task cannot be canceled" } });
40
+ return Response.json({ jsonrpc: "2.0", id: body.id, result: { id: body.params.id, contextId: "ctx-out-1", status: { state: modern ? (cancel ? "TASK_STATE_CANCELED" : "TASK_STATE_WORKING") : (cancel ? "canceled" : "working") } } });
41
+ }
42
+ if (body.params?.message?.parts[0]?.text === "echo-token") return Response.json({ jsonrpc: "2.0", id: body.id, error: { code: -32603, message: "Rejected tok-alpha" } });
43
+ if (body.params?.message?.parts[0]?.text === "terminal") return Response.json({ jsonrpc: "2.0", id: body.id, error: { code: -32002, message: "Task is in a terminal state" } });
44
+ if (body.method === "SendMessage" || body.method === "message/send") return Response.json({ jsonrpc: "2.0", id: body.id, result: { task: { id: `pt-${u.host.split(".")[0]}${modern ? "" : "-03"}`, contextId: "ctx-out-1", status: { state: "TASK_STATE_SUBMITTED" } } } });
35
45
  }
36
46
  return new Response("not found", { status: 404 });
37
47
  }) as typeof fetch;
@@ -89,6 +99,47 @@ test("hosted control plane send, reply, and mark_working", async (t) => {
89
99
  assert.equal(peerCalls.at(-1)!.body.params.message.parts[0].text, "status?");
90
100
  assert.equal(peerCalls.at(-1)!.body.params.message.contextId, "ctx-hosted");
91
101
 
102
+ for (const version of [true, false]) {
103
+ modern = version;
104
+ const taskId = version ? "pt-alpha" : "pt-alpha-03";
105
+ await rpc("/owner/mcp/send", { to: "alpha", text: "status?" });
106
+ for (const tool of ["poll_outbound", "cancel_outbound"]) {
107
+ const before = peerCalls.length;
108
+ const missing = await rpc(`/owner/mcp/${tool}`, { taskId: "unknown" });
109
+ assert.equal((missing.body as any).isError, true);
110
+ assert.equal(peerCalls.length, before, "unknown task never calls a peer");
111
+ const denied = await http("POST", `/owner/mcp/${tool}`, "Bearer platform-owner", { taskId });
112
+ assert.equal(denied.status, 401);
113
+ const result = await rpc(`/owner/mcp/${tool}`, { taskId });
114
+ assert.equal(result.status, 200);
115
+ assert.equal((result.body as any).isError, false, JSON.stringify(result.body));
116
+ assert.match((result.body as any).text, tool === "poll_outbound" ? /working/ : /canceled/);
117
+ assert.equal(peerCalls.at(-1)!.auth, "Bearer tok-alpha");
118
+ assert.equal(peerCalls.at(-1)!.body.method, version ? (tool === "poll_outbound" ? "GetTask" : "CancelTask") : (tool === "poll_outbound" ? "tasks/get" : "tasks/cancel"));
119
+ assert.deepEqual(peerCalls.at(-1)!.body.params, { id: taskId });
120
+ assert.equal(JSON.stringify(result.body).includes("tok-alpha"), false);
121
+ const stored = await http("GET", `/owner/outbound/${taskId}`, `Bearer ${ownerToken}`);
122
+ assert.match(JSON.stringify(stored.data), tool === "poll_outbound" ? /WORKING|working/ : /CANCELED|canceled/);
123
+ }
124
+ cancelRefused = true;
125
+ const beforeRefusal = await http("GET", `/owner/outbound/${taskId}`, `Bearer ${ownerToken}`);
126
+ const refused = await rpc("/owner/mcp/cancel_outbound", { taskId });
127
+ assert.equal((refused.body as any).isError, true);
128
+ assert.match((refused.body as any).text, /JSON-RPC -32002: Task cannot be canceled/);
129
+ assert.deepEqual((await http("GET", `/owner/outbound/${taskId}`, `Bearer ${ownerToken}`)).data, beforeRefusal.data, "refused cancellation preserves the saved task");
130
+ cancelRefused = false;
131
+ const error = await rpc("/owner/mcp/send", { to: "alpha", text: "terminal" });
132
+ assert.equal((error.body as any).isError, true);
133
+ assert.match((error.body as any).text, /JSON-RPC -32002: Task is in a terminal state/);
134
+ assert.doesNotMatch((error.body as any).text, /HTTP 200/);
135
+ }
136
+ modern = true;
137
+ const echoed = await rpc("/owner/mcp/send", { to: "alpha", text: "echo-token" });
138
+ assert.equal((echoed.body as any).isError, true);
139
+ assert.doesNotMatch(JSON.stringify(echoed.body), /tok-alpha/);
140
+ const history = await http("GET", "/owner/history/ctx-out-1", `Bearer ${ownerToken}`);
141
+ assert.match(JSON.stringify(history.data), /"event":"cancel"/);
142
+
92
143
  const viaHttp = await http("POST", "/owner/mcp/send", `Bearer ${ownerToken}`, { to: "alpha", text: "from the owner token" });
93
144
  assert.equal(viaHttp.status, 200, viaHttp.text);
94
145
  assert.equal(viaHttp.data.isError, false);
@@ -54,7 +54,11 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
54
54
  assert.deepEqual(refused("POST", "/owner/purge"), { ok: false, reason: "path" });
55
55
  assert.deepEqual(refused("POST", "/owner/tasks/task-1/reply"), { ok: false, reason: "path" });
56
56
  assert.deepEqual(refused("POST", "/owner/mcp/send", { to: "alpha" }), { ok: false, reason: "query" });
57
- assert.deepEqual(refused("POST", "/owner/mcp/poll_outbound"), { ok: false, reason: "path" });
57
+ for (const tool of ["poll_outbound", "cancel_outbound"]) {
58
+ assert.equal(ok("POST", `/owner/mcp/${tool}`)?.path, `/owner/mcp/${tool}`);
59
+ assert.deepEqual(ownerRpcBody(`POST /owner/mcp/${tool}`, { taskId: "task-1" }), { ok: true, value: { taskId: "task-1" } });
60
+ for (const body of [{}, { taskId: ".." }, { taskId: "task-1", to: "other" }, { taskId: "task-1", token: "secret" }]) assert.equal(ownerRpcBody(`POST /owner/mcp/${tool}`, body).ok, false);
61
+ }
58
62
  assert.deepEqual(refused("PUT", "/owner/mcp/send"), { ok: false, reason: "path" });
59
63
  assert.deepEqual(refused("POST", "/owner/history"), { ok: false, reason: "path" });
60
64
  assert.deepEqual(refused("GET", "/owner/peers/../export"), { ok: false, reason: "path" });