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 +2 -0
- package/package.json +1 -1
- package/worker/src/index.ts +15 -6
- package/worker/src/mcp.ts +3 -0
- package/worker/src/owner-rpc.ts +5 -1
- package/worker/src/version.ts +1 -1
- package/worker/test/mcp.test.ts +1 -1
- package/worker/test/owner-mcp.test.ts +53 -2
- package/worker/test/owner-rpc.test.ts +5 -1
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
package/worker/src/index.ts
CHANGED
|
@@ -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
|
-
|
|
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 } },
|
package/worker/src/owner-rpc.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
2
|
+
export const VERSION = "0.8.38";
|
package/worker/test/mcp.test.ts
CHANGED
|
@@ -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 === "
|
|
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
|
-
|
|
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" });
|