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 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
- return console.error("wake settings deployed. Next: a2a-exposed wake preview, then a2a-exposed wake test");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: Cloudflare Worker inbox + wake webhook or scheduled inbox checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ );