Skip to content

feat: agent onboarding: remote MCP with OAuth, lasting agent connections, sandbox in every SDK and the CLI (web 0.48.0) - #557

Merged
kroqdotdev merged 11 commits into
mainfrom
feat/agent-onboarding
Oct 28, 2026
Merged

kroqdotdev merged 11 commits into
mainfrom
feat/agent-onboarding

Conversation

@kroqdotdev

@kroqdotdev kroqdotdev commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

Makes it as easy as possible for AI agents to find, register for and use webhooks.cc, ideally without their human touching anything. One PR for the whole effort, as asked.

What changes

1. What made agents fail

  • Connections survive restarts. A shared agent credential file, ~/.config/whk/agent.json (XDG, macOS ~/Library/Application Support/whk/agent.json, Windows %APPDATA%\whk\agent.json, WHK_AGENT_FILE override), mode 0600, atomic writes. The MCP server, the TypeScript, Python and Go SDKs and the whk CLI all read and write it. Spec: packages/sdk/conformance/agent-credentials.md; every implementation runs the same vectors (agent-credentials.json, 15 path, 6 issuer, 31 read and 16 write cases). The claim token is never written, as /auth.md asks. invalid_grant removes the slot; the MCP server then swaps back to the sandbox tools.
  • The MCP server starts connected when the file holds a live connection (full 46 tools), and npx @webhooks-cc/mcp setup <tool> works without an API key (the VS Code and Codex configs it writes now use their current formats).
  • Legacy v0.1 SDK helpers (registerAnonymous, registerWithEmail, confirmEmailOtp, registerWithIdJag, their pollClaim/waitForClaim) are @deprecated and warn once; removal needs a major version, so it waits for G after 2026-11-30. npm shows about 7 downloads a week across MCP versions older than 1.11.0 (the ones that used v0.1). The 410 and verified_email answers now carry an upgrade notice in their body.
  • llms.txt no longer says the key is required.
  • Sandbox in Python, Go and the CLI, with parity with WebhooksCC.sandbox(): register with proof of work, reuse the saved sandbox, claim, connect(email), connected(). whk sandbox create|list|get|delete|requests|request|wait|status|connect|forget, all with --json; connect prints one JSON line with the link and code, waits, then prints the outcome.

2. /auth.md points agents at the tools

A "Start here" section above Step 1 lists the shortest way in for each kind of agent: the remote MCP server, the local MCP server, the Claude Code plugin, the Python/Node/Go SDKs and the CLI, plain HTTP; then the credential file and the claim email. The agents docs, llms.txt, the plugin skill and the MCP and SDK READMEs say the same.

3. Remote MCP server at https://webhooks.cc/mcp

