a2a-exposed 0.4.1 → 0.5.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/lib/commands.mjs +41 -0
- package/lib/deploy.mjs +23 -3
- package/lib/main.mjs +8 -2
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +1 -0
- package/worker/migrations/0006_mcp.sql +43 -0
- package/worker/migrations/0007_cimd.sql +10 -0
- package/worker/src/index.ts +737 -9
- package/worker/src/mcp.ts +318 -0
- package/worker/src/pairing.ts +1 -1
- package/worker/src/wake.ts +12 -1
- package/worker/test/mcp.test.ts +697 -0
- package/worker/test/migrations.test.ts +5 -5
- package/worker/test/pairing.test.ts +15 -3
- package/worker/test/wake-retry.test.ts +51 -0
- package/worker/test/wake.test.ts +15 -0
package/lib/commands.mjs
CHANGED
|
@@ -101,6 +101,11 @@ export async function token(action, label, o) {
|
|
|
101
101
|
const via = r.source === "pairing" ? `\tvia pairing: code ${r.user_code || "?"}${r.client_name ? `, ${JSON.stringify(r.client_name)}` : ""}` : "";
|
|
102
102
|
console.log(`${r.label}\tcreated=${r.created_at}\t${r.revoked_at ? "REVOKED " + r.revoked_at : "active"}${via}`);
|
|
103
103
|
}
|
|
104
|
+
// MCP connectors (Worker 0.5+): their own tokens, listed and revoked by label like peer tokens
|
|
105
|
+
const m = await ownerTry("GET", "/owner/mcp").catch(() => null);
|
|
106
|
+
if (m && m.status === 200 && m.data && Array.isArray(m.data.grants))
|
|
107
|
+
for (const g of m.data.grants)
|
|
108
|
+
console.log(`${g.label}\tcreated=${g.created_at}\t${g.revoked_at ? "REVOKED " + g.revoked_at : "active"}\tMCP connector: ${JSON.stringify(g.client_name || "?")} -> ${g.redirect_host}${g.last_used_at ? `, last used ${g.last_used_at}` : ""}`);
|
|
104
109
|
return;
|
|
105
110
|
}
|
|
106
111
|
if (!label) die("label required");
|
|
@@ -118,6 +123,14 @@ export async function token(action, label, o) {
|
|
|
118
123
|
if (o.json) return out({ revoked: true, label });
|
|
119
124
|
return console.log(`revoked ${label}: its token gets HTTP 401 from now on`);
|
|
120
125
|
}
|
|
126
|
+
if (r.status === 404) {
|
|
127
|
+
// not a peer token: maybe an MCP connector
|
|
128
|
+
const m = await ownerTry("DELETE", `/owner/mcp/${q(label)}`).catch(() => null);
|
|
129
|
+
if (m && m.status === 200 && m.data && m.data.revoked === true) {
|
|
130
|
+
if (o.json) return out({ revoked: true, label, kind: "mcp" });
|
|
131
|
+
return console.log(`revoked ${label}: that MCP connector gets HTTP 401 from now on (remove it in the client, or reconnect to approve it again)`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
121
134
|
if (r.status === 404 || (r.status === 200 && r.data && r.data.revoked === false))
|
|
122
135
|
die((r.data && r.data.error) || `no active token with label ${label} (see: a2a-exposed token list)`);
|
|
123
136
|
die(`could not revoke ${label}: ${describeHttp(r.status, r.data)}`);
|
|
@@ -157,7 +170,35 @@ function resolvePeer(to) {
|
|
|
157
170
|
die(`unknown peer alias ${JSON.stringify(to)} (see: a2a-exposed peers list)`);
|
|
158
171
|
}
|
|
159
172
|
|
|
173
|
+
/** peers sync [alias...]: upload outbound peers (URL + token, encrypted on the Worker with a key derived from the owner
|
|
174
|
+
* token) so the MCP connector's send / poll_outbound tools can reach them. peers unsync <alias>: remove one. */
|
|
175
|
+
async function peersRemote(sub, args) {
|
|
176
|
+
if (sub === "unsync") {
|
|
177
|
+
if (!args.length) die("usage: peers unsync <alias>...");
|
|
178
|
+
for (const alias of args) {
|
|
179
|
+
const r = await ownerTry("DELETE", `/owner/outbound-peers/${q(alias)}`);
|
|
180
|
+
if (r.status !== 200) die((r.data && r.data.error) || `could not unsync ${alias}: ${describeHttp(r.status, r.data)}`);
|
|
181
|
+
console.log(`unsynced ${alias}: the connector can no longer send to it`);
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const all = C.loadPeers();
|
|
186
|
+
const list = args.length ? args : Object.keys(all);
|
|
187
|
+
if (!list.length) die("no peers to sync: add one first (a2a-exposed connect <url> or peers add)");
|
|
188
|
+
for (const alias of list) {
|
|
189
|
+
const pe = all[alias];
|
|
190
|
+
if (!pe) die(`unknown peer alias ${JSON.stringify(alias)} (see: a2a-exposed peers list)`);
|
|
191
|
+
const tok = peerToken(pe.token_env);
|
|
192
|
+
const r = await ownerTry("PUT", `/owner/outbound-peers/${q(alias)}`, { url: pe.url.replace(/\/$/, ""), ...(tok ? { token: tok } : {}) });
|
|
193
|
+
if (r.status === 404) die("this Worker has no MCP support yet: redeploy (a2a-exposed deploy) and try again");
|
|
194
|
+
if (r.status !== 200) die(`could not sync ${alias}: ${(r.data && r.data.error) || describeHttp(r.status, r.data)}`);
|
|
195
|
+
console.log(`synced ${alias}\t${pe.url}\t${tok ? "token stored encrypted on the Worker" : "no token"}`);
|
|
196
|
+
}
|
|
197
|
+
console.error("# the MCP connector's send tool can now reach these peers; re-run after connect / token changes or an owner-token rotation");
|
|
198
|
+
}
|
|
199
|
+
|
|
160
200
|
export function peers(sub, args, o) {
|
|
201
|
+
if (sub === "sync" || sub === "unsync") return peersRemote(sub, args);
|
|
161
202
|
const all = C.loadPeers();
|
|
162
203
|
if (sub === "add") {
|
|
163
204
|
const [alias, url] = args;
|
package/lib/deploy.mjs
CHANGED
|
@@ -11,6 +11,19 @@ import { readHidden } from "./pair.mjs";
|
|
|
11
11
|
import * as WD from "./workersdev.mjs";
|
|
12
12
|
|
|
13
13
|
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
|
+
/** Each Claude Code routine fire is a new cloud session: a 2xx only means a session started. */
|
|
15
|
+
export const CLAUDE_CODE_NOTE = [
|
|
16
|
+
"note (claude-code): each fire starts a NEW cloud session, which sees only what the routine provides.",
|
|
17
|
+
" Simplest: add this inbox as a connector at claude.ai/settings/connectors (URL: <your Worker URL>/mcp, approve with",
|
|
18
|
+
" your approval password) and keep it enabled on the routine; the session then uses its tools, no CLI or token needed.",
|
|
19
|
+
" Without the connector (CLI fallback), check that",
|
|
20
|
+
" - the repo with the committed skills (.claude/skills/) is a routine source and the skills are on its default branch,",
|
|
21
|
+
" - A2A_BASE_URL and A2A_OWNER_TOKEN are set on the routine's cloud environment (not only in this session),",
|
|
22
|
+
" - the environment's network access allows this Worker's hostname,",
|
|
23
|
+
" - the routine prompt tells Claude to act on the routine-fire-payload block.",
|
|
24
|
+
" Then open the new session from the routine's runs and confirm it ran the inbox command without errors.",
|
|
25
|
+
].join("\n");
|
|
26
|
+
|
|
14
27
|
const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "generic"];
|
|
15
28
|
// WAKE_ACCESS_*: Cloudflare Access service token for a wake URL behind Access (set by `tunnel create`, or exported
|
|
16
29
|
// by hand for your own Access-protected endpoint)
|
|
@@ -27,7 +40,7 @@ const DEPLOY_KEYS = [
|
|
|
27
40
|
"A2A_PROVIDER_ORGANIZATION", "A2A_PROVIDER_URL", "A2A_DOCUMENTATION_URL",
|
|
28
41
|
"WAKE_PRESET", "WAKE_AGENT_ID", "WAKE_KEY_HEADER", "WAKE_KEY_PREFIX", "WAKE_BODY_TEMPLATE", "WAKE_CLI_COMMAND",
|
|
29
42
|
"WAKE_DEBOUNCE_SECONDS", "WAKE_MAX_PER_HOUR", "A2A_MAX_BODY", "A2A_RATE_PER_MIN", "A2A_ENABLE_CRON",
|
|
30
|
-
"A2A_WORKERS_DEV_SUBDOMAIN", "A2A_RETIRED_HOSTNAMES", "PAIRING_APPROVAL", "A2A_PBKDF2_ITERATIONS", "A2A_WORKERS_LOGS",
|
|
43
|
+
"A2A_WORKERS_DEV_SUBDOMAIN", "A2A_RETIRED_HOSTNAMES", "PAIRING_APPROVAL", "A2A_PBKDF2_ITERATIONS", "A2A_WORKERS_LOGS", "A2A_MCP",
|
|
31
44
|
"A2A_UPSTREAM_URL", "A2A_UPSTREAM_CARD_URL",
|
|
32
45
|
];
|
|
33
46
|
// init/deploy flag -> config key
|
|
@@ -37,7 +50,7 @@ const FLAG_KEYS = {
|
|
|
37
50
|
"provider-organization": "A2A_PROVIDER_ORGANIZATION", "provider-url": "A2A_PROVIDER_URL",
|
|
38
51
|
preset: "WAKE_PRESET", "agent-id": "WAKE_AGENT_ID", "key-header": "WAKE_KEY_HEADER", "key-prefix": "WAKE_KEY_PREFIX",
|
|
39
52
|
"body-template": "WAKE_BODY_TEMPLATE", "cli-command": "WAKE_CLI_COMMAND", debounce: "WAKE_DEBOUNCE_SECONDS", "max-per-hour": "WAKE_MAX_PER_HOUR",
|
|
40
|
-
"pairing-approval": "PAIRING_APPROVAL", "pbkdf2-iterations": "A2A_PBKDF2_ITERATIONS", "workers-logs": "A2A_WORKERS_LOGS",
|
|
53
|
+
"pairing-approval": "PAIRING_APPROVAL", "pbkdf2-iterations": "A2A_PBKDF2_ITERATIONS", "workers-logs": "A2A_WORKERS_LOGS", mcp: "A2A_MCP",
|
|
41
54
|
upstream: "A2A_UPSTREAM_URL", "upstream-card-url": "A2A_UPSTREAM_CARD_URL",
|
|
42
55
|
};
|
|
43
56
|
export const PAIRING_MODES = ["human", "agent", "off"];
|
|
@@ -194,6 +207,10 @@ function applyFlags(o) {
|
|
|
194
207
|
die("--pbkdf2-iterations must be an integer from 50000 to 100000 (default 100000, the Workers maximum; lower it only if /device approvals hit Cloudflare error 1102, the CPU limit)");
|
|
195
208
|
upd.A2A_PBKDF2_ITERATIONS = String(n);
|
|
196
209
|
}
|
|
210
|
+
if (upd.A2A_MCP !== undefined) {
|
|
211
|
+
if (!["on", "off"].includes(upd.A2A_MCP)) die("--mcp must be on or off (the remote MCP server at /mcp that Claude and other MCP clients add as a connector; default on)");
|
|
212
|
+
upd.A2A_MCP = upd.A2A_MCP === "off" ? "off" : null;
|
|
213
|
+
}
|
|
197
214
|
if (upd.A2A_WORKERS_LOGS !== undefined) {
|
|
198
215
|
if (!["on", "off"].includes(upd.A2A_WORKERS_LOGS)) die("--workers-logs must be on or off (Cloudflare Workers Logs: persisted, searchable Worker logs; query strings redacted)");
|
|
199
216
|
upd.A2A_WORKERS_LOGS = upd.A2A_WORKERS_LOGS === "on" ? "1" : null;
|
|
@@ -556,6 +573,7 @@ export async function wake(sub, o) {
|
|
|
556
573
|
die("no wake webhook configured on the Worker: export WAKE_WEBHOOK_URL (and WAKE_WEBHOOK_KEY or WAKE_HMAC_SECRET), then run `a2a-exposed wake set` first (or use polling)");
|
|
557
574
|
console.log(JSON.stringify(r, null, 2));
|
|
558
575
|
if (!(r.status >= 200 && r.status < 300)) process.exitCode = 1;
|
|
576
|
+
else if (C.get("WAKE_PRESET") === "claude-code") console.error(CLAUDE_CODE_NOTE);
|
|
559
577
|
return;
|
|
560
578
|
}
|
|
561
579
|
if (sub === "set") {
|
|
@@ -564,7 +582,9 @@ export async function wake(sub, o) {
|
|
|
564
582
|
if (!Object.keys(secrets).length && !DEPLOY_FLAGS.some((f) => o[f] !== undefined))
|
|
565
583
|
die("nothing to set: export WAKE_WEBHOOK_URL / WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET and/or pass --preset etc.");
|
|
566
584
|
await deploy(o); // wake set = save settings + upload secrets + redeploy, in one step
|
|
567
|
-
|
|
585
|
+
console.error("wake settings deployed. Next: a2a-exposed wake preview, then a2a-exposed wake test");
|
|
586
|
+
if (C.get("WAKE_PRESET") === "claude-code") console.error(CLAUDE_CODE_NOTE);
|
|
587
|
+
return;
|
|
568
588
|
}
|
|
569
589
|
if (sub === "unset") {
|
|
570
590
|
const dir = workerDir(o), name = workerName();
|
package/lib/main.mjs
CHANGED
|
@@ -22,7 +22,7 @@ CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting)
|
|
|
22
22
|
[--agent-name N] [--agent-description D] [--agent-skills JSON]
|
|
23
23
|
[--provider-organization O --provider-url U] [--preset P] [--worker-name W] [--d1-name D]
|
|
24
24
|
[--account-id ID] [--cf-profile NAME] [--cli-command CMD] [--debounce S] [--max-per-hour N]
|
|
25
|
-
[--pairing-approval human|agent|off] [--pbkdf2-iterations N] [--workers-logs on|off] [--cron]
|
|
25
|
+
[--pairing-approval human|agent|off] [--pbkdf2-iterations N] [--workers-logs on|off] [--mcp on|off] [--cron]
|
|
26
26
|
[--upstream URL [--upstream-card-url URL] [--upstream-token-stdin | --no-upstream-token]] [--worker-dir DIR]
|
|
27
27
|
deploy the Worker + D1 to your account; wake secrets are read from
|
|
28
28
|
env WAKE_WEBHOOK_URL / WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET (never argv)
|
|
@@ -39,6 +39,8 @@ CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting)
|
|
|
39
39
|
--pbkdf2-iterations approval-password hashing cost, 50000-100000 (default 100000,
|
|
40
40
|
the Workers maximum; lower only if /device hits error 1102)
|
|
41
41
|
--workers-logs on|off persisted Cloudflare Workers Logs (query strings redacted)
|
|
42
|
+
--mcp on|off remote MCP server at <url>/mcp for Claude and other MCP clients
|
|
43
|
+
(default on; each client needs your approval password once)
|
|
42
44
|
--upstream URL proxy / expose mode: a public façade for an agent that already
|
|
43
45
|
speaks A2A (JSON-RPC) on a private network. URL is its endpoint on
|
|
44
46
|
a Cloudflare Tunnel hostname behind Access (never a Tailnet/LAN
|
|
@@ -133,12 +135,16 @@ Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens
|
|
|
133
135
|
Inbound peer tokens (one per peer label; paired agents get one too)
|
|
134
136
|
token issue <label> print a new token once (fails if the label is active); manual fallback to \`connect\`
|
|
135
137
|
token rotate <label> replace the label's token
|
|
136
|
-
token list | token revoke <label> list shows how each token was created (pairing: code + date)
|
|
138
|
+
token list | token revoke <label> list shows how each token was created (pairing: code + date)
|
|
139
|
+
and MCP connectors (label mcp-*); revoke works for both;
|
|
137
140
|
revoke of an unknown label exits 1
|
|
138
141
|
|
|
139
142
|
Outbound (agents you call)
|
|
140
143
|
peers add <alias> <url> [--token-env VAR | --token-stdin]
|
|
141
144
|
peers list | peers rm <alias>
|
|
145
|
+
peers sync [alias...] | peers unsync <alias>
|
|
146
|
+
upload peers (URL + token, encrypted on the Worker) so the MCP connector can send to
|
|
147
|
+
them; default: all peers. Re-run after connect, token changes or an owner-token rotation
|
|
142
148
|
a token variable set in the environment overrides config.env; when a peer token
|
|
143
149
|
differs between the two, connect/send/poll/peers/status warn (values never shown)
|
|
144
150
|
send --to <alias|url> [--text T | stdin] [--context C] [--task T] [--push] [--proto 0.3|1.0]
|
package/package.json
CHANGED
|
@@ -39,6 +39,7 @@ const plain: Record<string, string> = {
|
|
|
39
39
|
RATE_PER_MIN: v("A2A_RATE_PER_MIN"),
|
|
40
40
|
RETIRED_HOSTNAMES: v("A2A_RETIRED_HOSTNAMES"), // custom domains this agent moved away from (see movedResponse)
|
|
41
41
|
PAIRING_APPROVAL: v("PAIRING_APPROVAL") || "human", // device-flow pairing: human (approval password on /device) | agent | off
|
|
42
|
+
MCP: v("A2A_MCP"), // remote MCP server at /mcp (connector for Claude etc.): on unless "off"; never in proxy mode
|
|
42
43
|
PBKDF2_ITERATIONS: v("A2A_PBKDF2_ITERATIONS"), // approval password hashing; default 100000 (the Workers maximum), at least 50000
|
|
43
44
|
// proxy / expose mode: forward A2A to an agent that already speaks it, published through a Tunnel hostname behind
|
|
44
45
|
// Access (never a Tailnet / LAN URL: the Worker can't reach those, and they never appear in the public card)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- Remote MCP server (/mcp): OAuth 2.1 clients (dynamic registration), one-time authorization codes, and the grants
|
|
2
|
+
-- (owner-approved, inbox-scoped tokens) that `token list` shows and `token revoke` ends. Peer tokens stay in `peers`
|
|
3
|
+
-- and are never accepted on /mcp; MCP tokens are never accepted on the A2A endpoint or /owner/*.
|
|
4
|
+
CREATE TABLE IF NOT EXISTS oauth_clients (
|
|
5
|
+
client_id TEXT PRIMARY KEY,
|
|
6
|
+
client_name TEXT,
|
|
7
|
+
redirect_uris_json TEXT NOT NULL,
|
|
8
|
+
created_ms INTEGER NOT NULL,
|
|
9
|
+
ip TEXT
|
|
10
|
+
);
|
|
11
|
+
CREATE TABLE IF NOT EXISTS oauth_codes (
|
|
12
|
+
code_hash TEXT PRIMARY KEY,
|
|
13
|
+
client_id TEXT NOT NULL,
|
|
14
|
+
redirect_uri TEXT NOT NULL,
|
|
15
|
+
code_challenge TEXT NOT NULL,
|
|
16
|
+
scope TEXT NOT NULL,
|
|
17
|
+
resource TEXT NOT NULL,
|
|
18
|
+
created_ms INTEGER NOT NULL,
|
|
19
|
+
expires_ms INTEGER NOT NULL
|
|
20
|
+
);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS mcp_grants (
|
|
22
|
+
label TEXT PRIMARY KEY,
|
|
23
|
+
client_id TEXT NOT NULL,
|
|
24
|
+
client_name TEXT,
|
|
25
|
+
redirect_host TEXT,
|
|
26
|
+
scope TEXT NOT NULL,
|
|
27
|
+
access_hash TEXT UNIQUE,
|
|
28
|
+
access_expires_ms INTEGER,
|
|
29
|
+
refresh_hash TEXT UNIQUE,
|
|
30
|
+
refresh_expires_ms INTEGER,
|
|
31
|
+
created_at TEXT NOT NULL,
|
|
32
|
+
last_used_at TEXT,
|
|
33
|
+
revoked_at TEXT
|
|
34
|
+
);
|
|
35
|
+
-- Peers this inbox sends to from the Worker (MCP `send` / `poll_outbound`), uploaded by `peers sync`. The token is
|
|
36
|
+
-- AES-GCM ciphertext under a key derived from the Worker's OWNER_TOKEN secret, so a D1 export alone doesn't reveal it.
|
|
37
|
+
CREATE TABLE IF NOT EXISTS outbound_peers (
|
|
38
|
+
alias TEXT PRIMARY KEY,
|
|
39
|
+
url TEXT NOT NULL,
|
|
40
|
+
token_enc TEXT,
|
|
41
|
+
created_at TEXT NOT NULL,
|
|
42
|
+
updated_at TEXT NOT NULL
|
|
43
|
+
);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- MCP authorization (2026-07-28): OAuth Client ID Metadata Documents. A client_id that is an https URL names a JSON
|
|
2
|
+
-- document the Worker fetches (SSRF-guarded, 5 KB, 5 s) and validates; valid documents are cached here for their
|
|
3
|
+
-- Cache-Control max-age (60 s - 24 h, default 1 h). Errors and invalid documents are never cached.
|
|
4
|
+
CREATE TABLE IF NOT EXISTS oauth_client_metadata (
|
|
5
|
+
client_id TEXT PRIMARY KEY,
|
|
6
|
+
client_name TEXT,
|
|
7
|
+
redirect_uris_json TEXT NOT NULL,
|
|
8
|
+
fetched_ms INTEGER NOT NULL,
|
|
9
|
+
expires_ms INTEGER NOT NULL
|
|
10
|
+
);
|