Design note

  • Where it runs: a Next.js route in the web app (app/mcp/route.ts, lib/mcp/hosted.ts), not a separate service. It uses the v2 MCP TypeScript SDK (@modelcontextprotocol/server 2.4, which implements 2026-07-28) with createMcpHandler: stateless, no initialize or Mcp-Session-Id for 2026-07-28 clients (per-request _meta, server/discover, Mcp-Method/Mcp-Name headers, ttlMs/cacheScope on tools/list), and the SDK's stateless fallback for clients still on the 2025 revisions, which is most hosted clients today. JSON responses only.
  • Tools are not forked: registerTools() from packages/mcp/src/tools.ts now takes a small ToolServer interface; the stdio server keeps the v1 SDK unchanged, the remote one adapts the v2 server. A new @webhooks-cc/mcp/tools entry exports the definitions without the stdio server. Remote mode leaves out send_to and replay_request (they post to a URL the caller names, which would run from our network and cannot reach the caller's machine anyway), refuses targetUrl on render_email and test_webhook_flow, and caps waits at 50 s (Cloudflare ends requests at 100 s): 44 tools.
  • How tools reach the API: through the SDK over loopback (MCP_INTERNAL_API_URL, default http://127.0.0.1:$PORT) with the caller's own bearer and address (cf-connecting-ip), so the API routes authenticate, rate-limit and audit exactly as for any client.
  • Token format: access tokens are ordinary whcc_ rows in api_keys (1 hour), so every route accepts them unchanged; a dashboard API key also works as a bearer at /mcp. Refresh tokens are whrt_, rotate on every use, and stop after 90 days unused; a rotated token presented again within 60 s gets a new pair (a client that lost a response) and supersedes the one it lost; later, or a superseded token at any time, revokes the grant. Codes are whac_, 5 minutes, single use; a replayed code revokes what the first use created.
  • Grants are agent registrations: kind oauth, claimed at once (migration 00066), so they share Connected agents, the cap of 10 and disconnecting with agents connected by code.
  • Scopes: webhooks:read webhooks:write, as for claimed agents. Like every agent token they are advisory: routes do not enforce them per call, so a client asking for less still gets the account API, and the consent page says so. Enforcing scopes per route is a separate piece of work.
  • Client registration: Client ID Metadata Documents (client_id_metadata_document_supported) are preferred, and Dynamic Client Registration is supported too, because most clients still use it. Documents are fetched through the notify proxy like forwarding, so the box's IP stays hidden, and are cached for 5 minutes; the proxy returns the first 1 KB, which is the size limit (real documents from Claude, Claude Code and VS Code are 300 to 500 bytes). Public clients only (PKCE S256); a DCR request for a secret-based method is registered as none (RFC 7591 3.2.1). Loopback redirect URIs match on any port (RFC 8252).
  • Issuer binding: every authorization response carries iss (RFC 9207; authorization_response_iss_parameter_supported), and DCR client ids exist only at this issuer.
  • Consent: /oauth/authorize reuses the web session (sign in, then approve once in the client's connect dialog, no code). It posts with the session's bearer token, never a cookie, and navigates to the redirect itself, because the CSP's form-action 'self' would block a redirect after a form post. A refused request is shown, never redirected: a dynamically registered redirect URI proves nothing, so the page navigates only after the human clicks Connect or Decline. Redirect URIs must be https (not on webhooks.cc's own hosts), http on loopback, or an app's own scheme (an allowlist plus reverse-domain schemes); client ids on our own hosts are refused, since anyone can serve a document from a capture URL.
  • Outbound sends: send_webhook and test_webhook_flow post to a capture URL through the notify proxy, so a capture never shows the box's IP.
  • Rate limits: /mcp 300 requests a minute per token, 120 a minute per address without one, one JSON-RPC message per request (no batches), and 8 tool calls running at once per account (counted around the handler, since a 2025-era client's answer streams back before the tool finishes); waits capped at 50 s with polls 1 s apart at least; DCR 20 an hour per address; the authorization_code and refresh_token grants 600 a minute per address (hosted clients refresh for many users from few addresses), the existing agent grants keep 60.
  • Discovery: /.well-known/oauth-protected-resource/mcp for the resource https://webhooks.cc/mcp, which 401s point to; the authorization server metadata gains authorization_endpoint, registration_endpoint, code_challenge_methods_supported, response_types_supported and the rest. packages/mcp/server.json gains a remotes entry.

4. Discovery

  • /.well-known/agent-skills/index.json (Agent Skills Discovery RFC v0.2.0) serving the webhook-testing skill with its digest.
  • /.well-known/api-catalog (RFC 9727 linkset), advertised in the Link header next to rel="auth.md" on every response.
  • openapi.yaml: the OAuth2 security scheme and the agent identity, sandbox and token paths.
  • Markdown for agents: /index.md, /docs/<page>.md, and Accept: text/markdown on / and /docs/*.
  • Metadata cleanup: left as it was, on purpose. auth.md's own example restates the protected-resource fields in the authorization server metadata, so agents following it may read them there, and register_uri/claim_uri are kept for the agent-readiness scanners noted in the code.

Versions

web 0.48.0 (0.46.0 and 0.47.0 belong to #555 and #556), SDK 1.19.0, MCP 1.15.0, CLI 1.6.0, Python 0.3.0, Go 0.3.0. Migration 00066 (00065 is #555's).

Rollout (needs your OK at each production step)

  1. Apply 00066_oauth_mcp.sql to production, then NOTIFY pgrst, 'reload schema'.
  2. Deploy the web release. No new required env vars; the notify proxy needs no change.
  3. Cloudflare: extend the Browser Integrity Check rule (below), or default user agents get 403 on /mcp, /openapi.yaml, /docs/*.md and /install.sh.
  4. Tag sdk-v1.19.0, then mcp-v1.15.0 once the SDK is on npm (the MCP Registry entry with the remote goes out with it), sdk-python-v0.3.0, packages/sdk-go/v0.3.0, and the CLI release.

Cloudflare rule (not applied)

Zone webhooks.cc, Rules, Configuration Rules, rule "Browser Integrity Check off for receiver, API and agent discovery". Replace its expression with:

(http.host eq "go.webhooks.cc") or (http.host eq "webhooks.cc" and (
  starts_with(http.request.uri.path, "/api/")
  or starts_with(http.request.uri.path, "/.well-known/")
  or starts_with(http.request.uri.path, "/mcp/")
  or starts_with(http.request.uri.path, "/docs.md/")
  or (starts_with(http.request.uri.path, "/docs/") and ends_with(http.request.uri.path, ".md"))
  or http.request.uri.path in {"/auth.md" "/llms.txt" "/llms-full.txt" "/robots.txt" "/mcp" "/openapi.yaml" "/index.md" "/docs.md" "/install.sh"}
  or any(http.request.headers["accept"][*] contains "text/markdown")
))

Check afterwards with curl -s -o /dev/null -w '%{http_code}' -A 'Python-urllib/3.12' https://webhooks.cc/openapi.yaml (should be 200).

Verified

  • Suites: TS SDK 875, MCP 92, web unit 960, web integration (agent identity, sandbox, auth, claim, OAuth and MCP) 54, Python 810 (pytest, ruff, mypy), Go (vet, -race, staticcheck, Windows vet of the file code), CLI 145 (clippy clean); pnpm typecheck, pnpm lint, Prettier and next build pass.
  • Local end to end, against the dev stack: DCR, consent, token, then /mcp in both protocol eras with real tool calls over loopback (create_endpoint, send_webhook captured by the local receiver, wait_for_request, list_endpoints, delete_endpoint); the app is listed under Connected agents.
  • Cross-tool sharing: a sandbox registered by the TS SDK was reused by the Python SDK, the Go SDK and the CLI without registering again; whk sandbox connect plus a human completing the claim left a claimed slot (no claim token in the file); WebhooksCC.connected() and a fresh stdio MCP server then started connected (46 tools); after the human disconnected the agent, the MCP server swapped back to the 11 sandbox tools and the slot was removed.
  • Consent page in the classic and clean styles, and the browser navigating to the client's redirect with code, state and iss.
  • Production, read-only: WebhooksCC.connected() with an existing claimed assertion exchanged it, listed the account's endpoints and cached the token in the file.

Review

CodeRabbit skips PRs over 100 files, so two fresh subagent reviews (OAuth and /mcp; the credential file across the five implementations) and a verification round were the gate. Round one found five blocking issues, all fixed: an open redirect from the consent page's error handling, client metadata documents served from a capture URL, the hosted send_webhook exposing the origin IP, unbounded wait loops, and the TS store removing slots another process had replaced (writes that follow from using a slot are now conditional on the registration, in the spec too). The verification round confirmed those and found the wait-loop bounds still incomplete (the cap counted HTTP requests, which a 2025-era streamed answer or a batch slipped past; the poll floor had no ceiling and did not apply to a left-out interval); both are fixed as described above. A third, focused round confirmed the bounds hold (no blocking issues); its four optional points are in too (no listChanged on the hosted server so listen streams close at once, polls never sleep past the deadline, test_webhook_flow polls once a second, wait_for_email reads the endpoint before waiting). A fresh review on a different model (fable, with sub-reviews of the docs and OpenAPI and of the Go port) found no blocking code issues; it corrected the OpenAPI description of the token and registration endpoints, and its optional points for the Go, Python and Rust ports are fixed (refusals remembered, sandbox_closed drops the slot, emails normalised, dead pending claims re-registered, Retry-After honoured, secrets never printed, the CLI's conditional writes made atomic, device login independent of the agent connection).

CodeQL alert 207 (js/request-forgery on the forwarding module's direct send, now reachable from the OAuth client_id) was researched separately and dismissed as a false positive: the source is the signed-in consent route; client ids must be https, not on our hosts, not IP literals or localhost; production fetches through the notify-proxy Worker; the direct path refuses private and reserved addresses after resolution, pins the checked addresses, follows no redirects and reads 1 KB.

Decisions for you

  • Claim token persistence. auth.md keeps the claim token in memory, so only the process that registered a sandbox can move its endpoints into an account; a later process (each CLI command, a new MCP session) connects the human through a new registration and the sandbox endpoints stay behind until they expire. Persisting the claim token in the 0600 file (next to assertions that are at least as powerful) would let them move. Kept as asked.
  • TS SDK default. WebhooksCC.sandbox() now reads and writes the file by default, like the other tools (credentialsFile: false opts out). Test suites that create sandbox endpoints without deleting them will share one sandbox per machine for 24 hours.
  • Scopes stay advisory, as for every agent token; per-route enforcement is separate work.
  • Vary: Accept on the HTML homepage and docs needs Caddy (header +Vary Accept for / and /docs*); Next replaces it on rendered pages. Cloudflare does not cache HTML here, so nothing breaks meanwhile.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e93df4a5-9fe8-4998-95fe-6d45d6586a89
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

⚠️ Deprecation Warning: The deny-licenses option is deprecated for possible removal in the next major release. For more information, see issue 997.

Dependency Review

The following issues were found:
  • ✅ 0 vulnerable package(s)
  • ✅ 0 package(s) with incompatible licenses
  • ✅ 0 package(s) with invalid SPDX license definitions
  • ⚠️ 1 package(s) with unknown licenses.
See the Details below.

License Issues

apps/web/package.json

PackageVersionLicenseIssue Type
@webhooks-cc/mcpworkspace:*NullUnknown License
Denied Licenses: GPL-3.0, AGPL-3.0

OpenSSF Scorecard

PackageVersionScoreDetails
npm/@modelcontextprotocol/server ^2.4.0 UnknownUnknown
npm/@webhooks-cc/mcp workspace:* UnknownUnknown
npm/@modelcontextprotocol/core 2.4.0 UnknownUnknown
npm/@modelcontextprotocol/server 2.4.0 UnknownUnknown

Scanned Files

  • apps/web/package.json
  • pnpm-lock.yaml

kroqdotdev and others added 11 commits October 28, 2026 22:53
…1.19.0)

WebhooksCC.sandbox() keeps the sandbox in ~/.config/whk/agent.json and
reuses it; waitForClaim() saves the account connection, connected() and
fromSavedConnection() read it back, and connect({ email }) connects an
account without a sandbox. The file's rules are in
conformance/agent-credentials.md with vectors every port runs. Adds a fetch
option to the client and deprecates the auth.md v0.1 helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1.15.0)

Without an API key the server starts with the full tools when the agent
credential file holds a live connection, and swaps back to the sandbox
tools when the human disconnects it. setup works without a key.
registerTools takes a small ToolServer interface and has a remote mode,
exported as @webhooks-cc/mcp/tools for the hosted server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…0.3.0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
….6.0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…here guide (web 0.48.0)

A remote MCP server at /mcp (MCP 2026-07-28 with the 2025 fallback) behind
an OAuth 2.1 authorization server (authorization code with PKCE, Client ID
Metadata Documents and dynamic client registration, rotating refresh
tokens, iss in responses); grants are agent registrations of kind oauth
(migration 00066). /auth.md starts with the shortest way in for each kind
of agent. Agent Skills index, RFC 9727 API catalog, OAuth in openapi.yaml,
and markdown twins of the homepage and docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntial file

Consent errors are shown, never redirected, and redirect URIs and client
metadata documents on webhooks.cc's own hosts are refused; app schemes are
an allowlist plus reverse-domain ones. Hosted sends to a capture URL go
through the notify proxy. Remote waits are bounded (50 s, polls 1 s to 50 s
apart, 8 running tools per account, no batches). A refresh retry inside the
grace window supersedes the token it lost. Writes that follow from using a
credential slot only apply while it holds the same registration, in every
port; an unreadable file is never written over; claim tokens never print.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s short

The hosted server advertises no listChanged, so a subscriptions/listen
stream completes at once. Poll loops never sleep past the deadline,
test_webhook_flow polls once a second, and wait_for_email reads the
endpoint before it starts waiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The token endpoint never answers 401 or invalid_client; jwt-bearer takes
/mcp as a resource; dynamic registration takes app schemes and always
registers public clients. Go: a refused assertion fails locally from then
on, sandbox_closed drops the slot, emails are normalised, a dead pending
claim starts a new registration, claim polls wait out Retry-After, and the
protocol structs never print their secrets. Client ids on IP literals or
localhost are refused before any fetch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Python and the CLI now match the TS and Go behaviour: sandbox_closed drops
the slot, emails are normalised, a finished service_auth registration
starts a new one (a rate limit or an approval in flight does not), and
connected() checks the saved connection. The CLI's conditional credential
writes are decided on the same read they write from, device login no
longer needs a working agent connection, a transient token failure takes
the stream's reconnect backoff, and sandbox wait honours Retry-After.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The theme toggle and the floating nav it sat in are gone since the one
anchored header landed; the consent page now uses SiteHeader like the
agent claim page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kroqdotdev
kroqdotdev force-pushed the feat/agent-onboarding branch from 769a738 to bd37c9d Compare October 28, 2026 22:54
@kroqdotdev
kroqdotdev merged commit ed9c317 into main Oct 28, 2026
26 checks passed
@kroqdotdev
kroqdotdev deleted the feat/agent-onboarding branch October 28, 2026 23:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant