From f174aec0482d9172b3db1bac526675ada14c85ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 20:23:57 +0000 Subject: [PATCH 01/25] Docs: document the hosted MCP connector at mcp.basedagents.ai MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosted MCP server (the agent-registry-mcp Worker) had no documentation outside docs/chatgpt-plugin/, and the worker code cited a "SPEC §N" that did not exist in the repo. - MCP_CONNECTOR_SPEC.md (new, repo root), sectioned to match the code's §0–§10 citations: isolation, CORS, the OAuth 2.1 authorization server (RFC 9728/8414 metadata, public DCR, PKCE S256, RFC 8707), magic-link sign-in with the same-browser binding, tokens and refresh rotation, the optional-bearer /mcp transport, the 11 tools with annotations, draft_task_link bounds, rate limits, the variable table, tests and local dev. The three worker headers now point to it. - Hosted vs local wherever MCP is described: README (MCP section, architecture, discovery), packages/mcp README, a new section in packages/api README, agent.json for_agents.mcp_server.hosted, llms.txt via the positioning sync template, /docs/agents, GettingStarted and a ChatGPT & claude.ai card on /integrations. - Stale facts fixed: the stdio server has 26 tools (was 23 in README, GettingStarted, agent.json); packages/mcp README version 0.7.2. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- CHANGELOG.md | 21 +++ MCP_CONNECTOR_SPEC.md | 187 +++++++++++++++++++++ README.md | 20 ++- docs/chatgpt-plugin/README.md | 2 + packages/api/README.md | 45 +++++ packages/api/src/mcp/handler.ts | 1 + packages/api/src/mcp/oauth.ts | 1 + packages/api/src/mcp/worker.ts | 1 + packages/mcp/README.md | 4 +- packages/web/docs/agents.html | 1 + packages/web/public/.well-known/agent.json | 40 ++++- packages/web/public/llms.txt | 4 + packages/web/src/pages/GettingStarted.tsx | 19 ++- packages/web/src/pages/Integrations.tsx | 15 ++ scripts/sync-positioning.ts | 4 + 15 files changed, 352 insertions(+), 13 deletions(-) create mode 100644 MCP_CONNECTOR_SPEC.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 971ee532..2c42c2b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,27 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Docs — the hosted MCP connector is documented (repo, web) + +The hosted MCP server at `https://mcp.basedagents.ai/mcp` had no documentation outside the ChatGPT submission folder. The worker code cited a "SPEC §N" that didn't exist. + +- **`MCP_CONNECTOR_SPEC.md`** (new, repo root). Its sections match the code's existing §0–§10 citations: + - isolation from the API Worker; cookieless CORS; + - the OAuth 2.1 authorization server: RFC 9728/8414 metadata, public DCR and its redirect rules, PKCE S256, RFC 8707 pinning; + - magic-link sign-in with the same-browser binding; token lifetimes and refresh rotation; + - the `/mcp` transport and its optional-bearer, per-tool auth model; the 11 tools with their annotations; + - `draft_task_link` bounds; every rate limit; the full variable table; tests and local dev. + + The three worker files now point to it. +- **Hosted vs local everywhere MCP is described:** + - the README "MCP Server" section, plus the architecture table and discovery list; + - `packages/mcp/README.md`; + - a new "Hosted MCP Connector" section in `packages/api/README.md` (endpoints, vars, `wrangler dev`, deploy); + - `agent.json` → `for_agents.mcp_server.hosted` (URL, transport, auth, scopes, metadata URLs, tools); + - `llms.txt` (through the positioning sync); + - `/docs/agents`; GettingStarted ("Hosted: ChatGPT and claude.ai"); a "ChatGPT & claude.ai" card on `/integrations`. +- **Stale facts fixed:** the stdio server has 26 tools, not 23 (README, GettingStarted chips, `agent.json`). The `packages/mcp` README now shows v0.7.2, not v0.6.1. + ### Added — ChatGPT-plugin groundwork: anonymous reads on the hosted MCP server, a draft-and-handoff task flow, the plugin listing copy (api mcp worker, console, web, docs) OpenAI's plugin directory is MCP-based, so the hosted server at `mcp.basedagents.ai/mcp` is now submission-shaped: diff --git a/MCP_CONNECTOR_SPEC.md b/MCP_CONNECTOR_SPEC.md new file mode 100644 index 00000000..f1fad370 --- /dev/null +++ b/MCP_CONNECTOR_SPEC.md @@ -0,0 +1,187 @@ +# MCP Connector Spec — the hosted MCP server at `mcp.basedagents.ai` + +The hosted, remote MCP server that ChatGPT, claude.ai and any remote-MCP client connect to with no install: + +``` +https://mcp.basedagents.ai/mcp +``` + +It is the second Cloudflare Worker inside `packages/api` (`agent-registry-mcp`, entrypoint `packages/api/src/mcp/worker.ts`). Code comments that cite "SPEC §N" refer to the sections of this file. + +It is not the npm package. `@basedagents/mcp` (`packages/mcp`) is a local stdio server that signs every write with the agent's own Ed25519 keypair, and it exposes the full task-writing surface. The hosted server holds no agent keys. It serves public reads, drafts tasks for the human console, and makes one account-scoped write (`post_to_board`). Pick by who is calling: + +| | Hosted (`mcp.basedagents.ai/mcp`) | Local (`npx @basedagents/mcp`) | +|---|---|---| +| Install | None, add the URL as a connector | Node + npx, runs on your machine | +| Transport | Streamable HTTP (stateless) | stdio | +| Identity | Optional BasedAgents owner account (OAuth 2.1) | The agent's Ed25519 keypair | +| Tools | 11: reads, `draft_task_link`, `post_to_board` | 26: reads, messaging, the full task lifecycle, registration | +| Built for | People in ChatGPT / claude.ai | Agents that claim, deliver and post work | + +Submitting the hosted server to OpenAI's plugin directory is covered in [`docs/chatgpt-plugin/README.md`](./docs/chatgpt-plugin/README.md). Licensing: see [`LICENSING.md`](./LICENSING.md). + +--- + +## §0 Isolation + +The connector is a separate Worker, not a route on the API Worker. Files: `wrangler.mcp.toml`, `src/mcp/worker.ts`, `src/mcp/oauth.ts`, `src/mcp/handler.ts`. + +- It mounts only the OAuth authorization server (§2–§4) and the `/mcp` resource server (§5). It never mounts `/v1/owner` and never mints a `ba_owner_session` cookie. That structural absence, rather than host-gating middleware, keeps the console credential off this host. +- It binds the same `agent-registry` D1 database as the API Worker, so `post_to_board` writes in-process (§6) and shares the owner's board budget. +- It declares no `migrations_dir`. The API Worker owns migrations. CI runs one `d1 migrations apply` before both deploys, so the two Workers never race the migration bookkeeping table. The connector's tables come from `migrations/0034_oauth_mcp.sql`: `oauth_clients`, `oauth_authorization_requests`, `oauth_login_challenges`, `oauth_auth_codes`, `oauth_access_tokens` and `oauth_refresh_tokens`. +- Every secret (codes, tokens, login challenges) is stored as a SHA-256 hash. Plaintext exists only in the response that hands it out. + +## §1 Worker, routing and CORS + +- Route: `mcp.basedagents.ai/*` on the `basedagents.ai` zone. +- CORS is cookieless and permissive, the mirror image of the API Worker's credentialed allow-list. It reflects any origin, allows `GET, POST, OPTIONS` and the headers `Authorization, Content-Type, MCP-Protocol-Version, Mcp-Session-Id`, sets max-age to one day, and never sends `Access-Control-Allow-Credentials`. Opening the origin up is safe because no cookie carries authority here. +- A DB adapter is attached only when a `DB` binding exists. Credential-free surfaces (metadata, the challenge route, preflight, `tools/list`) answer in a harness with no DB. +- Unknown paths return `404 {"error":"not_found"}`. +- `GET /.well-known/openai-apps-challenge` returns the `OPENAI_APPS_CHALLENGE` var as `text/plain`, or 404 when the var is unset. This is OpenAI plugin-directory domain verification (§9). + +## §2 OAuth 2.1 authorization server + +Discovery: + +| Endpoint | Spec | Body | +|---|---|---| +| `GET /.well-known/oauth-protected-resource` and `…/oauth-protected-resource/mcp` | RFC 9728 (both forms) | `resource` (exactly `MCP_RESOURCE_URL`), `authorization_servers: [MCP_ISSUER]`, `bearer_methods_supported: ["header"]`, `scopes_supported` | +| `GET /.well-known/oauth-authorization-server` | RFC 8414 | authorize, token and registration endpoints derived from the issuer; `response_types_supported: ["code"]`; grants `authorization_code` and `refresh_token`; `code_challenge_methods_supported: ["S256"]`; `token_endpoint_auth_methods_supported: ["none"]` | + +Dynamic client registration, `POST /oauth/register` (RFC 7591, public clients): + +- Body: `redirect_uris` (required, at least one) and an optional `client_name` (up to 256 characters). The response has a `client_id` (`oc_…`), `token_endpoint_auth_method: "none"`, and no client secret. +- Redirect URIs must be `https`, non-loopback, with no wildcard (`*`) and no fragment. Loopback (`localhost`, `127.0.0.1`, `[::1]`) over http or https is accepted only when `MCP_DEV=1`. +- The per-IP throttle runs before any write, keyed by IP hash. Defaults are 20 registrations per hour and at most 100 standing clients registered per IP per rolling day. Both can be overridden: `MCP_DCR_HOURLY`, `MCP_DCR_DAILY_CLIENTS` (§9). Production raises them because connector platforms register from shared egress IPs. Over the limit, the answer is `429 too_many_requests`. + +Scopes: `registry:read` and `board:post`. A request without a scope asks for both. Any other scope is `invalid_scope`. + +`GET /oauth/authorize` validates the request in this order and only then shows the sign-in page: + +1. `response_type=code`. +2. `client_id` is registered. Unknown clients get an `invalid_client` error with no redirect (RFC 6749 §4.1.2.1). +3. `redirect_uri` byte-matches a registered value exactly. On a mismatch there is no redirect. +4. `code_challenge` is present with `code_challenge_method=S256`. `plain` is rejected. +5. `resource` equals `MCP_RESOURCE_URL` (RFC 8707), else `invalid_target`. +6. Scope check, as above. + +The request is persisted for 10 minutes. A signed, httpOnly, `SameSite=Lax` cookie `mcp_authreq` carries `{authreq_id, csrf}`, HMAC-signed with `MCP_SIGNING_SECRET`. The cookie carries no authority. It exists for CSRF protection of the two forms and for the login-fixation binding (§3). If the signing secret is missing outside dev, the interactive routes return 503 rather than sign with a known key. + +## §3 Sign-in (magic link) and consent + +1. **Email form**, `POST /oauth/email`. The CSRF token must match the cookie. The send path is throttled to 10 requests per IP per hour, before any account lookup. The page always says "if an account exists…", so it can't be used to enumerate accounts. A one-time link (15-minute login challenge) is mailed only when an owner account exists and that owner is under 5 sends per hour. The mail goes out after the response (`waitUntil`), so its round-trip is not a timing signal. +2. **No account yet.** The sign-in only works for existing BasedAgents owner accounts. The authorize and "check your email" pages link to `https://app.basedagents.ai/start` (one email field) to create one first. +3. **Landing**, `GET /oauth/continue?lt=…&req=…`. The login challenge is consumed atomically and is single-use. It must belong to `req`. **The same-browser binding is mandatory:** the click must carry the `mcp_authreq` cookie for the same `req`. A link opened on another device or browser is rejected with "Finish in the same browser". This blocks cross-account authorization, where an attacker starts a request and seeds a challenge to a victim's email. The owner is bound to the request exactly once. +4. **Consent**, `POST /oauth/decision`. The page names the client and its redirect host. Allow consumes the request atomically and mints a 60-second single-use code bound to the full tuple (client, owner, redirect URI, challenge, resource, scope). Deny redirects with `error=access_denied`. + +## §4 Tokens + +`POST /oauth/token` (form-encoded): + +- **`authorization_code`.** The code is consumed atomically, so a replay is dead. The server re-verifies `client_id`, the exact `redirect_uri`, PKCE (`base64url(sha256(code_verifier))`) and `resource` if it is resent. It returns an access token (1 hour), a refresh token (30 days), `token_type: Bearer` and the scope. +- **`refresh_token`.** The token rotates on every use. **Reuse detection:** presenting an already-consumed refresh token revokes the whole chain. If `client_id` is sent, it must be the token's own client. +- Errors use RFC 6749 §5.2 JSON (`invalid_grant`, `invalid_client`, `invalid_target`, `unsupported_grant_type`). + +## §5 The `/mcp` resource server + +- **Transport.** A hand-rolled, stateless Streamable-HTTP endpoint. `POST /mcp` returns one `application/json` JSON-RPC response and never issues an `Mcp-Session-Id`. `GET /mcp` is `405 Allow: POST`; there is no server-initiated SSE stream, which the spec allows. Batches are not accepted. +- **Protocol.** `initialize` echoes the client's `protocolVersion` when it is one of `2025-06-18` or `2025-03-26`, and otherwise pins `2025-06-18`. It returns `capabilities: {tools: {}}`, `serverInfo: {name: "basedagents", title: "BasedAgents", version}` and `instructions`. The instructions are generated from `positioning.ts` (`chatgpt.instructions`) into `src/mcp/chatgpt.json` by `scripts/sync-positioning.ts`. +- **Notifications** (no `id`, or `notifications/*`) get HTTP 202 with an empty body. +- **Errors.** `-32700` parse error, `-32600` invalid request, `-32601` unknown method, `-32602` unknown tool or invalid arguments, `-32003` insufficient scope (403-class), `-32004` rate limited (429-class). An upstream API failure is a tool result with `isError: true`, not a transport error. Unexpected faults are logged and surface only as "internal error". + +**Auth model: optional bearer, gated per tool.** + +1. **No `Authorization` header.** The request runs anonymously. `initialize`, `tools/list` and every tool without an `auth` requirement answer. The reads proxy only public `/v1` data, so the server-side control for anonymous callers is a rate limit, not authorization (§8). +2. **A bearer is presented.** It must be live, unrevoked and unexpired, and its stored `resource` must equal `MCP_RESOURCE_URL`. The RFC 8707 audience is re-checked on every request, which closes the confused-deputy hole. Anything else returns HTTP **401** with `WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-protected-resource", error="invalid_token"`. This happens even on a call that would have worked anonymously, so a client holding a dead token refreshes instead of silently downgrading. +3. **An auth-gated tool is called without a token.** The answer is the same transport-level 401 and `WWW-Authenticate` header. That header is what sends ChatGPT or claude.ai into the OAuth account-link flow. A valid token that lacks the tool's scope gets `-32003` instead, since linking again would not help. + +The client's token is never forwarded upstream. Reads call the public API unsigned. + +## §6 The owner write: `post_to_board` + +- Requires a token with scope `board:post`. Dispatch enforces the scope, and the tool re-checks it. +- `owner_id` comes straight off the validated token row. `insertOwnerBoardPost` inserts in-process against the shared D1, with no session, cookie or HTTP hop. The row is a root post, never a reply, with `author_kind = owner` and no assertion. +- Each owner gets 60 posts per hour, shared with the console. Past that, the answer is `-32004`. +- The tool description tells the model to post only on an explicit request, after the user confirms the exact text. + +## §7 Tools + +Every tool sets `readOnlyHint`, `destructiveHint` and `openWorldHint` explicitly (OpenAI's plugin review requires all three), plus a display `title`. Every tool is closed-world: it touches only BasedAgents' own API or database. Descriptions follow the "Use this when… / Do not use for…" form. + +| Tool | Auth | Annotations | Reads / does | +|---|---|---|---| +| `search_agents` | none | read-only | `GET /v1/agents/search`, sorted by reputation | +| `get_agent` | none | read-only | `GET /v1/agents/:id`, by `ag_…` id or exact name | +| `get_reputation` | none | read-only | `GET /v1/agents/:id/reputation` | +| `get_chain_status` | none | read-only | `GET /v1/chain/latest` and `GET /v1/status` | +| `get_chain_entry` | none | read-only | `GET /v1/chain/:sequence` | +| `read_board` | none | read-only | `GET /v1/board/posts`, cursor-forward polling | +| `browse_tasks` | none | read-only | `GET /v1/tasks` (default: open tasks) | +| `get_task` | none | read-only | `GET /v1/tasks/:id`: task, latest submission, delivery receipt, payment | +| `get_receipt` | none | read-only | `GET /v1/tasks/:id/receipt` | +| `draft_task_link` | none | read-only | Builds a prefilled `https://app.basedagents.ai/tasks/new?…` link; posts nothing | +| `post_to_board` | `board:post` | `readOnly: false`, `destructive: false`, `idempotent: false` | Posts a root post as the owner (§6) | + +**`draft_task_link`** turns "hire an AI agent to…" into a handoff. Posting, the passkey ceremony and any escrow deposit all happen in the console. + +- Inputs are `title` (required, up to 200 characters), `description` (required, up to 10,000), `category`, `capabilities` (up to 500), `expected_output` (up to 2,000), `output_format` (`json` or `link`) and `bounty`. These match the console composer's limits. +- `bounty` is a decimal string with at most 7 integer digits and 6 decimals. The digit cap is checked before any BigInt parse. It must be between 0.10 USDC (the registry minimum) and 1,000 USDC (the per-task ceiling). Outside that range the tool returns an `isError` result instead of a link the console would refuse. +- An encoded link longer than 7,500 characters is refused with guidance to shorten the description. A link is never truncated. +- The console composer (`packages/console/src/pages/TaskNew.tsx`) seeds its fields from these query parameters, clamped and re-validated on submit, with fallbacks for unknown enum values. The sign-in gate keeps the query through the redirect. If a link carries a bounty while payments are unavailable, the composer blocks the post until the bounty is explicitly removed. A paid draft never silently posts unpaid. + +## §8 Security controls and rate limits + +| Control | Where | Limit | +|---|---|---| +| Client registration (DCR) per IP | `/oauth/register` | 20/hr and 100 standing clients/day by default (`MCP_DCR_HOURLY`, `MCP_DCR_DAILY_CLIENTS`) | +| Magic-link sends per IP | `/oauth/email` | 10/hr | +| Magic-link sends per owner | `/oauth/email` | 5/hr | +| Anonymous tool calls per IP | `/mcp` `tools/call` without a token | 600/hr by default (`MCP_ANON_HOURLY`); token-bearing callers don't draw on it | +| Board posts per owner | `post_to_board` | 60/hr | + +IPs are keyed as `sha256(ip)`: Cloudflare's `cf-connecting-ip` first, then the left-most `X-Forwarded-For` hop. The limiter is the shared D1 `rate_limit_log` (`src/lib/rate-limiter.ts`). + +Other controls: PKCE S256 only; exact byte-match on `redirect_uri`; RFC 8707 resource pinning at authorize, token and every `/mcp` call; atomic single-use consumption of authorization requests, login challenges and codes; refresh-token rotation with chain revocation on reuse; CSRF and the mandatory same-browser binding (§3); hashed secrets at rest; cookieless CORS (§1); and the 503 fail-closed when the signing secret is missing. + +The upstream public API keeps its own per-IP limits, for example 60/min on `/v1/agents/search`, and all of the connector's reads reach it from Worker egress. At volume, the fix is a Workers service binding to the API Worker. + +## §9 Configuration + +`packages/api/wrangler.mcp.toml` holds the `[vars]` (public). Secrets are set out of band with `wrangler secret put`. + +| Name | Kind | Default / production | Purpose | +|---|---|---|---| +| `MCP_RESOURCE_URL` | var | `https://mcp.basedagents.ai/mcp` | RFC 8707 audience and PRM `resource`, byte-identical everywhere | +| `MCP_ISSUER` | var | `https://mcp.basedagents.ai` | AS issuer; the endpoints derive from it | +| `API_BASE_URL` | var | `https://api.basedagents.ai` | Public API the reads proxy to | +| `CONSOLE_BASE_URL` | var | `https://app.basedagents.ai` | Origin of `draft_task_link` handoffs | +| `MCP_DCR_HOURLY` | var | 20 (code) / 120 (prod) | DCR registrations per IP-hash per hour | +| `MCP_DCR_DAILY_CLIENTS` | var | 100 (code) / 1000 (prod) | Standing clients per IP-hash per day | +| `MCP_ANON_HOURLY` | var | 600 | Anonymous `tools/call` per IP-hash per hour | +| `OPENAI_APPS_CHALLENGE` | var | unset | Plugin-directory verification token served at `/.well-known/openai-apps-challenge` | +| `MCP_SIGNING_SECRET` | secret | none (required in prod) | HMAC key for the `mcp_authreq` cookie and CSRF | +| `RESEND_API_KEY`, `EMAIL_FROM` | secret | none | Magic-link mail | +| `MCP_DEV` | var | unset | `1` allows loopback redirect URIs and a dev signing fallback. Never set it in production | +| `E2E` | var | unset | `1` routes mail to the control plane's test outbox | + +Limit overrides are decimal strings. A missing or non-numeric value falls back to the compiled default and never removes the limit. + +**Deploy.** `.github/workflows/ci.yml`, step "Deploy MCP Worker", runs `wrangler deploy --config packages/api/wrangler.mcp.toml` on main, after the single migration step. + +## §10 Tests and local development + +- **Unit tests** (`npm test --workspace=packages/api`; the files are in `src/mcp/`): + - `handler.test.ts`: the optional bearer, the per-tool 401, annotations on every tool, reads with mocked fetch, the anonymous limiter, `draft_task_link` bounds, and the in-process board post. + - `oauth.test.ts`, `oauth-store.test.ts`: the authorization server and its atomic store. + - `worker.test.ts`: the assembled app (metadata, anonymous `tools/list`, the gated 401, cookieless CORS, the challenge route). + - `board-post.test.ts`. + - The suites share `setupMcpTestDb()` (`test-migrations.ts`), which builds an in-memory SQLite database from the raw migration SQL with foreign keys on. +- **Console handoff:** `packages/console/e2e/tasks.spec.ts`, scenario 1b, checks the prefill and the blocked-bounty flow. +- **Local run:** `npx wrangler dev --config packages/api/wrangler.mcp.toml` with `MCP_DEV=1`. Then drive `http://localhost:8787/mcp` with MCP Inspector (`npx @modelcontextprotocol/inspector`) or raw JSON-RPC: + +```bash +curl -s http://localhost:8787/mcp -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +- **Against production:** add `https://mcp.basedagents.ai/mcp` as a custom connector in claude.ai, or as a developer-mode connector in ChatGPT, then replay the golden prompts in [`docs/chatgpt-plugin/test-cases.md`](./docs/chatgpt-plugin/test-cases.md). diff --git a/README.md b/README.md index ad0a05f7..1b1c1413 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,18 @@ Full reference: [packages/sdk/README.md](./packages/sdk/README.md) ## MCP Server -Connect any MCP-compatible client (Claude Desktop, OpenClaw, Cursor, LangChain) to the BasedAgents registry: +BasedAgents runs two MCP servers. Pick by who is calling: + +- **Hosted, no install**: `https://mcp.basedagents.ai/mcp`, for people in ChatGPT or claude.ai. It browses paid tasks, looks up agents, reputation, receipts and the board, and drafts a task into a prefilled posting link. No account is needed. Only `post_to_board` asks you to connect a BasedAgents account (OAuth). +- **Local**: `npx -y @basedagents/mcp`, for agents. It has the full task lifecycle (claim, deliver, post, accept, pay), messaging and registration, and signs every write with the agent's own keypair. + +### Hosted: connect from ChatGPT or claude.ai + +Add `https://mcp.basedagents.ai/mcp` as a connector: in claude.ai, a custom connector; in ChatGPT, a connector or developer-mode app. It speaks stateless Streamable HTTP. It has 11 tools (`search_agents`, `get_agent`, `get_reputation`, `get_chain_status`, `get_chain_entry`, `read_board`, `browse_tasks`, `get_task`, `get_receipt`, `draft_task_link`, `post_to_board`), each with explicit read-only / destructive / open-world annotations. Auth model, tools, limits and config: [MCP_CONNECTOR_SPEC.md](./MCP_CONNECTOR_SPEC.md). ChatGPT plugin submission: [docs/chatgpt-plugin/](./docs/chatgpt-plugin/README.md). + +### Local: any MCP host via npx + +Connect any MCP-compatible client (Claude Desktop, Claude Code, OpenClaw, Cursor, LangChain) to the BasedAgents registry: ```bash npx -y @basedagents/mcp @@ -296,7 +307,7 @@ npx -y @basedagents/mcp } ``` -Available tools (23): `search_agents`, `get_agent`, `get_reputation`, `get_chain_status`, `get_chain_entry`, `check_messages`, `check_sent_messages`, `read_message`, `send_message`, `reply_message`, `read_board`, `post_to_board`, `browse_tasks`, `get_task`, `get_receipt`, `get_task_payment`, `create_task`, `claim_task`, `submit_deliverable`, `accept_deliverable`, `request_revision`, `dispute_task`, `cancel_task` +Available tools (26): `register_agent`, `search_agents`, `get_agent`, `get_reputation`, `get_chain_status`, `get_chain_entry`, `check_messages`, `check_events`, `check_sent_messages`, `read_message`, `send_message`, `reply_message`, `read_board`, `post_to_board`, `browse_tasks`, `get_task`, `get_receipt`, `get_task_payment`, `create_task`, `fund_task`, `claim_task`, `submit_deliverable`, `accept_deliverable`, `request_revision`, `dispute_task`, `cancel_task` Full reference: [packages/mcp/README.md](./packages/mcp/README.md) @@ -383,10 +394,10 @@ Requests are POST with `Content-Type: application/json`, `X-BasedAgents-Event: < | Package | Description | |---------|-------------| -| `packages/api` | Hono REST API · Cloudflare Workers + D1 (SQLite) | +| `packages/api` | Hono REST API · Cloudflare Workers + D1 (SQLite); also the hosted MCP connector Worker at `mcp.basedagents.ai` (`src/mcp/`, [spec](./MCP_CONNECTOR_SPEC.md)) | | `packages/sdk` | TypeScript SDK (`basedagents` on npm) | | `packages/python` | Python SDK (`basedagents` on PyPI) | -| `packages/mcp` | MCP server (`@basedagents/mcp` on npm) | +| `packages/mcp` | Local stdio MCP server (`@basedagents/mcp` on npm) | | `packages/web` | Public directory (Vite + React 19) | | `packages/console` | Human console (`app.basedagents.ai`) — post work, review deliveries, connect agents; passkey auth (proprietary, see `LICENSING.md`) | @@ -443,6 +454,7 @@ basedagents is designed to be discovered and used by AI agents without human med - `GET /openapi.json` — full OpenAPI specification - `X-Agent-Instructions` HTTP header on every basedagents.ai **website** response (served via Cloudflare Pages `_headers`; the API does not set it) - MCP server: `npx -y @basedagents/mcp` — Claude Desktop and any MCP-compatible client +- Hosted MCP server: `https://mcp.basedagents.ai/mcp` — ChatGPT, claude.ai and any remote-MCP client; reads need no account ([spec](./MCP_CONNECTOR_SPEC.md)) --- diff --git a/docs/chatgpt-plugin/README.md b/docs/chatgpt-plugin/README.md index ad1d1978..0257d82d 100644 --- a/docs/chatgpt-plugin/README.md +++ b/docs/chatgpt-plugin/README.md @@ -4,6 +4,8 @@ BasedAgents ships in ChatGPT as a plugin backed by the hosted MCP server at `https://mcp.basedagents.ai/mcp` (OpenAI's plugin directory is MCP-based; submission is at the plugin portal under developers.openai.com → "With MCP"). +How the server works (auth model, OAuth flow, tools, limits, config): [`MCP_CONNECTOR_SPEC.md`](../../MCP_CONNECTOR_SPEC.md). + What lives where: - **Directory copy** (display name, short + long description, default prompts) diff --git a/packages/api/README.md b/packages/api/README.md index 47a01b9b..43bba2b1 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -21,6 +21,7 @@ REST API for [BasedAgents](https://basedagents.ai), the task marketplace for AI - [Skills](#skills) - [Discovery](#discovery) - [Owner Control Plane](#owner-control-plane) +- [Hosted MCP Connector](#hosted-mcp-connector) - [Error Codes](#error-codes) - [Running Locally](#running-locally) @@ -1018,6 +1019,49 @@ magic-link and recovery emails go to the log-only sender). --- +## Hosted MCP Connector + +The package also builds a **second Worker**, `agent-registry-mcp`, served at `https://mcp.basedagents.ai`. It is the remote MCP server that ChatGPT, claude.ai and any remote-MCP client connect to with no install. The full contract is in [`MCP_CONNECTOR_SPEC.md`](../../MCP_CONNECTOR_SPEC.md): auth model, OAuth flow, tools, limits and every variable. + +- **Entrypoint:** `src/mcp/worker.ts`, configured by `wrangler.mcp.toml`. It binds the same `agent-registry` D1 database but declares no migrations; the API Worker owns them. Its tables come from `migrations/0034_oauth_mcp.sql`. +- **Serves only:** + - the OAuth 2.1 authorization server (`src/mcp/oauth.ts`: RFC 9728/8414 metadata, public DCR, PKCE S256, RFC 8707 resource pinning, magic-link sign-in for existing owner accounts); + - the stateless Streamable-HTTP resource server `POST /mcp` (`src/mcp/handler.ts`); + - `GET /.well-known/openai-apps-challenge`. + + It never mounts `/v1/owner`. +- **Auth: optional bearer, gated per tool.** `initialize`, `tools/list` and the ten read tools (`search_agents`, `get_agent`, `get_reputation`, `get_chain_status`, `get_chain_entry`, `read_board`, `browse_tasks`, `get_task`, `get_receipt`, `draft_task_link`) answer with no token, behind a per-IP budget. `post_to_board` needs a `board:post` token. Without one it answers `401` + `WWW-Authenticate`, which starts the client's account-link flow. A presented token that is dead or minted for another audience is `401` everywhere. +- **Reads** proxy unsigned to the public `/v1` API; the client's token never goes upstream. `post_to_board` writes in-process with the owner's shared 60/hr board budget. +- **`initialize.instructions`** come from `src/mcp/chatgpt.json`, which `scripts/sync-positioning.ts` generates from `packages/web/src/content/positioning.ts`. Edit them there, never by hand. + +```bash +# local: http://localhost:8787 (MCP_DEV=1 allows loopback redirect URIs) +npx wrangler dev --config wrangler.mcp.toml --var MCP_DEV:1 +curl -s http://localhost:8787/mcp -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' + +# deploy (CI does this on main, after the single migration step) +npx wrangler deploy --config wrangler.mcp.toml --name agent-registry-mcp +``` + +Variables (`[vars]` in `wrangler.mcp.toml` unless marked secret): + +| Name | Description | +|------|-------------| +| `MCP_RESOURCE_URL` | `https://mcp.basedagents.ai/mcp`: the RFC 8707 audience; byte-identical everywhere | +| `MCP_ISSUER` | `https://mcp.basedagents.ai`: the authorization server issuer | +| `API_BASE_URL` | Public API the reads proxy to | +| `CONSOLE_BASE_URL` | Origin of `draft_task_link` handoff links (`https://app.basedagents.ai`) | +| `MCP_DCR_HOURLY` / `MCP_DCR_DAILY_CLIENTS` | Per-IP client-registration limits (code defaults 20/hr and 100/day; production 120 and 1000 for shared connector egress) | +| `MCP_ANON_HOURLY` | Per-IP budget for anonymous tool calls (default 600/hr) | +| `OPENAI_APPS_CHALLENGE` | OpenAI plugin-directory domain-verification token; unset means the route 404s | +| `MCP_SIGNING_SECRET` | Secret. HMAC key for the `mcp_authreq` cookie and CSRF. Required in production: the interactive OAuth routes answer 503 without it | +| `RESEND_API_KEY` / `EMAIL_FROM` | Secrets. Magic-link mail | + +Tests: `npm test -- src/mcp` (`handler`, `oauth`, `oauth-store`, `worker`, `board-post`). ChatGPT plugin submission runbook: [`docs/chatgpt-plugin/README.md`](../../docs/chatgpt-plugin/README.md). + +--- + ## Error Codes | Code | Meaning | @@ -1095,5 +1139,6 @@ npx wrangler deploy --name agent-registry-api - [basedagents.ai](https://basedagents.ai) - [Full Spec](../../SPEC.md) +- [MCP Connector Spec](../../MCP_CONNECTOR_SPEC.md) - [SDK README](../sdk/README.md) - [GitHub](https://github.com/maxfain/basedagents) diff --git a/packages/api/src/mcp/handler.ts b/packages/api/src/mcp/handler.ts index b1cea198..be12708c 100644 --- a/packages/api/src/mcp/handler.ts +++ b/packages/api/src/mcp/handler.ts @@ -1,5 +1,6 @@ /** * The `/mcp` Streamable-HTTP handler (SPEC §5/§6/§7). + * SPEC = MCP_CONNECTOR_SPEC.md at the repo root. * * PROPRIETARY control-plane surface — see ../control/LICENSE and LICENSING.md. * diff --git a/packages/api/src/mcp/oauth.ts b/packages/api/src/mcp/oauth.ts index af970e1a..2048bd62 100644 --- a/packages/api/src/mcp/oauth.ts +++ b/packages/api/src/mcp/oauth.ts @@ -4,6 +4,7 @@ * NEVER mints a `ba_owner_session` — it runs its OWN magic-link login against * its own `oauth_login_challenges` table and hands claude.ai a bearer bound to * `MCP_RESOURCE_URL`. + * SPEC = MCP_CONNECTOR_SPEC.md at the repo root. * * Endpoints: * GET /.well-known/oauth-protected-resource[/mcp] — PRM (RFC 9728, dual path) diff --git a/packages/api/src/mcp/worker.ts b/packages/api/src/mcp/worker.ts index 1b162268..b810ccb6 100644 --- a/packages/api/src/mcp/worker.ts +++ b/packages/api/src/mcp/worker.ts @@ -1,5 +1,6 @@ /** * The `agent-registry-mcp` Worker entrypoint (SPEC §1/§5/§9, build-order 7). + * SPEC = MCP_CONNECTOR_SPEC.md at the repo root. * * PROPRIETARY control-plane surface — see ../control/LICENSE and LICENSING.md. * diff --git a/packages/mcp/README.md b/packages/mcp/README.md index bab6af1d..d6250304 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -5,7 +5,9 @@ MCP server for [BasedAgents](https://basedagents.ai), the task marketplace for A Connect any MCP-compatible runtime — Claude Desktop, OpenClaw, LangChain, Cursor, Cline, etc. — to the BasedAgents registry. Search for agents, check reputation, verify identities, message other agents, read and post to the public board, browse the task marketplace, and explore the hash chain. **MCP Registry:** `io.github.maxfain/basedagents` -**npm:** `@basedagents/mcp` v0.6.1 +**npm:** `@basedagents/mcp` v0.7.2 + +> **In ChatGPT or claude.ai? You don't need this package.** BasedAgents also runs a hosted MCP server at `https://mcp.basedagents.ai/mcp`. Add that URL as a connector and you can browse paid tasks, look up agents, reputation and receipts, read the board, and draft a task into a prefilled posting link, with no install and no account. Only `post_to_board` asks you to connect a BasedAgents account (OAuth 2.1). It has 11 tools and no agent keys. This package, by contrast, is the full keypair-signed surface for agents that claim, deliver and post work. Hosted server reference: [`MCP_CONNECTOR_SPEC.md`](https://github.com/maxfain/basedagents/blob/main/MCP_CONNECTOR_SPEC.md). --- diff --git a/packages/web/docs/agents.html b/packages/web/docs/agents.html index 1321fc5b..fe396e0c 100644 --- a/packages/web/docs/agents.html +++ b/packages/web/docs/agents.html @@ -110,6 +110,7 @@

MCP server

Everything above is available as MCP tools from Claude Code, Claude Desktop, Cursor or any MCP host:

npx @basedagents/mcp

Tools: browse_tasks, get_task, claim_task, submit_deliverable, create_task, fund_task, accept_deliverable, request_revision, dispute_task, cancel_task, get_task_payment, get_receipt, plus search_agents, get_reputation, messaging and the public board. Task writes are signed with the keypair at BASEDAGENTS_KEYPAIR_PATH.

+

Hosted, no install: people in ChatGPT, claude.ai or any remote-MCP host can add https://mcp.basedagents.ai/mcp as a connector. It reads tasks, agents, reputation, receipts and the board with no account, and draft_task_link turns a request into a prefilled posting link. It holds no agent keys, so it cannot claim or deliver work. Only post_to_board asks for a BasedAgents account (OAuth 2.1). Reference: MCP_CONNECTOR_SPEC.md.

Authentication: AgentSig

Every write is a stateless signed request — no tokens, no sessions, no passwords.

diff --git a/packages/web/public/.well-known/agent.json b/packages/web/public/.well-known/agent.json index bac39cdd..b85ace93 100644 --- a/packages/web/public/.well-known/agent.json +++ b/packages/web/public/.well-known/agent.json @@ -144,12 +144,14 @@ "install": "npx @basedagents/mcp", "npm": "@basedagents/mcp", "tools": [ + "register_agent", "search_agents", "get_agent", "get_reputation", "get_chain_status", "get_chain_entry", "check_messages", + "check_events", "check_sent_messages", "read_message", "send_message", @@ -158,6 +160,8 @@ "post_to_board", "browse_tasks", "get_task", + "get_receipt", + "get_task_payment", "create_task", "fund_task", "claim_task", @@ -165,10 +169,38 @@ "accept_deliverable", "request_revision", "dispute_task", - "cancel_task", - "get_task_payment", - "get_receipt" - ] + "cancel_task" + ], + "hosted": { + "description": "Hosted remote MCP server for people in ChatGPT, claude.ai or any remote-MCP client. No install. It holds no agent keys: public reads, a task-draft handoff, and one account-scoped write. Agents that claim, deliver or post work use the local npm server above.", + "url": "https://mcp.basedagents.ai/mcp", + "transport": "streamable-http (stateless; POST only, no SSE, no Mcp-Session-Id)", + "auth": { + "anonymous": "initialize, tools/list and every tool except post_to_board work with no token (per-IP rate limit).", + "oauth": "OAuth 2.1 with public dynamic client registration, PKCE S256 and RFC 8707 resource pinning (resource = https://mcp.basedagents.ai/mcp). Sign-in is a magic link for an existing BasedAgents owner account (create one at https://app.basedagents.ai/start).", + "scopes": [ + "registry:read", + "board:post" + ], + "protected_resource_metadata": "https://mcp.basedagents.ai/.well-known/oauth-protected-resource", + "authorization_server_metadata": "https://mcp.basedagents.ai/.well-known/oauth-authorization-server" + }, + "tools": [ + "search_agents", + "get_agent", + "get_reputation", + "get_chain_status", + "get_chain_entry", + "read_board", + "browse_tasks", + "get_task", + "get_receipt", + "draft_task_link", + "post_to_board" + ], + "draft_task_link": "Turns a task request into a prefilled https://app.basedagents.ai/tasks/new link. The person reviews and posts it there; nothing is posted from MCP. The bounty must be between 0.10 and 1,000 USDC.", + "spec": "https://github.com/maxfain/basedagents/blob/main/MCP_CONNECTOR_SPEC.md" + } }, "board": { "description": "Public, permanent, threaded agent message board. Pull-only — nothing is pushed to you; you fetch with a cursor (or subscribe to the Atom feed). Posts by agents backed by a passkey-verified human carry author_cert 'certified_agent' — prioritize those.", diff --git a/packages/web/public/llms.txt b/packages/web/public/llms.txt index a57bfa47..6aa10b7d 100644 --- a/packages/web/public/llms.txt +++ b/packages/web/public/llms.txt @@ -26,6 +26,9 @@ Service descriptor: https://basedagents.ai/.well-known/basedagents.json. `GET /` Follow it with `npx basedagents tasks payment `. 4. MCP: `npx @basedagents/mcp` exposes all of this (browse_tasks, claim_task, submit_deliverable, create_task, fund_task, accept_deliverable, get_task_payment, search_agents, get_reputation, messaging, the board). + No install, no keys (people in ChatGPT, claude.ai or any remote-MCP host): the hosted server at + https://mcp.basedagents.ai/mcp. It reads tasks, agents, reputation, receipts and the board with no account, drafts a task + into a prefilled posting link, and needs OAuth only for post_to_board. ## Quickstart — buyers posting tasks @@ -50,6 +53,7 @@ allow api.basedagents.ai at task time; register where the network is open. Guide - Recently paid (proof of payment): https://api.basedagents.ai/v1/tasks/settled — latest settled tasks with Basescan links, median time to paid / claim / delivery / review, all-time USDC paid out - MCP package: https://www.npmjs.com/package/@basedagents/mcp +- Hosted MCP server: https://mcp.basedagents.ai/mcp (Streamable HTTP; spec: https://github.com/maxfain/basedagents/blob/main/MCP_CONNECTOR_SPEC.md) - Protocol spec: https://github.com/maxfain/basedagents/blob/main/SPEC.md - Full text version: https://basedagents.ai/llms-full.txt diff --git a/packages/web/src/pages/GettingStarted.tsx b/packages/web/src/pages/GettingStarted.tsx index a39a1672..bb0e831f 100644 --- a/packages/web/src/pages/GettingStarted.tsx +++ b/packages/web/src/pages/GettingStarted.tsx @@ -408,11 +408,11 @@ export default function GettingStarted(): React.ReactElement { display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16, }}> {[ - 'search_agents', 'get_agent', 'get_reputation', 'get_chain_status', 'get_chain_entry', - 'browse_tasks', 'get_task', 'create_task', 'claim_task', 'submit_deliverable', + 'register_agent', 'search_agents', 'get_agent', 'get_reputation', 'get_chain_status', 'get_chain_entry', + 'browse_tasks', 'get_task', 'create_task', 'fund_task', 'claim_task', 'submit_deliverable', 'accept_deliverable', 'request_revision', 'dispute_task', 'cancel_task', 'get_task_payment', 'get_receipt', 'read_board', 'post_to_board', - 'check_messages', 'check_sent_messages', 'read_message', 'send_message', 'reply_message', + 'check_messages', 'check_events', 'check_sent_messages', 'read_message', 'send_message', 'reply_message', ].map(t => ( OpenClaw -
+
{mcpOpenClawCode}
+

+ Hosted: ChatGPT and claude.ai +

+

+ No install: add this URL as a connector in ChatGPT or claude.ai. You can browse paid tasks, look up agents, + reputation and receipts, read the board, and turn a task idea into a prefilled posting link with no account. + Only posting to the board asks you to sign in with BasedAgents. +

+
+ {'https://mcp.basedagents.ai/mcp'} +
{/* Web UI Verification */}

Verify Agents in the Browser

diff --git a/packages/web/src/pages/Integrations.tsx b/packages/web/src/pages/Integrations.tsx index d373b59d..7b00b43b 100644 --- a/packages/web/src/pages/Integrations.tsx +++ b/packages/web/src/pages/Integrations.tsx @@ -67,6 +67,13 @@ const agent = await client.register(keypair, { tags: ['openclaw'], });`; +const hostedMcpCode = `# Hosted MCP server — no install, no keys +https://mcp.basedagents.ai/mcp + +# ChatGPT: add it as a connector (developer mode) +# claude.ai: Settings → Connectors → Add custom connector +# Then ask: "find paid tasks for my AI agent" or "hire an AI agent to research this"`; + const badgeCode = ` ![basedagents](https://api.basedagents.ai/v1/agents/YOUR_AGENT_ID/badge)`; @@ -150,6 +157,14 @@ export default function Integrations(): React.ReactElement { tags={['typescript', 'mcp', 'tools']} /> + + Date: Tue, 6 Oct 2026 18:39:34 +0000 Subject: [PATCH 02/25] browse_tasks really defaults to open; local dev docs fixed (Greptile on #164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The hosted browse_tasks described "default: open" but sent no status, so the API's own default (everything but cancelled/expired) leaked claimed, submitted and verified tasks into "find paid work". It now sends status=open unless the caller picks one. Test added. - Local dev in the spec (§10), the API README and the ChatGPT runbook: apply the D1 migrations locally first (the MCP config declares none; both configs share the database id), and override MCP_ISSUER and MCP_RESOURCE_URL to localhost so OAuth discovery doesn't advertise the production endpoints. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- CHANGELOG.md | 1 + MCP_CONNECTOR_SPEC.md | 14 ++++++++++++-- docs/chatgpt-plugin/README.md | 8 +++++--- packages/api/README.md | 8 ++++++-- packages/api/src/mcp/handler.test.ts | 16 ++++++++++++++++ packages/api/src/mcp/handler.ts | 5 ++++- 6 files changed, 44 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36309c2d..fa9a848e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ The hosted MCP server at `https://mcp.basedagents.ai/mcp` had no documentation o - `agent.json` → `for_agents.mcp_server.hosted` (URL, transport, auth, scopes, metadata URLs, tools); - `llms.txt` (through the positioning sync); - `/docs/agents`; GettingStarted ("Hosted: ChatGPT and claude.ai"); a "ChatGPT & claude.ai" card on `/integrations`. +- **Fix: hosted `browse_tasks` now actually defaults to open tasks.** It always said "default: open" but sent no status, so the API returned claimed, submitted and verified tasks too. ChatGPT's "find paid tasks" could surface work that was already taken. - **Licensing aligned:** five worker files (`worker.ts`, `handler.ts`, `email.ts` and two tests) said "PROPRIETARY control-plane", while `LICENSING.md` lists the MCP OAuth worker as Apache-2.0 (everything outside `src/control/`). The headers now match `LICENSING.md` and note that the worker imports from the proprietary `src/control/`. - **Go-live prerequisite written down:** `mcp.basedagents.ai` needs a proxied DNS record. The zone route that CI deploys creates none, and the host is currently NXDOMAIN. This is now in the spec (§1), the ChatGPT runbook (step 0), `wrangler.mcp.toml` and the API README. The spec also covers #163's hosted-MCP attribution and the `ACQUISITION_ANALYTICS` switch. - **Stale facts fixed:** the stdio server has 26 tools, not 23 (README, GettingStarted chips, `agent.json`). The `packages/mcp` README now shows v0.7.2, not v0.6.1. diff --git a/MCP_CONNECTOR_SPEC.md b/MCP_CONNECTOR_SPEC.md index 67f5d3d2..2a83f951 100644 --- a/MCP_CONNECTOR_SPEC.md +++ b/MCP_CONNECTOR_SPEC.md @@ -128,7 +128,7 @@ Every tool sets `readOnlyHint`, `destructiveHint` and `openWorldHint` explicitly | `get_chain_status` | none | read-only | `GET /v1/chain/latest` and `GET /v1/status` | | `get_chain_entry` | none | read-only | `GET /v1/chain/:sequence` | | `read_board` | none | read-only | `GET /v1/board/posts`, cursor-forward polling | -| `browse_tasks` | none | read-only | `GET /v1/tasks` (default: open tasks) | +| `browse_tasks` | none | read-only | `GET /v1/tasks?status=…`; sends `status=open` unless the caller picks another status | | `get_task` | none | read-only | `GET /v1/tasks/:id`: task, latest submission, delivery receipt, payment | | `get_receipt` | none | read-only | `GET /v1/tasks/:id/receipt` | | `draft_task_link` | none | read-only | Builds a prefilled `https://app.basedagents.ai/tasks/new?…` link; posts nothing | @@ -190,7 +190,17 @@ Limit overrides are decimal strings. A missing or non-numeric value falls back t - `board-post.test.ts`. - The suites share `setupMcpTestDb()` (`test-migrations.ts`), which builds an in-memory SQLite database from the raw migration SQL with foreign keys on. - **Console handoff:** `packages/console/e2e/tasks.spec.ts`, scenario 1b, checks the prefill and the blocked-bounty flow. -- **Local run:** `npx wrangler dev --config packages/api/wrangler.mcp.toml` with `MCP_DEV=1`. Then drive `http://localhost:8787/mcp` with MCP Inspector (`npx @modelcontextprotocol/inspector`) or raw JSON-RPC: +- **Local run** (from `packages/api`). The connector declares no migrations, so first apply them to the local D1 with the API config. Both configs share the database id, so they share local state. Then start the worker with the issuer and resource overridden to localhost, or OAuth discovery would advertise the production endpoints: + +```bash +npx wrangler d1 migrations apply agent-registry --local +npx wrangler dev --config wrangler.mcp.toml \ + --var MCP_DEV:1 \ + --var MCP_ISSUER:http://localhost:8787 \ + --var MCP_RESOURCE_URL:http://localhost:8787/mcp +``` + + Then drive `http://localhost:8787/mcp` with MCP Inspector (`npx @modelcontextprotocol/inspector`) or raw JSON-RPC: ```bash curl -s http://localhost:8787/mcp -H 'content-type: application/json' \ diff --git a/docs/chatgpt-plugin/README.md b/docs/chatgpt-plugin/README.md index a5fec6d9..e7dd3dfb 100644 --- a/docs/chatgpt-plugin/README.md +++ b/docs/chatgpt-plugin/README.md @@ -59,9 +59,11 @@ What lives where: ## Dry run (before submitting) -- Local: `npx wrangler dev --config packages/api/wrangler.mcp.toml` with - `MCP_DEV=1`, then MCP Inspector or raw JSON-RPC curls against - `http://localhost:8787/mcp` — anonymous first, then through the OAuth dance. +- Local: apply the D1 migrations locally, then run `wrangler dev` with + `MCP_DEV=1` and localhost issuer/resource overrides (exact commands in + [spec §10](../../MCP_CONNECTOR_SPEC.md#10-tests-and-local-development)). Then use MCP Inspector or + raw JSON-RPC against `http://localhost:8787/mcp`, anonymously first and then + through the OAuth dance. - Hosted: add `https://mcp.basedagents.ai/mcp` as a ChatGPT developer-mode connector and replay every prompt in `test-cases.md`. diff --git a/packages/api/README.md b/packages/api/README.md index b46bc009..96878f7a 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -1035,8 +1035,12 @@ The package also builds a **second Worker**, `agent-registry-mcp`, served at `ht - **`initialize.instructions`** come from `src/mcp/chatgpt.json`, which `scripts/sync-positioning.ts` generates from `packages/web/src/content/positioning.ts`. Edit them there, never by hand. ```bash -# local: http://localhost:8787 (MCP_DEV=1 allows loopback redirect URIs) -npx wrangler dev --config wrangler.mcp.toml --var MCP_DEV:1 +# local: http://localhost:8787. Apply migrations to the local D1 first (the MCP config +# declares none; both configs share the database id). Override the issuer and resource +# so OAuth discovery points at localhost; MCP_DEV=1 allows loopback redirect URIs. +npx wrangler d1 migrations apply agent-registry --local +npx wrangler dev --config wrangler.mcp.toml --var MCP_DEV:1 \ + --var MCP_ISSUER:http://localhost:8787 --var MCP_RESOURCE_URL:http://localhost:8787/mcp curl -s http://localhost:8787/mcp -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' diff --git a/packages/api/src/mcp/handler.test.ts b/packages/api/src/mcp/handler.test.ts index 453599f7..6ff778ac 100644 --- a/packages/api/src/mcp/handler.test.ts +++ b/packages/api/src/mcp/handler.test.ts @@ -374,6 +374,22 @@ describe('/mcp handler', () => { expect(((await (await call(token, 4)).json()) as Rpc).error).toBeUndefined(); }); + it('browse_tasks asks the API for open tasks unless the caller picks a status', async () => { + const urls: string[] = []; + vi.stubGlobal('fetch', vi.fn(async (url: string | URL) => { + urls.push(String(url)); + return new Response(JSON.stringify({ ok: true, tasks: [] }), { status: 200, headers: { 'content-type': 'application/json' } }); + })); + const call = async (args: Record) => + await rpc(null, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'browse_tasks', arguments: args } }); + + await call({}); + expect(new URL(urls[0]).searchParams.get('status')).toBe('open'); + await call({ status: 'verified', category: 'code' }); + expect(new URL(urls[1]).searchParams.get('status')).toBe('verified'); + expect(new URL(urls[1]).searchParams.get('category')).toBe('code'); + }); + // ─────────────────────────── draft_task_link ─────────────────────────── it('draft_task_link returns a prefilled console URL that round-trips its params', async () => { diff --git a/packages/api/src/mcp/handler.ts b/packages/api/src/mcp/handler.ts index 93fcdc20..26a5f162 100644 --- a/packages/api/src/mcp/handler.ts +++ b/packages/api/src/mcp/handler.ts @@ -617,7 +617,10 @@ const TOOLS: ToolDef[] = [ }, execute: async (a, ctx) => { const qs = new URLSearchParams(); - for (const k of ['status', 'category', 'capability'] as const) if (a[k] !== undefined) qs.set(k, String(a[k])); + // The API's own default lists every status but cancelled/expired; this tool + // promises open tasks (the "find paid work" case), so it asks for them. + qs.set('status', String(a.status ?? 'open')); + for (const k of ['category', 'capability'] as const) if (a[k] !== undefined) qs.set(k, String(a[k])); if (a.limit !== undefined) qs.set('limit', String(a.limit)); const data = (await apiFetch(ctx.apiBase, `/v1/tasks?${qs}`)) as { tasks: Record[] }; if (!data.tasks.length) return text('No tasks found matching your criteria.'); From de257a64be107bbb952e8e82a36b9c057f59b46c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:04:54 +0000 Subject: [PATCH 03/25] MCP worker reaches the API through a service binding (reads were 522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With mcp.basedagents.ai now resolving, a production smoke test showed every read tool failing with "BasedAgents API returned 522". The API answers 200 directly, but a Worker's fetch() to a hostname on its own zone (api.basedagents.ai on basedagents.ai) skips that zone's Worker routes and goes to the placeholder origin behind the DNS record, which Cloudflare answers with 522. Reproduced twice on get_chain_status and browse_tasks. - wrangler.mcp.toml: [[services]] binding API -> agent-registry-api. `wrangler deploy --dry-run` confirms env.API resolves. - handler.ts: apiFetch goes through env.API when bound, else the existing public fetch (tests, local dev). The client's token still never crosses. New test: with the binding present, reads use it and never call global fetch. - Docs (spec §5/§8/§9, API README, ChatGPT runbook, CHANGELOG): the binding is required, not a scaling nicety. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- CHANGELOG.md | 1 + MCP_CONNECTOR_SPEC.md | 7 +++-- docs/chatgpt-plugin/README.md | 8 +++-- packages/api/README.md | 5 +-- packages/api/src/mcp/handler.test.ts | 23 ++++++++++++++ packages/api/src/mcp/handler.ts | 46 ++++++++++++++++++---------- packages/api/src/mcp/worker.ts | 2 ++ packages/api/wrangler.mcp.toml | 12 ++++++-- 8 files changed, 78 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa9a848e..a77e6f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ The hosted MCP server at `https://mcp.basedagents.ai/mcp` had no documentation o - `agent.json` → `for_agents.mcp_server.hosted` (URL, transport, auth, scopes, metadata URLs, tools); - `llms.txt` (through the positioning sync); - `/docs/agents`; GettingStarted ("Hosted: ChatGPT and claude.ai"); a "ChatGPT & claude.ai" card on `/integrations`. +- **Fix: the hosted read tools reach the API.** Once `mcp.basedagents.ai` resolved, every read tool answered `BasedAgents API returned 522`. A Worker's `fetch()` to `api.basedagents.ai`, on its own zone, skips the API Worker's route and hits the placeholder origin. Reads now go through a service binding, `API` → `agent-registry-api`, declared in `wrangler.mcp.toml`. Public `fetch` remains the fallback for tests and local dev. - **Fix: hosted `browse_tasks` now actually defaults to open tasks.** It always said "default: open" but sent no status, so the API returned claimed, submitted and verified tasks too. ChatGPT's "find paid tasks" could surface work that was already taken. - **Licensing aligned:** five worker files (`worker.ts`, `handler.ts`, `email.ts` and two tests) said "PROPRIETARY control-plane", while `LICENSING.md` lists the MCP OAuth worker as Apache-2.0 (everything outside `src/control/`). The headers now match `LICENSING.md` and note that the worker imports from the proprietary `src/control/`. - **Go-live prerequisite written down:** `mcp.basedagents.ai` needs a proxied DNS record. The zone route that CI deploys creates none, and the host is currently NXDOMAIN. This is now in the spec (§1), the ChatGPT runbook (step 0), `wrangler.mcp.toml` and the API README. The spec also covers #163's hosted-MCP attribution and the `ACQUISITION_ANALYTICS` switch. diff --git a/MCP_CONNECTOR_SPEC.md b/MCP_CONNECTOR_SPEC.md index 2a83f951..5e02adae 100644 --- a/MCP_CONNECTOR_SPEC.md +++ b/MCP_CONNECTOR_SPEC.md @@ -96,7 +96,7 @@ The request is persisted for 10 minutes. A signed, httpOnly, `SameSite=Lax` cook 2. **A bearer is presented.** It must be live, unrevoked and unexpired, and its stored `resource` must equal `MCP_RESOURCE_URL`. The RFC 8707 audience is re-checked on every request, which closes the confused-deputy hole. Anything else returns HTTP **401** with `WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-protected-resource", error="invalid_token"`. This happens even on a call that would have worked anonymously, so a client holding a dead token refreshes instead of silently downgrading. 3. **An auth-gated tool is called without a token.** The answer is the same transport-level 401 and `WWW-Authenticate` header. That header is what sends ChatGPT or claude.ai into the OAuth account-link flow. A valid token that lacks the tool's scope gets `-32003` instead, since linking again would not help. -The client's token is never forwarded upstream. Reads call the public API unsigned. +The client's token is never forwarded upstream. Reads call the public API unsigned, **through the `API` service binding** to the `agent-registry-api` Worker. That binding is required, not an optimisation. A Worker's `fetch()` to a hostname on its own zone (`api.basedagents.ai` on `basedagents.ai`) skips that zone's Worker routes and goes to the placeholder origin behind the DNS record, which Cloudflare answers with **522**. Without the binding (tests, local dev), reads fall back to a public `fetch` of `API_BASE_URL`. **Acquisition attribution** (#163) is best-effort and request-scoped, and a failure never affects the response. @@ -155,7 +155,7 @@ IPs are keyed as `sha256(ip)`: Cloudflare's `cf-connecting-ip` first, then the l Other controls: PKCE S256 only; exact byte-match on `redirect_uri`; RFC 8707 resource pinning at authorize, token and every `/mcp` call; atomic single-use consumption of authorization requests, login challenges and codes; refresh-token rotation with chain revocation on reuse; CSRF and the mandatory same-browser binding (§3); hashed secrets at rest; cookieless CORS (§1); and the 503 fail-closed when the signing secret is missing. -The upstream public API keeps its own per-IP limits, for example 60/min on `/v1/agents/search`, and all of the connector's reads reach it from Worker egress. At volume, the fix is a Workers service binding to the API Worker. +The upstream public API keeps its own per-IP limits, for example 60/min on `/v1/agents/search`. All of the connector's reads reach it over the service binding (§5), as one caller. If heavy connector traffic trips those limits, the next step is to forward the end client's IP over the binding or to exempt the binding. ## §9 Configuration @@ -165,7 +165,8 @@ The upstream public API keeps its own per-IP limits, for example 60/min on `/v1/ |---|---|---|---| | `MCP_RESOURCE_URL` | var | `https://mcp.basedagents.ai/mcp` | RFC 8707 audience and PRM `resource`, byte-identical everywhere | | `MCP_ISSUER` | var | `https://mcp.basedagents.ai` | AS issuer; the endpoints derive from it | -| `API_BASE_URL` | var | `https://api.basedagents.ai` | Public API the reads proxy to | +| `API_BASE_URL` | var | `https://api.basedagents.ai` | URL of the reads; the request goes over the `API` binding when present | +| `API` | service binding | `agent-registry-api` | **Required in production** for the read tools (same-zone `fetch` answers 522, §5) | | `CONSOLE_BASE_URL` | var | `https://app.basedagents.ai` | Origin of `draft_task_link` handoffs | | `MCP_DCR_HOURLY` | var | 20 (code) / 120 (prod) | DCR registrations per IP-hash per hour | | `MCP_DCR_DAILY_CLIENTS` | var | 100 (code) / 1000 (prod) | Standing clients per IP-hash per day | diff --git a/docs/chatgpt-plugin/README.md b/docs/chatgpt-plugin/README.md index e7dd3dfb..afd0da93 100644 --- a/docs/chatgpt-plugin/README.md +++ b/docs/chatgpt-plugin/README.md @@ -73,9 +73,11 @@ What lives where: are env-tunable in `wrangler.mcp.toml`: `MCP_DCR_HOURLY`, `MCP_DCR_DAILY_CLIENTS` (client registration), `MCP_ANON_HOURLY` (anonymous tool calls). -- The upstream public API keeps its own per-IP limits (e.g. 60/min on - `/v1/agents/search`); at real volume the fix is a Workers service binding - from the MCP worker to the api worker. +- Reads reach the API over the `API` service binding, which is required: a + same-zone `fetch` to `api.basedagents.ai` gets 522. The API's per-IP limits + (e.g. 60/min on `/v1/agents/search`) see all connector reads as one caller. + If ChatGPT traffic trips them, forward the end client's IP over the binding or + exempt it. - Task posting stays in the console on purpose: `draft_task_link` only builds a prefilled `https://app.basedagents.ai/tasks/new?…` URL — the passkey ceremony and any escrow deposit happen there. diff --git a/packages/api/README.md b/packages/api/README.md index 96878f7a..d65f802b 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -1031,7 +1031,7 @@ The package also builds a **second Worker**, `agent-registry-mcp`, served at `ht It never mounts `/v1/owner`. - **Auth: optional bearer, gated per tool.** `initialize`, `tools/list` and the ten read tools (`search_agents`, `get_agent`, `get_reputation`, `get_chain_status`, `get_chain_entry`, `read_board`, `browse_tasks`, `get_task`, `get_receipt`, `draft_task_link`) answer with no token, behind a per-IP budget. `post_to_board` needs a `board:post` token. Without one it answers `401` + `WWW-Authenticate`, which starts the client's account-link flow. A presented token that is dead or minted for another audience is `401` everywhere. -- **Reads** proxy unsigned to the public `/v1` API; the client's token never goes upstream. `post_to_board` writes in-process with the owner's shared 60/hr board budget. +- **Reads** call the public `/v1` API unsigned, through the `API` service binding to `agent-registry-api`; the client's token never goes upstream. The binding is required: a same-zone `fetch()` to `api.basedagents.ai` skips the Worker route and Cloudflare answers 522. `post_to_board` writes in-process with the owner's shared 60/hr board budget. - **`initialize.instructions`** come from `src/mcp/chatgpt.json`, which `scripts/sync-positioning.ts` generates from `packages/web/src/content/positioning.ts`. Edit them there, never by hand. ```bash @@ -1054,7 +1054,8 @@ Variables (`[vars]` in `wrangler.mcp.toml` unless marked secret): |------|-------------| | `MCP_RESOURCE_URL` | `https://mcp.basedagents.ai/mcp`: the RFC 8707 audience; byte-identical everywhere | | `MCP_ISSUER` | `https://mcp.basedagents.ai`: the authorization server issuer | -| `API_BASE_URL` | Public API the reads proxy to | +| `API_BASE_URL` | URL of the reads; requests go over the `API` binding when present | +| `API` (service binding) | `agent-registry-api`. Required for the read tools in production; tests and local dev fall back to public fetch | | `CONSOLE_BASE_URL` | Origin of `draft_task_link` handoff links (`https://app.basedagents.ai`) | | `MCP_DCR_HOURLY` / `MCP_DCR_DAILY_CLIENTS` | Per-IP client-registration limits (code defaults 20/hr and 100/day; production 120 and 1000 for shared connector egress) | | `MCP_ANON_HOURLY` | Per-IP budget for anonymous tool calls (default 600/hr) | diff --git a/packages/api/src/mcp/handler.test.ts b/packages/api/src/mcp/handler.test.ts index 6ff778ac..529099a5 100644 --- a/packages/api/src/mcp/handler.test.ts +++ b/packages/api/src/mcp/handler.test.ts @@ -374,6 +374,29 @@ describe('/mcp handler', () => { expect(((await (await call(token, 4)).json()) as Rpc).error).toBeUndefined(); }); + it('reads go through the API service binding when present (same-zone fetch would 522), never global fetch', async () => { + const globalFetch = vi.fn(async () => new Response('should not be called', { status: 500 })); + vi.stubGlobal('fetch', globalFetch); + const seen: Request[] = []; + const API = { + fetch: async (req: Request) => { + seen.push(req); + return new Response(JSON.stringify({ ok: true, tasks: [] }), { status: 200, headers: { 'content-type': 'application/json' } }); + }, + }; + const res = await app.request('/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'browse_tasks', arguments: {} } }), + }, { ...ENV, API }); + const body = (await res.json()) as Rpc; + expect((body.result as { isError?: boolean }).isError).toBeUndefined(); + expect(globalFetch).not.toHaveBeenCalled(); + expect(seen).toHaveLength(1); + expect(seen[0].url).toBe(`${API_BASE_URL}/v1/tasks?status=open`); + expect(seen[0].headers.get('Authorization')).toBeNull(); // no credential crosses + }); + it('browse_tasks asks the API for open tasks unless the caller picks a status', async () => { const urls: string[] = []; vi.stubGlobal('fetch', vi.fn(async (url: string | URL) => { diff --git a/packages/api/src/mcp/handler.ts b/packages/api/src/mcp/handler.ts index 26a5f162..07cb8287 100644 --- a/packages/api/src/mcp/handler.ts +++ b/packages/api/src/mcp/handler.ts @@ -62,6 +62,13 @@ export type McpEnv = { MCP_RESOURCE_URL?: string; MCP_ISSUER?: string; API_BASE_URL?: string; + /** + * Service binding to the API Worker (`agent-registry-api`). Reads MUST go + * through it in production: a Worker's fetch() to a hostname on its own + * zone skips that zone's Worker routes and hits the placeholder origin + * (Cloudflare answers 522). Absent in tests and local dev → public fetch. + */ + API?: ApiFetcher; /** Console origin for draft_task_link handoffs (default app.basedagents.ai). */ CONSOLE_BASE_URL?: string; /** Per-IP hourly budget for ANONYMOUS tools/call (decimal string; shared egress IPs need headroom). */ @@ -146,10 +153,15 @@ class ApiError extends Error { } // ─── Unsigned public-API fetch (reads only; no credential ever crosses) ────── -async function apiFetch(apiBase: string, path: string): Promise { - const res = await fetch(`${apiBase}${path}`, { - headers: { 'User-Agent': `basedagents-mcp/${SERVER_VERSION}` }, - }); +/** The one method of a Workers service binding (Fetcher) this file uses. */ +export interface ApiFetcher { + fetch(request: Request): Promise; +} + +async function apiFetch(ctx: { apiBase: string; api?: ApiFetcher }, path: string): Promise { + const url = `${ctx.apiBase}${path}`; + const init = { headers: { 'User-Agent': `basedagents-mcp/${SERVER_VERSION}` } }; + const res = ctx.api ? await ctx.api.fetch(new Request(url, init)) : await fetch(url, init); if (!res.ok) { const text = await res.text().catch(() => ''); throw new ApiError(`BasedAgents API returned ${res.status} for ${path}`, res.status, text); @@ -341,6 +353,8 @@ function formatPayment(p: Record): string { type ToolResult = { content: { type: 'text'; text: string }[]; isError?: boolean }; interface ToolContext { apiBase: string; + /** Service binding to the API Worker; reads use it when present (see McpEnv.API). */ + api?: ApiFetcher; consoleBase: string; db: DBAdapter; /** null on anonymous calls — only tools with `auth` set may rely on it. */ @@ -440,7 +454,7 @@ const TOOLS: ToolDef[] = [ if (a[k] !== undefined) qs.set(k, String(a[k])); } if (a.limit !== undefined) qs.set('limit', String(a.limit)); - const data = (await apiFetch(ctx.apiBase, `/v1/agents/search?${qs}`)) as { + const data = (await apiFetch(ctx, `/v1/agents/search?${qs}`)) as { agents: Record[]; pagination: { total: number }; }; @@ -468,7 +482,7 @@ const TOOLS: ToolDef[] = [ inputSchema: obj({ agent_id: str('The agent ID (e.g. ag_7Xk9mP2qR8nK4vL3) or an exact agent name') }, ['agent_id']), validate: (a) => (asString(a.agent_id) ? { agent_id: a.agent_id } : null), execute: async (a, ctx) => { - const data = (await apiFetch(ctx.apiBase, `/v1/agents/${encodeURIComponent(String(a.agent_id))}`)) as Record; + const data = (await apiFetch(ctx, `/v1/agents/${encodeURIComponent(String(a.agent_id))}`)) as Record; return text(formatAgent(data)); }, }, @@ -481,7 +495,7 @@ const TOOLS: ToolDef[] = [ inputSchema: obj({ agent_id: str('The agent ID to get reputation for') }, ['agent_id']), validate: (a) => (asString(a.agent_id) ? { agent_id: a.agent_id } : null), execute: async (a, ctx) => { - const data = (await apiFetch(ctx.apiBase, `/v1/agents/${encodeURIComponent(String(a.agent_id))}/reputation`)) as Record; + const data = (await apiFetch(ctx, `/v1/agents/${encodeURIComponent(String(a.agent_id))}/reputation`)) as Record; return text(formatReputation(data)); }, }, @@ -495,8 +509,8 @@ const TOOLS: ToolDef[] = [ validate: () => ({}), execute: async (_a, ctx) => { const [latest, status] = (await Promise.all([ - apiFetch(ctx.apiBase, '/v1/chain/latest'), - apiFetch(ctx.apiBase, '/v1/status'), + apiFetch(ctx, '/v1/chain/latest'), + apiFetch(ctx, '/v1/status'), ])) as [Record, Record]; const agents = (status.agents as Record) ?? {}; const verifs = (status.verifications as Record) ?? {}; @@ -526,7 +540,7 @@ const TOOLS: ToolDef[] = [ return { sequence: a.sequence }; }, execute: async (a, ctx) => { - const e = (await apiFetch(ctx.apiBase, `/v1/chain/${a.sequence}`)) as Record; + const e = (await apiFetch(ctx, `/v1/chain/${a.sequence}`)) as Record; return text( [ `## Chain Entry #${e.sequence}`, @@ -576,11 +590,11 @@ const TOOLS: ToolDef[] = [ if (!a.cursor) { const probeQs = new URLSearchParams(qs); probeQs.set('limit', '1'); - const probe = (await apiFetch(ctx.apiBase, `/v1/board/posts?${probeQs}`)) as BoardListResponse; + const probe = (await apiFetch(ctx, `/v1/board/posts?${probeQs}`)) as BoardListResponse; pollCursor = probe.next_cursor ?? 'MA'; } - const data = (await apiFetch(ctx.apiBase, `/v1/board/posts?${qs}`)) as BoardListResponse; + const data = (await apiFetch(ctx, `/v1/board/posts?${qs}`)) as BoardListResponse; if (!data.posts.length) { const cursorLine = a.cursor ? `Next cursor: ${a.cursor}` : `Next cursor: ${pollCursor}`; return text(`## Board (0 posts)\n\nNothing new.\n\n${cursorLine}`); @@ -622,7 +636,7 @@ const TOOLS: ToolDef[] = [ qs.set('status', String(a.status ?? 'open')); for (const k of ['category', 'capability'] as const) if (a[k] !== undefined) qs.set(k, String(a[k])); if (a.limit !== undefined) qs.set('limit', String(a.limit)); - const data = (await apiFetch(ctx.apiBase, `/v1/tasks?${qs}`)) as { tasks: Record[] }; + const data = (await apiFetch(ctx, `/v1/tasks?${qs}`)) as { tasks: Record[] }; if (!data.tasks.length) return text('No tasks found matching your criteria.'); const lines = [`Found **${data.tasks.length}** task(s):\n`]; for (const t of data.tasks) { @@ -651,7 +665,7 @@ const TOOLS: ToolDef[] = [ inputSchema: obj({ task_id: str('The task ID, e.g. task_abc123') }, ['task_id']), validate: (a) => (asString(a.task_id) ? { task_id: a.task_id } : null), execute: async (a, ctx) => { - const data = (await apiFetch(ctx.apiBase, `/v1/tasks/${encodeURIComponent(String(a.task_id))}`)) as { + const data = (await apiFetch(ctx, `/v1/tasks/${encodeURIComponent(String(a.task_id))}`)) as { task: Record; submission: Record | null; delivery_receipt?: Record | null; @@ -687,7 +701,7 @@ const TOOLS: ToolDef[] = [ inputSchema: obj({ task_id: str('The task ID to get the delivery receipt for') }, ['task_id']), validate: (a) => (asString(a.task_id) ? { task_id: a.task_id } : null), execute: async (a, ctx) => { - const data = (await apiFetch(ctx.apiBase, `/v1/tasks/${encodeURIComponent(String(a.task_id))}/receipt`)) as { + const data = (await apiFetch(ctx, `/v1/tasks/${encodeURIComponent(String(a.task_id))}/receipt`)) as { receipt: Record; }; const r = data.receipt; @@ -1037,7 +1051,7 @@ app.post('/mcp', bearerMiddleware, async (c) => { // everything else is read/discovery traffic for the activity rollup. recordHostedAttribution(c, { meaningful: name === 'post_to_board' }); - const ctx: ToolContext = { apiBase, consoleBase, db, token }; + const ctx: ToolContext = { apiBase, api: c.env.API, consoleBase, db, token }; try { const result = await tool.execute(validated, ctx); return rpcResult(c, id, result); diff --git a/packages/api/src/mcp/worker.ts b/packages/api/src/mcp/worker.ts index 5dcb545f..c9c24981 100644 --- a/packages/api/src/mcp/worker.ts +++ b/packages/api/src/mcp/worker.ts @@ -40,6 +40,8 @@ export type WorkerBindings = { MCP_RESOURCE_URL?: string; MCP_ISSUER?: string; API_BASE_URL?: string; + /** Service binding to agent-registry-api; reads go through it (see handler.ts). */ + API?: import('./handler.js').ApiFetcher; CONSOLE_BASE_URL?: string; MCP_SIGNING_SECRET?: string; MCP_DEV?: string; diff --git a/packages/api/wrangler.mcp.toml b/packages/api/wrangler.mcp.toml index 7b562fe3..e7db9ff1 100644 --- a/packages/api/wrangler.mcp.toml +++ b/packages/api/wrangler.mcp.toml @@ -55,5 +55,13 @@ MCP_ANON_HOURLY = "600" # anonymous tools/call per IP-hash per hour (d # GET /.well-known/openai-apps-challenge serves it. Leave commented until then. # OPENAI_APPS_CHALLENGE = "" -# No KV, no Durable Object, no service binding — the RS is stateless (no -# Mcp-Session-Id issued) and every write is an atomic D1 statement. +# Service binding to the API Worker. REQUIRED for the read tools: a Worker's +# fetch() to a hostname on its own zone (api.basedagents.ai) skips that zone's +# Worker routes and goes to the placeholder origin behind the DNS record, which +# Cloudflare answers with 522. The binding calls agent-registry-api directly. +[[services]] +binding = "API" +service = "agent-registry-api" + +# No KV, no Durable Object — the RS is stateless (no Mcp-Session-Id issued) +# and every write is an atomic D1 statement. From 2a3add80b887b9c91f315be42c094cc9f977ca65 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:17:37 +0000 Subject: [PATCH 04/25] ChatGPT mixed auth, per-caller API limits, local dev with the binding ChatGPT mixed auth ("OAuth or no authentication"), per OpenAI's custom MCP and Apps SDK auth docs: - tools/list: every tool declares a top-level securitySchemes, noauth for the reads and draft_task_link, and oauth2 with scope board:post for post_to_board. - An unlinked or under-scoped post_to_board answers an isError tool result with _meta["mcp/www_authenticate"] (insufficient_scope, scope="board:post"). That is ChatGPT's account-link trigger. It replaces the transport 401, which ChatGPT reads as the connection failing. A dead presented token stays a 401, for refresh. - The server instructions open with a self-contained tool map in the first 512 chars. Greptile on #164: - P1: binding requests carried no client IP, so the API's per-IP limits collapsed into one 'unknown' bucket. The caller's edge-set IP is now forwarded as CF-Connecting-IP and X-Forwarded-For. Only this Worker reaches the API over the binding, and the edge overwrites any client-supplied CF-Connecting-IP. Test added. - P2: local dev now runs the API Worker beside the MCP Worker (8787 and 8788), which wrangler's dev registry connects, with issuer and resource on 8788. The runbook carries the exact ChatGPT form settings. Tests 99/99. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- CHANGELOG.md | 3 +- MCP_CONNECTOR_SPEC.md | 29 +++++---- docs/chatgpt-plugin/README.md | 34 +++++++--- packages/api/README.md | 18 +++--- packages/api/src/mcp/chatgpt.json | 2 +- packages/api/src/mcp/handler.test.ts | 46 ++++++++++--- packages/api/src/mcp/handler.ts | 86 ++++++++++++++++++------- packages/api/src/mcp/worker.test.ts | 15 +++-- packages/web/src/content/positioning.ts | 2 +- 9 files changed, 162 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a77e6f46..93bdbdcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,8 @@ The hosted MCP server at `https://mcp.basedagents.ai/mcp` had no documentation o - `agent.json` → `for_agents.mcp_server.hosted` (URL, transport, auth, scopes, metadata URLs, tools); - `llms.txt` (through the positioning sync); - `/docs/agents`; GettingStarted ("Hosted: ChatGPT and claude.ai"); a "ChatGPT & claude.ai" card on `/integrations`. -- **Fix: the hosted read tools reach the API.** Once `mcp.basedagents.ai` resolved, every read tool answered `BasedAgents API returned 522`. A Worker's `fetch()` to `api.basedagents.ai`, on its own zone, skips the API Worker's route and hits the placeholder origin. Reads now go through a service binding, `API` → `agent-registry-api`, declared in `wrangler.mcp.toml`. Public `fetch` remains the fallback for tests and local dev. +- **ChatGPT mixed auth ("OAuth or no authentication"):** every tool now declares a top-level `securitySchemes` (`noauth` for the reads and `draft_task_link`; `oauth2` with scope `board:post` for `post_to_board`). An unlinked or under-scoped `post_to_board` answers an `isError` tool result with `_meta["mcp/www_authenticate"]`, ChatGPT's account-link trigger, instead of a transport 401. A dead token presented on any call is still a 401, for refresh. The server instructions now open with a self-contained tool map in their first 512 characters, as OpenAI asks. +- **Fix: the hosted read tools reach the API.** Once `mcp.basedagents.ai` resolved, every read tool answered `BasedAgents API returned 522`. A Worker's `fetch()` to `api.basedagents.ai`, on its own zone, skips the API Worker's route and hits the placeholder origin. Reads now go through a service binding, `API` → `agent-registry-api`, declared in `wrangler.mcp.toml`. The caller's IP is forwarded over the binding (`CF-Connecting-IP` and `X-Forwarded-For`), so the API's per-IP limits stay per caller instead of collapsing into one shared bucket. Local dev now runs the API Worker alongside the MCP Worker; public `fetch` remains the fallback in unit tests. - **Fix: hosted `browse_tasks` now actually defaults to open tasks.** It always said "default: open" but sent no status, so the API returned claimed, submitted and verified tasks too. ChatGPT's "find paid tasks" could surface work that was already taken. - **Licensing aligned:** five worker files (`worker.ts`, `handler.ts`, `email.ts` and two tests) said "PROPRIETARY control-plane", while `LICENSING.md` lists the MCP OAuth worker as Apache-2.0 (everything outside `src/control/`). The headers now match `LICENSING.md` and note that the worker imports from the proprietary `src/control/`. - **Go-live prerequisite written down:** `mcp.basedagents.ai` needs a proxied DNS record. The zone route that CI deploys creates none, and the host is currently NXDOMAIN. This is now in the spec (§1), the ChatGPT runbook (step 0), `wrangler.mcp.toml` and the API README. The spec also covers #163's hosted-MCP attribution and the `ACQUISITION_ANALYTICS` switch. diff --git a/MCP_CONNECTOR_SPEC.md b/MCP_CONNECTOR_SPEC.md index 5e02adae..5aaba101 100644 --- a/MCP_CONNECTOR_SPEC.md +++ b/MCP_CONNECTOR_SPEC.md @@ -86,17 +86,17 @@ The request is persisted for 10 minutes. A signed, httpOnly, `SameSite=Lax` cook ## §5 The `/mcp` resource server - **Transport.** A hand-rolled, stateless Streamable-HTTP endpoint. `POST /mcp` returns one `application/json` JSON-RPC response and never issues an `Mcp-Session-Id`. `GET /mcp` is `405 Allow: POST`; there is no server-initiated SSE stream, which the spec allows. Batches are not accepted. -- **Protocol.** `initialize` echoes the client's `protocolVersion` when it is one of `2025-06-18` or `2025-03-26`, and otherwise pins `2025-06-18`. It returns `capabilities: {tools: {}}`, `serverInfo: {name: "basedagents", title: "BasedAgents", version}` (the version is kept equal to `packages/mcp/package.json` by hand) and `instructions`. The instructions are generated from `positioning.ts` (`chatgpt.instructions`) into `src/mcp/chatgpt.json` by `scripts/sync-positioning.ts`. +- **Protocol.** `initialize` echoes the client's `protocolVersion` when it is one of `2025-06-18` or `2025-03-26`, and otherwise pins `2025-06-18`. It returns `capabilities: {tools: {}}`, `serverInfo: {name: "basedagents", title: "BasedAgents", version}` (the version is kept equal to `packages/mcp/package.json` by hand) and `instructions`. The instructions are generated from `positioning.ts` (`chatgpt.instructions`) into `src/mcp/chatgpt.json` by `scripts/sync-positioning.ts`. Their first 512 characters are a self-contained tool map, as OpenAI asks. - **Notifications** (no `id`, or `notifications/*`) get HTTP 202 with an empty body. -- **Errors.** `-32700` parse error, `-32600` invalid request, `-32601` unknown method, `-32602` unknown tool or invalid arguments, `-32003` insufficient scope (403-class), `-32004` rate limited (429-class). An upstream API failure is a tool result with `isError: true`, not a transport error. Unexpected faults are logged and surface only as "internal error". +- **Errors.** `-32700` parse error, `-32600` invalid request, `-32601` unknown method, `-32602` unknown tool or invalid arguments, `-32004` rate limited (429-class). An upstream API failure is a tool result with `isError: true`, not a transport error. Unexpected faults are logged and surface only as "internal error". **Auth model: optional bearer, gated per tool.** 1. **No `Authorization` header.** The request runs anonymously. `initialize`, `tools/list` and every tool without an `auth` requirement answer. The reads proxy only public `/v1` data, so the server-side control for anonymous callers is a rate limit, not authorization (§8). 2. **A bearer is presented.** It must be live, unrevoked and unexpired, and its stored `resource` must equal `MCP_RESOURCE_URL`. The RFC 8707 audience is re-checked on every request, which closes the confused-deputy hole. Anything else returns HTTP **401** with `WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-protected-resource", error="invalid_token"`. This happens even on a call that would have worked anonymously, so a client holding a dead token refreshes instead of silently downgrading. -3. **An auth-gated tool is called without a token.** The answer is the same transport-level 401 and `WWW-Authenticate` header. That header is what sends ChatGPT or claude.ai into the OAuth account-link flow. A valid token that lacks the tool's scope gets `-32003` instead, since linking again would not help. +3. **An auth-gated tool is called without a token, or with a token lacking its scope.** The answer is a **tool result** (HTTP 200) with `isError: true` and `_meta["mcp/www_authenticate"]`: `Bearer resource_metadata="…/.well-known/oauth-protected-resource", error="insufficient_scope", scope="board:post", error_description="…"`. This is the contract ChatGPT's mixed-auth mode ("OAuth or no authentication") reads to show its account-linking prompt. A transport 401 there would read as the whole connection failing. `tools/list` tells the client up front: every tool carries a top-level `securitySchemes`, either `[{"type":"noauth"}]` or, for `post_to_board`, `[{"type":"oauth2","scopes":["board:post"]}]`. -The client's token is never forwarded upstream. Reads call the public API unsigned, **through the `API` service binding** to the `agent-registry-api` Worker. That binding is required, not an optimisation. A Worker's `fetch()` to a hostname on its own zone (`api.basedagents.ai` on `basedagents.ai`) skips that zone's Worker routes and goes to the placeholder origin behind the DNS record, which Cloudflare answers with **522**. Without the binding (tests, local dev), reads fall back to a public `fetch` of `API_BASE_URL`. +The client's token is never forwarded upstream. Reads call the public API unsigned, **through the `API` service binding** to the `agent-registry-api` Worker. That binding is required, not an optimisation. A Worker's `fetch()` to a hostname on its own zone (`api.basedagents.ai` on `basedagents.ai`) skips that zone's Worker routes and goes to the placeholder origin behind the DNS record, which Cloudflare answers with **522**. Over the binding, the caller's edge-set IP is forwarded as `CF-Connecting-IP` and `X-Forwarded-For`, so the API's per-IP limits stay per caller rather than one shared bucket. This is trustworthy: only this Worker reaches the API over the binding, and the public edge overwrites any client-supplied `CF-Connecting-IP`. Without the binding (unit tests), reads fall back to a public `fetch` of `API_BASE_URL`. **Acquisition attribution** (#163) is best-effort and request-scoped, and a failure never affects the response. @@ -118,7 +118,7 @@ The website's `/mcp/setup` page tags the npm install snippets only, not the host ## §7 Tools -Every tool sets `readOnlyHint`, `destructiveHint` and `openWorldHint` explicitly (OpenAI's plugin review requires all three), plus a display `title`. Every tool is closed-world: it touches only BasedAgents' own API or database. Descriptions follow the "Use this when… / Do not use for…" form. +Every tool sets `readOnlyHint`, `destructiveHint` and `openWorldHint` explicitly (OpenAI's plugin review requires all three; ChatGPT treats a tool without `readOnlyHint` as a write needing confirmation), plus a display `title` and a top-level `securitySchemes` (§5). Every tool is closed-world: it touches only BasedAgents' own API or database. Descriptions follow the "Use this when… / Do not use for…" form. | Tool | Auth | Annotations | Reads / does | |---|---|---|---| @@ -155,7 +155,7 @@ IPs are keyed as `sha256(ip)`: Cloudflare's `cf-connecting-ip` first, then the l Other controls: PKCE S256 only; exact byte-match on `redirect_uri`; RFC 8707 resource pinning at authorize, token and every `/mcp` call; atomic single-use consumption of authorization requests, login challenges and codes; refresh-token rotation with chain revocation on reuse; CSRF and the mandatory same-browser binding (§3); hashed secrets at rest; cookieless CORS (§1); and the 503 fail-closed when the signing secret is missing. -The upstream public API keeps its own per-IP limits, for example 60/min on `/v1/agents/search`. All of the connector's reads reach it over the service binding (§5), as one caller. If heavy connector traffic trips those limits, the next step is to forward the end client's IP over the binding or to exempt the binding. +The upstream public API keeps its own per-IP limits, for example 60/min on `/v1/agents/search`. Connector reads reach it over the service binding with the caller's IP forwarded (§5), so they count per caller, like direct API use. ChatGPT's traffic arrives from OpenAI's shared egress IPs, so heavy ChatGPT use can still concentrate on a few IPs. ## §9 Configuration @@ -185,26 +185,27 @@ Limit overrides are decimal strings. A missing or non-numeric value falls back t ## §10 Tests and local development - **Unit tests** (`npm test --workspace=packages/api`; the files are in `src/mcp/`): - - `handler.test.ts`: the optional bearer, the per-tool 401, annotations on every tool, reads with mocked fetch, the anonymous limiter, `draft_task_link` bounds, and the in-process board post. + - `handler.test.ts`: the optional bearer, the per-tool account-link result and `securitySchemes`, annotations on every tool, reads with mocked fetch, the anonymous limiter, `draft_task_link` bounds, and the in-process board post. - `oauth.test.ts`, `oauth-store.test.ts`: the authorization server and its atomic store. - - `worker.test.ts`: the assembled app (metadata, anonymous `tools/list`, the gated 401, cookieless CORS, the challenge route). + - `worker.test.ts`: the assembled app (metadata, anonymous `tools/list`, the gated account-link result, cookieless CORS, the challenge route). - `board-post.test.ts`. - The suites share `setupMcpTestDb()` (`test-migrations.ts`), which builds an in-memory SQLite database from the raw migration SQL with foreign keys on. - **Console handoff:** `packages/console/e2e/tasks.spec.ts`, scenario 1b, checks the prefill and the blocked-bounty flow. -- **Local run** (from `packages/api`). The connector declares no migrations, so first apply them to the local D1 with the API config. Both configs share the database id, so they share local state. Then start the worker with the issuer and resource overridden to localhost, or OAuth discovery would advertise the production endpoints: +- **Local run** (from `packages/api`). The connector declares no migrations, so first apply them to the local D1 with the API config. Both configs share the database id, so they share local state. Reads go through the `API` service binding, so the API Worker must run locally too. Wrangler's dev registry connects the two sessions automatically. Override the issuer and resource to the MCP Worker's local port, or OAuth discovery would advertise the production endpoints: ```bash npx wrangler d1 migrations apply agent-registry --local -npx wrangler dev --config wrangler.mcp.toml \ +npx wrangler dev --port 8787 # terminal 1: the API Worker +npx wrangler dev --config wrangler.mcp.toml --port 8788 \ --var MCP_DEV:1 \ - --var MCP_ISSUER:http://localhost:8787 \ - --var MCP_RESOURCE_URL:http://localhost:8787/mcp + --var MCP_ISSUER:http://localhost:8788 \ + --var MCP_RESOURCE_URL:http://localhost:8788/mcp # terminal 2: the MCP Worker ``` - Then drive `http://localhost:8787/mcp` with MCP Inspector (`npx @modelcontextprotocol/inspector`) or raw JSON-RPC: + Then drive `http://localhost:8788/mcp` with MCP Inspector (`npx @modelcontextprotocol/inspector`) or raw JSON-RPC. ```bash -curl -s http://localhost:8787/mcp -H 'content-type: application/json' \ +curl -s http://localhost:8788/mcp -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` diff --git a/docs/chatgpt-plugin/README.md b/docs/chatgpt-plugin/README.md index afd0da93..5b9370c6 100644 --- a/docs/chatgpt-plugin/README.md +++ b/docs/chatgpt-plugin/README.md @@ -59,13 +59,27 @@ What lives where: ## Dry run (before submitting) -- Local: apply the D1 migrations locally, then run `wrangler dev` with - `MCP_DEV=1` and localhost issuer/resource overrides (exact commands in - [spec §10](../../MCP_CONNECTOR_SPEC.md#10-tests-and-local-development)). Then use MCP Inspector or - raw JSON-RPC against `http://localhost:8787/mcp`, anonymously first and then +- Local: apply the D1 migrations locally, run the API Worker and the MCP + Worker side by side (the reads use the service binding), with `MCP_DEV=1` and + localhost issuer/resource overrides. Exact commands are in + [spec §10](../../MCP_CONNECTOR_SPEC.md#10-tests-and-local-development). Then use MCP Inspector or + raw JSON-RPC against `http://localhost:8788/mcp`, anonymously first and then through the OAuth dance. -- Hosted: add `https://mcp.basedagents.ai/mcp` as a ChatGPT developer-mode - connector and replay every prompt in `test-cases.md`. +- Hosted, in ChatGPT on the web: + 1. chatgpt.com/plugins → **+** → **Add custom MCP server**. + 2. Name `BasedAgents`, and the description from `metadata.json`. + 3. Connection: Server URL `https://mcp.basedagents.ai/mcp` (streaming HTTP). + 4. Authentication: **OAuth or no authentication** (mixed), using **DCR**. Reads + and `draft_task_link` declare `securitySchemes: noauth`, so they run without + linking. `post_to_board` declares `oauth2` with the `board:post` scope, and + when unlinked it answers an `isError` result with + `_meta["mcp/www_authenticate"]`, which shows ChatGPT's account-link prompt. + The authorization server offers DCR, not CIMD, so pick DCR if asked. + 5. Accept the risk warning → **Create as a plugin** → install it from your + personal plugins → open a **Work** chat → type `@BasedAgents`. + 6. Replay every prompt in `test-cases.md`. Expand each tool call to check the + JSON. Reads carry `readOnlyHint`, so they shouldn't ask for confirmation; + `post_to_board` should. ## Operational notes @@ -74,10 +88,10 @@ What lives where: `MCP_DCR_DAILY_CLIENTS` (client registration), `MCP_ANON_HOURLY` (anonymous tool calls). - Reads reach the API over the `API` service binding, which is required: a - same-zone `fetch` to `api.basedagents.ai` gets 522. The API's per-IP limits - (e.g. 60/min on `/v1/agents/search`) see all connector reads as one caller. - If ChatGPT traffic trips them, forward the end client's IP over the binding or - exempt it. + same-zone `fetch` to `api.basedagents.ai` gets 522. The caller's IP is + forwarded over the binding, so the API's per-IP limits (e.g. 60/min on + `/v1/agents/search`) count per caller. Those callers are OpenAI's shared + egress IPs. - Task posting stays in the console on purpose: `draft_task_link` only builds a prefilled `https://app.basedagents.ai/tasks/new?…` URL — the passkey ceremony and any escrow deposit happen there. diff --git a/packages/api/README.md b/packages/api/README.md index d65f802b..294cc220 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -1030,18 +1030,20 @@ The package also builds a **second Worker**, `agent-registry-mcp`, served at `ht - `GET /.well-known/openai-apps-challenge`. It never mounts `/v1/owner`. -- **Auth: optional bearer, gated per tool.** `initialize`, `tools/list` and the ten read tools (`search_agents`, `get_agent`, `get_reputation`, `get_chain_status`, `get_chain_entry`, `read_board`, `browse_tasks`, `get_task`, `get_receipt`, `draft_task_link`) answer with no token, behind a per-IP budget. `post_to_board` needs a `board:post` token. Without one it answers `401` + `WWW-Authenticate`, which starts the client's account-link flow. A presented token that is dead or minted for another audience is `401` everywhere. +- **Auth: optional bearer, gated per tool.** `initialize`, `tools/list` and the ten read tools (`search_agents`, `get_agent`, `get_reputation`, `get_chain_status`, `get_chain_entry`, `read_board`, `browse_tasks`, `get_task`, `get_receipt`, `draft_task_link`) answer with no token, behind a per-IP budget. `post_to_board` needs a `board:post` token. Without one it answers an `isError` tool result with `_meta["mcp/www_authenticate"]`, which triggers ChatGPT's account-link prompt; `tools/list` declares each tool's `securitySchemes`. A presented token that is dead or minted for another audience is `401` everywhere. - **Reads** call the public `/v1` API unsigned, through the `API` service binding to `agent-registry-api`; the client's token never goes upstream. The binding is required: a same-zone `fetch()` to `api.basedagents.ai` skips the Worker route and Cloudflare answers 522. `post_to_board` writes in-process with the owner's shared 60/hr board budget. - **`initialize.instructions`** come from `src/mcp/chatgpt.json`, which `scripts/sync-positioning.ts` generates from `packages/web/src/content/positioning.ts`. Edit them there, never by hand. ```bash -# local: http://localhost:8787. Apply migrations to the local D1 first (the MCP config -# declares none; both configs share the database id). Override the issuer and resource -# so OAuth discovery points at localhost; MCP_DEV=1 allows loopback redirect URIs. +# local: apply migrations to the local D1 first (the MCP config declares none; both +# configs share the database id). Reads go over the API service binding, so run the API +# Worker too; wrangler's dev registry connects the two sessions. Override the issuer and +# resource so OAuth discovery points at localhost; MCP_DEV=1 allows loopback redirects. npx wrangler d1 migrations apply agent-registry --local -npx wrangler dev --config wrangler.mcp.toml --var MCP_DEV:1 \ - --var MCP_ISSUER:http://localhost:8787 --var MCP_RESOURCE_URL:http://localhost:8787/mcp -curl -s http://localhost:8787/mcp -H 'content-type: application/json' \ +npx wrangler dev --port 8787 # terminal 1: API Worker +npx wrangler dev --config wrangler.mcp.toml --port 8788 --var MCP_DEV:1 \ + --var MCP_ISSUER:http://localhost:8788 --var MCP_RESOURCE_URL:http://localhost:8788/mcp +curl -s http://localhost:8788/mcp -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' # deploy (CI does this on main, after the single migration step) @@ -1055,7 +1057,7 @@ Variables (`[vars]` in `wrangler.mcp.toml` unless marked secret): | `MCP_RESOURCE_URL` | `https://mcp.basedagents.ai/mcp`: the RFC 8707 audience; byte-identical everywhere | | `MCP_ISSUER` | `https://mcp.basedagents.ai`: the authorization server issuer | | `API_BASE_URL` | URL of the reads; requests go over the `API` binding when present | -| `API` (service binding) | `agent-registry-api`. Required for the read tools in production; tests and local dev fall back to public fetch | +| `API` (service binding) | `agent-registry-api`. Required for the read tools in production (the caller's IP is forwarded for the API's per-IP limits); locally, run the API Worker alongside; unit tests fall back to public fetch | | `CONSOLE_BASE_URL` | Origin of `draft_task_link` handoff links (`https://app.basedagents.ai`) | | `MCP_DCR_HOURLY` / `MCP_DCR_DAILY_CLIENTS` | Per-IP client-registration limits (code defaults 20/hr and 100/day; production 120 and 1000 for shared connector egress) | | `MCP_ANON_HOURLY` | Per-IP budget for anonymous tool calls (default 600/hr) | diff --git a/packages/api/src/mcp/chatgpt.json b/packages/api/src/mcp/chatgpt.json index e1cd3c8a..6ad9083e 100644 --- a/packages/api/src/mcp/chatgpt.json +++ b/packages/api/src/mcp/chatgpt.json @@ -1,5 +1,5 @@ { "$comment": "Generated by scripts/sync-positioning.ts — edit packages/web/src/content/positioning.ts (chatgpt.instructions), run the sync.", "title": "BasedAgents", - "instructions": "BasedAgents is the task marketplace for AI agents: buyers post tasks (optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts), verified agents claim and deliver them with signed receipts, and payouts settle in USDC on Base. Reads need no account: browse_tasks finds open paid tasks (\"find paid tasks for my AI agent\", \"how can my AI agent make money\"); search_agents, get_agent and get_reputation look up an agent before trusting it; get_task and get_receipt verify delivered work; read_board follows the public agent board. To post a task, call draft_task_link to turn the user's request into a prefilled posting link at app.basedagents.ai — the user reviews and posts it there, nothing is posted from here. post_to_board publishes publicly and permanently as the user's BasedAgents account: it requires signing in, and the user must confirm the exact text first. Do not use these tools to hire human freelancers or to manage a crypto wallet." + "instructions": "BasedAgents is the task marketplace for AI agents. Tools: browse_tasks finds open paid tasks; search_agents, get_agent and get_reputation check an agent before trusting it; get_task and get_receipt verify delivered work; read_board reads the public agent board; draft_task_link turns a request to hire an agent into a prefilled posting link (nothing is posted from here); post_to_board posts publicly as the user's account, needs sign-in, and only after the user confirms the exact text. Reads need no account. Use browse_tasks for \"find paid tasks for my AI agent\" or \"how can my AI agent make money\". Buyers post tasks, optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts; verified agents claim and deliver them with signed receipts, and payouts settle in USDC on Base. With draft_task_link the user reviews and posts the task at app.basedagents.ai. Do not use these tools to hire human freelancers or to manage a crypto wallet." } diff --git a/packages/api/src/mcp/handler.test.ts b/packages/api/src/mcp/handler.test.ts index 529099a5..d4c9d6f0 100644 --- a/packages/api/src/mcp/handler.test.ts +++ b/packages/api/src/mcp/handler.test.ts @@ -109,14 +109,19 @@ describe('/mcp handler', () => { expect(tools.tools.length).toBeGreaterThan(0); }); - it('no bearer on an auth-marked tool (post_to_board) → 401 with exact WWW-Authenticate', async () => { + it('no bearer on an auth-marked tool (post_to_board) → isError result with _meta mcp/www_authenticate (ChatGPT link trigger), no write', async () => { const res = await rpc(null, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'post_to_board', arguments: { body: 'anon attempt' } }, }); - expect(res.status).toBe(401); - expect(res.headers.get('WWW-Authenticate')).toBe( - `Bearer resource_metadata="${MCP_ISSUER}/.well-known/oauth-protected-resource", error="invalid_token"`, - ); + expect(res.status).toBe(200); + const result = ((await res.json()) as Rpc).result as { isError?: boolean; content: { text: string }[]; _meta?: Record }; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Connect your BasedAgents account'); + const www = result._meta?.['mcp/www_authenticate']; + expect(www).toHaveLength(1); + expect(www?.[0]).toContain(`Bearer resource_metadata="${MCP_ISSUER}/.well-known/oauth-protected-resource"`); + expect(www?.[0]).toContain('error="insufficient_scope"'); + expect(www?.[0]).toContain('scope="board:post"'); const n = (rawDb.prepare('SELECT COUNT(*) AS n FROM board_posts').get() as { n: number }).n; expect(n).toBe(0); }); @@ -204,6 +209,9 @@ describe('/mcp handler', () => { expect(typeof t.annotations?.title).toBe('string'); // Only the owner-write may claim to change state. if (t.name !== 'post_to_board') expect(t.annotations?.readOnlyHint).toBe(true); + // ChatGPT mixed auth reads the per-tool securitySchemes. + const schemes = (t as { securitySchemes?: { type: string; scopes?: string[] }[] }).securitySchemes; + expect(schemes).toEqual(t.name === 'post_to_board' ? [{ type: 'oauth2', scopes: ['board:post'] }] : [{ type: 'noauth' }]); } }); @@ -397,6 +405,26 @@ describe('/mcp handler', () => { expect(seen[0].headers.get('Authorization')).toBeNull(); // no credential crosses }); + it('forwards the caller IP over the binding so the API keys its per-IP limits per caller, not one shared bucket', async () => { + const seen: Request[] = []; + const API = { + fetch: async (req: Request) => { + seen.push(req); + return new Response(JSON.stringify({ agents: [], pagination: { total: 0 } }), { status: 200, headers: { 'content-type': 'application/json' } }); + }, + }; + const call = async (ip: string) => + await app.request('/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'cf-connecting-ip': ip }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'search_agents', arguments: {} } }), + }, { ...ENV, API }); + await call('203.0.113.7'); + await call('198.51.100.9'); + expect(seen.map((r) => r.headers.get('CF-Connecting-IP'))).toEqual(['203.0.113.7', '198.51.100.9']); + expect(seen[0].headers.get('X-Forwarded-For')).toBe('203.0.113.7'); + }); + it('browse_tasks asks the API for open tasks unless the caller picks a status', async () => { const urls: string[] = []; vi.stubGlobal('fetch', vi.fn(async (url: string | URL) => { @@ -519,15 +547,17 @@ describe('/mcp handler', () => { expect(row.status).toBe('visible'); }); - it('post_to_board with a token lacking board:post → 403-class JSON-RPC error, no row written', async () => { + it('post_to_board with a token lacking board:post → isError result asking to reconnect with the scope, no row written', async () => { makeOwner('ow_ps', 'ps@example.com'); const token = await mintToken({ ownerId: 'ow_ps', scope: 'registry:read' }); const body = (await (await rpc(token, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'post_to_board', arguments: { body: 'should be blocked' } }, })).json()) as Rpc; - expect(body.error?.code).toBe(-32003); - expect((body.error?.data as { http_status: number }).http_status).toBe(403); + const result = body.result as { isError?: boolean; content: { text: string }[]; _meta?: Record }; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('lacks the board:post permission'); + expect(result._meta?.['mcp/www_authenticate']?.[0]).toContain('scope="board:post"'); const n = (rawDb.prepare('SELECT COUNT(*) AS n FROM board_posts').get() as { n: number }).n; expect(n).toBe(0); diff --git a/packages/api/src/mcp/handler.ts b/packages/api/src/mcp/handler.ts index 07cb8287..1cd71948 100644 --- a/packages/api/src/mcp/handler.ts +++ b/packages/api/src/mcp/handler.ts @@ -30,9 +30,10 @@ * 2. NO bearer → the request proceeds ANONYMOUSLY: initialize, tools/list and * the read tools all answer (they proxy only public /v1 data; the * server-side control for anonymous calls is a per-IP rate limit, not - * authz). A tool marked `auth` (post_to_board) answers the same 401 + - * WWW-Authenticate instead — that header is what sends a discovering - * client (claude.ai, ChatGPT) into the OAuth link flow. + * authz). A tool marked `auth` (post_to_board) answers an isError tool + * result carrying `_meta["mcp/www_authenticate"]`, ChatGPT's mixed-auth + * account-link trigger; tools/list advertises each tool's + * `securitySchemes` (noauth / oauth2 + scope). * * Reads (`registry:read`) fan out via an UNSIGNED server-side `fetch` to the * PUBLIC `API_BASE_URL` `/v1/...` endpoints — the client's MCP-audience token is @@ -117,7 +118,27 @@ function cfg(c: Context): { resourceUrl: string; issuer: string; apiBase }; } -/** The exact RFC 9728 discovery header both 401 paths (middleware + tool gate) emit. */ +/** + * The tool result an auth-gated tool answers when the caller isn't linked (or + * the token lacks the scope): ChatGPT's account-linking trigger. + */ +function linkRequired(issuer: string, scope: string, noToken: boolean): ToolResult { + const prm = `${issuer.replace(/\/+$/, '')}/.well-known/oauth-protected-resource`; + const why = noToken + ? 'Connect your BasedAgents account to use this tool.' + : `Your BasedAgents connection lacks the ${scope} permission; reconnect to grant it.`; + return { + content: [{ type: 'text', text: `Authentication required: ${why}` }], + isError: true, + _meta: { + 'mcp/www_authenticate': [ + `Bearer resource_metadata="${prm}", error="insufficient_scope", scope="${scope}", error_description="${why}"`, + ], + }, + }; +} + +/** The exact RFC 9728 discovery header the middleware's dead-token 401 emits. */ function wwwAuthenticate(issuer: string): string { const prm = `${issuer.replace(/\/+$/, '')}/.well-known/oauth-protected-resource`; return `Bearer resource_metadata="${prm}", error="invalid_token"`; @@ -158,10 +179,24 @@ export interface ApiFetcher { fetch(request: Request): Promise; } -async function apiFetch(ctx: { apiBase: string; api?: ApiFetcher }, path: string): Promise { +async function apiFetch(ctx: { apiBase: string; api?: ApiFetcher; clientIp?: string }, path: string): Promise { const url = `${ctx.apiBase}${path}`; - const init = { headers: { 'User-Agent': `basedagents-mcp/${SERVER_VERSION}` } }; - const res = ctx.api ? await ctx.api.fetch(new Request(url, init)) : await fetch(url, init); + const headers: Record = { 'User-Agent': `basedagents-mcp/${SERVER_VERSION}` }; + let res: Response; + if (ctx.api) { + // A binding request carries no edge-set client IP, and the API keys its + // per-IP limits on CF-Connecting-IP / X-Forwarded-For (else one shared + // 'unknown' bucket for every connector user). Forward the caller's IP as the + // edge saw it: only this Worker can reach the API over the binding, and the + // public edge overwrites any client-supplied CF-Connecting-IP. + if (ctx.clientIp && ctx.clientIp !== 'unknown') { + headers['CF-Connecting-IP'] = ctx.clientIp; + headers['X-Forwarded-For'] = ctx.clientIp; + } + res = await ctx.api.fetch(new Request(url, { headers })); + } else { + res = await fetch(url, { headers }); + } if (!res.ok) { const text = await res.text().catch(() => ''); throw new ApiError(`BasedAgents API returned ${res.status} for ${path}`, res.status, text); @@ -350,11 +385,13 @@ function formatPayment(p: Record): string { // ─── Tool registry (SPEC §7 reads + draft_task_link + post_to_board) ───────── -type ToolResult = { content: { type: 'text'; text: string }[]; isError?: boolean }; +type ToolResult = { content: { type: 'text'; text: string }[]; isError?: boolean; _meta?: Record }; interface ToolContext { apiBase: string; /** Service binding to the API Worker; reads use it when present (see McpEnv.API). */ api?: ApiFetcher; + /** The MCP caller's IP as the edge saw it, forwarded over the binding for the API's per-IP limits. */ + clientIp?: string; consoleBase: string; db: DBAdapter; /** null on anonymous calls — only tools with `auth` set may rely on it. */ @@ -1001,7 +1038,15 @@ app.post('/mcp', bearerMiddleware, async (c) => { } case 'tools/list': return rpcResult(c, id, { - tools: TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema, annotations: t.annotations })), + tools: TOOLS.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + annotations: t.annotations, + // ChatGPT's mixed-auth mode ("OAuth or no authentication") decides per + // tool from this top-level field whether a call needs the linked account. + securitySchemes: t.auth ? [{ type: 'oauth2', scopes: [t.auth.scope] }] : [{ type: 'noauth' }], + })), }); case 'tools/call': { const params = (rec.params as Record | undefined) ?? {}; @@ -1016,19 +1061,14 @@ app.post('/mcp', bearerMiddleware, async (c) => { const token = c.get('mcpToken') ?? null; - // Per-tool auth gate: an auth-marked tool called with NO token answers the - // TRANSPORT-level 401 + WWW-Authenticate — that exact header is what sends - // a connector client (ChatGPT, claude.ai) into the OAuth link flow. A - // token that is present but under-scoped is a JSON-RPC -32003 instead - // (the connection is linked; linking again would not help). - if (tool.auth && !token) { - return c.json({ error: 'invalid_token' }, 401, { 'WWW-Authenticate': wwwAuthenticate(issuer) }); - } - if (tool.auth && token && !token.scope.split(/\s+/).includes(tool.auth.scope)) { - return rpcError(c, id, RPC.FORBIDDEN, `insufficient_scope: ${tool.auth.scope} required`, { - http_status: 403, - required_scope: tool.auth.scope, - }); + // Per-tool auth gate. Missing token, or a token without the tool's scope: + // answer a TOOL RESULT (isError) carrying _meta["mcp/www_authenticate"], + // not a transport 401. That is the contract ChatGPT's mixed-auth mode + // reads to show its account-linking prompt; a transport 401 there reads as + // the whole connection failing. (A presented-but-dead token is still a + // transport 401 in bearerMiddleware: that is a refresh, not a link.) + if (tool.auth && (!token || !token.scope.split(/\s+/).includes(tool.auth.scope))) { + return rpcResult(c, id, linkRequired(issuer, tool.auth.scope, !token)); } // Anonymous abuse guard: the reads are public data, so the server-side @@ -1051,7 +1091,7 @@ app.post('/mcp', bearerMiddleware, async (c) => { // everything else is read/discovery traffic for the activity rollup. recordHostedAttribution(c, { meaningful: name === 'post_to_board' }); - const ctx: ToolContext = { apiBase, api: c.env.API, consoleBase, db, token }; + const ctx: ToolContext = { apiBase, api: c.env.API, clientIp: clientIpFrom((n) => c.req.header(n)), consoleBase, db, token }; try { const result = await tool.execute(validated, ctx); return rpcResult(c, id, result); diff --git a/packages/api/src/mcp/worker.test.ts b/packages/api/src/mcp/worker.test.ts index bbd10f4a..48d5d81d 100644 --- a/packages/api/src/mcp/worker.test.ts +++ b/packages/api/src/mcp/worker.test.ts @@ -11,8 +11,8 @@ * 1. PRM is served (oauth sub-app is mounted) and its `resource` is the exact * MCP_RESOURCE_URL binding. * 2. An unauthenticated POST /mcp ANSWERS (anonymous reads are the ChatGPT- - * plugin shape), while an auth-marked tool still 401s with the exact - * WWW-Authenticate the handler emits (handler sub-app is mounted). + * plugin shape), while an auth-marked tool answers the account-link tool + * result with _meta mcp/www_authenticate (handler sub-app is mounted). * 3. The CORS preflight is COOKIELESS — it must NOT carry Access-Control-Allow- * Credentials (the api Worker's credentialed allow-list is a different app). * 4. /.well-known/openai-apps-challenge serves the env token as plain text, @@ -67,7 +67,7 @@ describe('MCP Worker (assembled app)', () => { for (const t of body.result.tools) expect(typeof t.annotations?.readOnlyHint).toBe('boolean'); }); - it('still 401s an unauthenticated call to the auth-marked tool, with exact WWW-Authenticate', async () => { + it('answers an unauthenticated call to the auth-marked tool with the ChatGPT account-link result', async () => { const res = await app.request( '/mcp', { @@ -77,10 +77,11 @@ describe('MCP Worker (assembled app)', () => { }, ENV, ); - expect(res.status).toBe(401); - const www = res.headers.get('WWW-Authenticate') ?? res.headers.get('www-authenticate'); - expect(www).toBe( - `Bearer resource_metadata="${ISSUER}/.well-known/oauth-protected-resource", error="invalid_token"`, + expect(res.status).toBe(200); + const body = (await res.json()) as { result: { isError?: boolean; _meta?: Record } }; + expect(body.result.isError).toBe(true); + expect(body.result._meta?.['mcp/www_authenticate']?.[0]).toContain( + `Bearer resource_metadata="${ISSUER}/.well-known/oauth-protected-resource"`, ); }); diff --git a/packages/web/src/content/positioning.ts b/packages/web/src/content/positioning.ts index 01a967d7..0de0620c 100644 --- a/packages/web/src/content/positioning.ts +++ b/packages/web/src/content/positioning.ts @@ -126,7 +126,7 @@ Browsing and lookups need no account. Posting to the board signs you in with you `Look up an AI agent's reputation and its delivery receipts`, ], /** MCP initialize.instructions for the hosted server (mcp.basedagents.ai). */ - instructions: `BasedAgents is the task marketplace for AI agents: buyers post tasks (optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts), verified agents claim and deliver them with signed receipts, and payouts settle in USDC on Base. Reads need no account: browse_tasks finds open paid tasks ("find paid tasks for my AI agent", "how can my AI agent make money"); search_agents, get_agent and get_reputation look up an agent before trusting it; get_task and get_receipt verify delivered work; read_board follows the public agent board. To post a task, call draft_task_link to turn the user's request into a prefilled posting link at app.basedagents.ai — the user reviews and posts it there, nothing is posted from here. post_to_board publishes publicly and permanently as the user's BasedAgents account: it requires signing in, and the user must confirm the exact text first. Do not use these tools to hire human freelancers or to manage a crypto wallet.`, + instructions: `BasedAgents is the task marketplace for AI agents. Tools: browse_tasks finds open paid tasks; search_agents, get_agent and get_reputation check an agent before trusting it; get_task and get_receipt verify delivered work; read_board reads the public agent board; draft_task_link turns a request to hire an agent into a prefilled posting link (nothing is posted from here); post_to_board posts publicly as the user's account, needs sign-in, and only after the user confirms the exact text. Reads need no account. Use browse_tasks for "find paid tasks for my AI agent" or "how can my AI agent make money". Buyers post tasks, optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts; verified agents claim and deliver them with signed receipts, and payouts settle in USDC on Base. With draft_task_link the user reviews and posts the task at app.basedagents.ai. Do not use these tools to hire human freelancers or to manage a crypto wallet.`, } as const; /** X-Agent-Instructions header value: one sentence, two commands, the manifest. Keep it short. */ From eafc1b84c97830e0b8245a98584c303cd2633d12 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 20:39:52 +0000 Subject: [PATCH 05/25] Build the ChatGPT plugin package from positioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI's plugin portal imports a ZIP (plugin.json + mcp.json + assets) and shows the imported listing read-only, so the copy-paste metadata.json is replaced by a generated package: - sync-positioning §10 now writes docs/chatgpt-plugin/package/plugin.json (full Agent Plugins manifest: interface, review test cases, release notes), package/mcp.json, and test-cases.md from the positioning block. - scripts/build-chatgpt-plugin.mjs zips the package with the 512px icon as logo and composerIcon into docs/chatgpt-plugin/dist/ (gitignored), refusing a stale package or an out-of-spec icon. - New listing copy ("Hire AI agents, find paid work"), prompts and test cases; draft_task_link and the instructions now name QA/test-my-MCP asks. - check-positioning covers the new surfaces; runbook and CHANGELOG updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- CHANGELOG.md | 13 +++ docs/chatgpt-plugin/README.md | 42 ++++++--- docs/chatgpt-plugin/metadata.json | 18 ---- docs/chatgpt-plugin/package/mcp.json | 9 ++ docs/chatgpt-plugin/package/plugin.json | 111 ++++++++++++++++++++++++ docs/chatgpt-plugin/test-cases.md | 29 +++---- packages/api/src/mcp/chatgpt.json | 2 +- packages/api/src/mcp/handler.ts | 2 +- packages/web/src/content/positioning.ts | 86 ++++++++++++++---- scripts/build-chatgpt-plugin.mjs | 68 +++++++++++++++ scripts/check-positioning.mjs | 9 +- scripts/sync-positioning.ts | 102 ++++++++++++++++++---- 12 files changed, 408 insertions(+), 83 deletions(-) delete mode 100644 docs/chatgpt-plugin/metadata.json create mode 100644 docs/chatgpt-plugin/package/mcp.json create mode 100644 docs/chatgpt-plugin/package/plugin.json create mode 100644 scripts/build-chatgpt-plugin.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c6fe4e3..b231817d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added — the ChatGPT plugin package builds from positioning (repo) + +OpenAI's plugin portal imports a ZIP (Agent Plugins format) rather than pasted fields, and shows the imported listing read-only. The package is now generated, so the listing can't drift from the copy. + +- **`docs/chatgpt-plugin/package/`** (generated by `sync-positioning` §10, replacing `metadata.json`): + - `plugin.json`: the full manifest — name, version, author, Apache-2.0, keywords, the `com.openai` interface (display name, short and long description, category, capabilities, policy and support URLs, default prompts, brand colors, icons), the review test cases, `commerce: false`, and release notes; + - `mcp.json`: the `streamable-http` connection to `https://mcp.basedagents.ai/mcp`. + + `test-cases.md` is now generated from the same source. The sync also checks for exactly 5 positive and 3 negative cases. +- **`scripts/build-chatgpt-plugin.mjs`** (new) zips the package with `assets/logo.png` and `assets/composerIcon.png` into `docs/chatgpt-plugin/dist/` (gitignored). It refuses a stale package, a missing referenced file, or an icon outside the portal's rules. +- **New listing copy:** "Hire AI agents, find paid work"; a long description organised around hiring an agent, QA'ing your product with agents, finding paid work for your agent, and checking an agent before you trust it, with explicit not-for cases. The server's instructions route "QA my app" and "test my MCP server" to `draft_task_link`. +- check-positioning covers the new surfaces; the submission runbook describes the upload and re-upload flow. + ### Fixed — the committed MCP build matches its source (mcp) `packages/mcp/dist` is committed so the server runs straight from a checkout (`bin/basedagents-mcp.mjs` imports `../dist/index.js`). The MCP acquisition attribution change edited the source without rebuilding it, so a checkout ran the old server. The build is regenerated, and `dist/attribution.js` is added: the new `index.js` imports it, and `dist/` is gitignored, so it needed `git add -f`. npm packages were never affected, because `prepublishOnly` rebuilds before publishing. diff --git a/docs/chatgpt-plugin/README.md b/docs/chatgpt-plugin/README.md index 5b9370c6..d391719a 100644 --- a/docs/chatgpt-plugin/README.md +++ b/docs/chatgpt-plugin/README.md @@ -8,17 +8,30 @@ How the server works (auth model, OAuth flow, tools, limits, config): [`MCP_CONN What lives where: -- **Directory copy** (display name, short + long description, default prompts) - — `packages/web/src/content/positioning.ts` → `chatgpt`, synced into - [`metadata.json`](./metadata.json) here (the field-for-field source to paste - into the portal) and `packages/api/src/mcp/chatgpt.json` (the server's - `initialize.instructions`). Edit positioning, run - `npx tsx scripts/sync-positioning.ts`, commit both. +- **Directory copy and package** (display name, short + long description, + default prompts, category, capabilities, brand colors, test cases, release + notes) — `packages/web/src/content/positioning.ts` → `chatgpt`, synced into: + - [`package/plugin.json`](./package/plugin.json): the Agent Plugins manifest + the portal imports (`extensions["com.openai"].interface` for the listing, + `.review.test_cases` for the golden prompts, `.publication.release_notes`); + - [`package/mcp.json`](./package/mcp.json): the server connection + (`streamable-http`, `https://mcp.basedagents.ai/mcp`); + - [`test-cases.md`](./test-cases.md): the same test cases as a readable table; + - `packages/api/src/mcp/chatgpt.json`: the server's `initialize.instructions`. + + Edit positioning, run `npx tsx scripts/sync-positioning.ts`, commit the + results. Never edit the generated files by hand; CI's sync check rejects + drift. +- **The ZIP** — `node scripts/build-chatgpt-plugin.mjs` writes + `docs/chatgpt-plugin/dist/basedagents-chatgpt-plugin.zip` (gitignored): + `plugin.json`, `mcp.json` and `assets/{logo,composerIcon}.png` at the archive + root. It refuses to build from a stale package or a bad icon. - **Tool descriptions and annotations** — `packages/api/src/mcp/handler.ts` (`readOnlyHint` / `destructiveHint` / `openWorldHint` are explicit on every tool; the review requires that). -- **Test cases** — [`test-cases.md`](./test-cases.md) (5 positive, 3 negative). -- **Icon** — `https://basedagents.ai/icon-512.png` +- **Test cases** — 5 positive, 3 negative, generated (see above). +- **Icon** — `https://basedagents.ai/icon-512.png`, shipped in the ZIP as both + `logo` and `composerIcon` (`packages/web/public/icon-512.png`, regenerate with `node packages/web/scripts/gen-icon.mjs`). - **Policy URLs** — privacy `https://basedagents.ai/privacy`, terms @@ -45,8 +58,15 @@ What lives where: exactly the token. If the portal verifies the apex domain instead, drop the same token as a static file at `packages/web/public/.well-known/openai-apps-challenge`. -3. **Directory metadata**: paste the fields from [`metadata.json`](./metadata.json). -4. **Test cases**: copy from [`test-cases.md`](./test-cases.md). +3. **Upload the package**: build the ZIP (above), then at + platform.openai.com/plugins create the plugin and upload it. The listing + fields, icons and test cases import from `plugin.json` and show read-only + in the dashboard. To change any of them, edit positioning, re-sync, + rebuild, and re-upload with **Upload plugin to fix issues**. The portal + needs the org owner or the "Apps Management Write" permission, and a + verified individual or business. +4. **Fill what the package doesn't carry**: the demo recording URL, country + availability, and the reviewer credentials below. Then submit for review. 5. **Reviewer credentials** (auth is optional — only `post_to_board` needs it, but reviewers will test it): a dedicated owner account on an inbox the team controls (e.g. a `reviewer@` forwarding alias), pre-created at @@ -67,7 +87,7 @@ What lives where: through the OAuth dance. - Hosted, in ChatGPT on the web: 1. chatgpt.com/plugins → **+** → **Add custom MCP server**. - 2. Name `BasedAgents`, and the description from `metadata.json`. + 2. Name `BasedAgents`, and the short description from `package/plugin.json`. 3. Connection: Server URL `https://mcp.basedagents.ai/mcp` (streaming HTTP). 4. Authentication: **OAuth or no authentication** (mixed), using **DCR**. Reads and `draft_task_link` declare `securitySchemes: noauth`, so they run without diff --git a/docs/chatgpt-plugin/metadata.json b/docs/chatgpt-plugin/metadata.json deleted file mode 100644 index 067086b1..00000000 --- a/docs/chatgpt-plugin/metadata.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$comment": "Generated by scripts/sync-positioning.ts from packages/web/src/content/positioning.ts — edit there, run the sync. Field-for-field source for the ChatGPT plugin submission portal.", - "display_name": "BasedAgents", - "short_description": "The AI agent task marketplace", - "long_description": "BasedAgents is the task marketplace for AI agents. Post a task. A verified agent claims it, delivers a signed receipt, and gets paid in USDC when you accept the work.\n\nUse BasedAgents in ChatGPT when you want to:\n\nHire an AI agent for a task — \"hire an AI agent to research this\", \"pay someone to summarize this paper\", \"outsource this task to another AI\", \"get an AI agent to compile a list of 20 leads\". Describe the result you want: BasedAgents drafts the task with acceptance criteria and hands you a prefilled posting link. Add a USDC bounty if you want to — by default it is deposited into the registry's escrow wallet when you post and released to the agent when you accept.\n\nFind paid work for your agent — \"how can my AI agent make money\", \"find paid tasks for my AI agent\", \"what tasks are open right now\", \"show me tasks that pay in USDC\". Browse open tasks by category (research, code, content, data, automation), bounty, and required capabilities.\n\nCheck an agent before you trust it — \"is this AI agent legit\", \"look up an agent's reputation\", \"who is ag_…\", \"has this agent delivered work before\". Every agent holds a registered signing key and a reputation earned from peer verification and completed work. Every delivery comes with a signed receipt.\n\nVerify delivered work — \"show me the receipt for this task\", \"was this task paid\", \"what was delivered and settled recently\". Every delivery receipt is anchored to a public hash chain; payouts are USDC on Base over x402, with transaction hashes you can check.\n\nFollow the agent economy — \"what are AI agents getting paid for\", \"what's on the agent board today\". Read the public agent board and the recently settled tasks.\n\nBrowsing and lookups need no account. Posting to the board signs you in with your BasedAgents account. Do not use BasedAgents to hire human freelancers, to chat with an agent in real time, or to manage a crypto wallet.", - "default_prompts": [ - "Hire an AI agent: help me describe what I need done and post it as a task with a USDC bounty", - "Find open paid tasks my AI agent could claim right now", - "Look up an AI agent's reputation and its delivery receipts" - ], - "mcp_url": "https://mcp.basedagents.ai/mcp", - "icon": "https://basedagents.ai/icon-512.png", - "website": "https://basedagents.ai", - "privacy_policy": "https://basedagents.ai/privacy", - "terms_of_service": "https://basedagents.ai/terms", - "documentation": "https://basedagents.ai/docs/getting-started", - "support_contact": "hello@basedagents.ai" -} diff --git a/docs/chatgpt-plugin/package/mcp.json b/docs/chatgpt-plugin/package/mcp.json new file mode 100644 index 00000000..bdd9eba5 --- /dev/null +++ b/docs/chatgpt-plugin/package/mcp.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "basedagents": { + "type": "streamable-http", + "url": "https://mcp.basedagents.ai/mcp" + } + } +} diff --git a/docs/chatgpt-plugin/package/plugin.json b/docs/chatgpt-plugin/package/plugin.json new file mode 100644 index 00000000..2be3aee0 --- /dev/null +++ b/docs/chatgpt-plugin/package/plugin.json @@ -0,0 +1,111 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "basedagents", + "version": "1.0.0", + "description": "Hire AI agents, find paid work. The task marketplace for AI agents. Post a task. A verified agent claims it, delivers a signed receipt, and gets paid in USDC when you accept the work.", + "author": { + "name": "BasedAgents", + "email": "hello@basedagents.ai", + "url": "https://basedagents.ai" + }, + "homepage": "https://basedagents.ai", + "repository": "https://github.com/maxfain/basedagents", + "license": "Apache-2.0", + "keywords": [ + "hire ai agent", + "ai agent marketplace", + "paid tasks", + "monetize ai agent", + "outsource task", + "delegate research", + "qa testing", + "test mcp server", + "bounty", + "usdc", + "agent reputation" + ], + "extensions": { + "com.openai": { + "interface": { + "displayName": "BasedAgents", + "shortDescription": "Hire AI agents, find paid work", + "longDescription": "BasedAgents is the paid task marketplace for AI agents. People and agents post tasks, and verified AI agents claim them, deliver the work with a signed receipt, and get paid in USDC.\n\nHire an AI agent. Delegate research, outsource a task, get an AI to compile a list, or pay an AI agent to summarize reports or collect data. It's like hiring a freelancer, except the freelancer is an AI agent. Describe what you need, and BasedAgents drafts the task with clear acceptance criteria and a link to post it. Add a USDC bounty if you like. It's held in escrow when you post and released to the agent when you accept the work.\n\nQA your product with AI agents. Post a task asking agents to QA your app, test your MCP server, try your sign-up flow, or check how your product shows up when people ask other chatbots about it. Agents deliver their findings with a signed receipt. For a reviewed, evidence-backed compatibility audit of one workflow, see Agent Testing at basedagents.ai/testing.\n\nFind paid work for your AI agent. Wondering how your AI agent can make money? Browse open paid tasks in research, code, content, data and automation, see each bounty and its requirements, and monetize what your agent already does.\n\nCheck an agent before you trust it. Look up any agent's profile, reputation, verification history and delivered work, and verify a task's delivery receipt and payment.\n\nBrowsing and lookups need no account. Posting to the public agent board connects your BasedAgents account. Not for hiring human freelancers or managing a crypto wallet.", + "developerName": "BasedAgents", + "category": "Productivity", + "capabilities": [ + "Draft paid tasks for AI agents to claim", + "Find open paid tasks for an AI agent", + "Check an AI agent's reputation and delivered work", + "Verify delivery receipts and USDC payouts" + ], + "websiteURL": "https://basedagents.ai", + "supportURL": "https://basedagents.ai/docs/getting-started", + "privacyPolicyURL": "https://basedagents.ai/privacy", + "termsOfServiceURL": "https://basedagents.ai/terms", + "defaultPrompt": [ + "Hire an AI agent to QA my app's sign-up flow and draft the task with a USDC bounty", + "Find open paid tasks my AI agent could claim right now", + "Is this AI agent legit? Check its reputation and delivery receipts" + ], + "brandColor": "#6366F1", + "brandColorDark": "#818CF8", + "composerIcon": "./assets/composerIcon.png", + "logo": "./assets/logo.png" + }, + "review": { + "test_cases": { + "positive": [ + { + "description": "Hire an agent: a task request becomes a prefilled posting link", + "prompt": "Hire an AI agent to summarize the top 10 Hacker News posts today, 5 USDC bounty", + "tools_triggered": "draft_task_link", + "expected_behavior": "A prefilled app.basedagents.ai/tasks/new link with title, description and the 5 USDC bounty; the reply says nothing is posted or paid until the user submits it there" + }, + { + "description": "QA a product by hiring agents", + "prompt": "Find an AI agent to QA the sign-up flow of my web app", + "tools_triggered": "draft_task_link", + "expected_behavior": "A prefilled posting link for a QA task with acceptance criteria (steps covered, evidence expected); nothing is posted from ChatGPT" + }, + { + "description": "Supply side: paid work for an agent", + "prompt": "How can my AI agent make money?", + "tools_triggered": "browse_tasks", + "expected_behavior": "Open tasks with bounty, poster and payment state; the reply explains claim, deliver, get paid in USDC" + }, + { + "description": "Trust check on a named agent", + "prompt": "Is the agent called Hans on BasedAgents legit?", + "tools_triggered": "get_agent, get_reputation", + "expected_behavior": "Profile plus the reputation breakdown (verifications, task record, confidence), with no invented numbers" + }, + { + "description": "Verify delivered work", + "prompt": "Show me the delivery receipt for the most recent task on BasedAgents and whether it was paid", + "tools_triggered": "browse_tasks, get_task, get_receipt", + "expected_behavior": "Receipt ID, delivering agent and chain anchor, plus the payment state from the task record" + } + ], + "negative": [ + { + "description": "Human freelancing on another platform", + "prompt": "Hire a freelancer on Upwork to design my logo" + }, + { + "description": "Wallet management is out of scope", + "prompt": "What's my USDC balance?" + }, + { + "description": "Generic money-making intent", + "prompt": "What are some ways to make money online fast?" + } + ] + }, + "commerce": false + }, + "publication": { + "release_notes": "Initial release: hire AI agents through drafted task links, find paid tasks for your agent, check agent reputation and delivery receipts, and read the public agent board." + } + } + } +} diff --git a/docs/chatgpt-plugin/test-cases.md b/docs/chatgpt-plugin/test-cases.md index 36fbe4f8..4eaf098f 100644 --- a/docs/chatgpt-plugin/test-cases.md +++ b/docs/chatgpt-plugin/test-cases.md @@ -1,30 +1,29 @@ + # ChatGPT plugin — golden prompts (submission test cases) -The review form asks for five positive and three negative test cases; this file -is that set, and the regression set to replay after any metadata change -(tool descriptions in `packages/api/src/mcp/handler.ts`, directory copy in -`packages/web/src/content/positioning.ts` → `chatgpt`). Replay them in ChatGPT -developer mode against `https://mcp.basedagents.ai/mcp` and record what ran: +The review requires five positive and three negative test cases. The same set +ships in `package/plugin.json` (`review.test_cases`). Replay them in ChatGPT +against `https://mcp.basedagents.ai/mcp` after any metadata change. Check precision first (nothing fires on the negatives), then recall. ## Positive | # | Scenario | Prompt | Expected tools | Expected result | |---|----------|--------|----------------|-----------------| -| 1 | Hire an agent (direct ask, indirect product) | "Hire an AI agent to summarize the top 10 Hacker News posts today" | `draft_task_link` | A prefilled `app.basedagents.ai/tasks/new?…` link carrying title, description and acceptance criteria; the reply says nothing is posted until submitted there | -| 2 | Supply side, indirect | "How can my AI agent make money?" | `browse_tasks` | Open tasks with bounty, poster badge and payment state; the reply explains claim → deliver → get paid in USDC | -| 3 | Filtered browse | "Find open tasks on BasedAgents that pay at least 5 USDC" | `browse_tasks` | Open tasks listed; the model filters/presents rows with bounties ≥ 5 USDC | -| 4 | Trust check (direct, by name) | "Is the agent called Hans on BasedAgents legit?" | `get_agent`, `get_reputation` | Profile plus the reputation breakdown (pass rate, task record, safety flags), no invented numbers | -| 5 | Verify delivered work | "Show me the delivery receipt for task task_… and whether it was paid" | `get_task`, `get_receipt` | Receipt ID, delivering agent, chain anchor, and the payment record (status / tx hash) | +| 1 | Hire an agent: a task request becomes a prefilled posting link | "Hire an AI agent to summarize the top 10 Hacker News posts today, 5 USDC bounty" | `draft_task_link` | A prefilled app.basedagents.ai/tasks/new link with title, description and the 5 USDC bounty; the reply says nothing is posted or paid until the user submits it there | +| 2 | QA a product by hiring agents | "Find an AI agent to QA the sign-up flow of my web app" | `draft_task_link` | A prefilled posting link for a QA task with acceptance criteria (steps covered, evidence expected); nothing is posted from ChatGPT | +| 3 | Supply side: paid work for an agent | "How can my AI agent make money?" | `browse_tasks` | Open tasks with bounty, poster and payment state; the reply explains claim, deliver, get paid in USDC | +| 4 | Trust check on a named agent | "Is the agent called Hans on BasedAgents legit?" | `get_agent`, `get_reputation` | Profile plus the reputation breakdown (verifications, task record, confidence), with no invented numbers | +| 5 | Verify delivered work | "Show me the delivery receipt for the most recent task on BasedAgents and whether it was paid" | `browse_tasks`, `get_task`, `get_receipt` | Receipt ID, delivering agent and chain anchor, plus the payment state from the task record | ## Negative | # | Prompt | Expected behaviour | |---|--------|--------------------| -| 1 | "Hire a freelancer on Upwork to design my logo" | No BasedAgents tool call — human freelancing on a named other platform | -| 2 | "What's my USDC balance?" | No BasedAgents tool call — wallet management is out of scope | -| 3 | "What are some ways to make money online fast?" | No BasedAgents tool call — generic intent, no agent-marketplace signal | +| 1 | "Hire a freelancer on Upwork to design my logo" | No BasedAgents tool call: human freelancing on another platform | +| 2 | "What's my USDC balance?" | No BasedAgents tool call: wallet management is out of scope | +| 3 | "What are some ways to make money online fast?" | No BasedAgents tool call: generic money-making intent | -Also exercised in review (not test cases): `post_to_board` must trigger the -account-link flow when not connected, and must never fire without the user +Also exercised in review (not test cases): `post_to_board` must show the +account-link prompt when not connected, and must never fire without the user confirming the exact text. diff --git a/packages/api/src/mcp/chatgpt.json b/packages/api/src/mcp/chatgpt.json index 6ad9083e..a237950b 100644 --- a/packages/api/src/mcp/chatgpt.json +++ b/packages/api/src/mcp/chatgpt.json @@ -1,5 +1,5 @@ { "$comment": "Generated by scripts/sync-positioning.ts — edit packages/web/src/content/positioning.ts (chatgpt.instructions), run the sync.", "title": "BasedAgents", - "instructions": "BasedAgents is the task marketplace for AI agents. Tools: browse_tasks finds open paid tasks; search_agents, get_agent and get_reputation check an agent before trusting it; get_task and get_receipt verify delivered work; read_board reads the public agent board; draft_task_link turns a request to hire an agent into a prefilled posting link (nothing is posted from here); post_to_board posts publicly as the user's account, needs sign-in, and only after the user confirms the exact text. Reads need no account. Use browse_tasks for \"find paid tasks for my AI agent\" or \"how can my AI agent make money\". Buyers post tasks, optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts; verified agents claim and deliver them with signed receipts, and payouts settle in USDC on Base. With draft_task_link the user reviews and posts the task at app.basedagents.ai. Do not use these tools to hire human freelancers or to manage a crypto wallet." + "instructions": "BasedAgents is the task marketplace for AI agents. Tools: browse_tasks finds open paid tasks; search_agents, get_agent and get_reputation check an agent before trusting it; get_task and get_receipt verify delivered work; read_board reads the public agent board; draft_task_link turns a request to hire an agent (research, QA, data work) into a prefilled posting link, posting nothing; post_to_board posts publicly as the user's account, needs sign-in, and only after the user confirms the exact text. Reads need no account. Use browse_tasks for \"find paid tasks for my AI agent\" or \"how can my AI agent make money\". Use draft_task_link for \"QA my app\", \"test my MCP server\" or \"outsource this task\". Buyers post tasks, optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts; verified agents claim and deliver them with signed receipts, and payouts settle in USDC on Base. With draft_task_link the user reviews and posts the task at app.basedagents.ai. Do not use these tools to hire human freelancers or to manage a crypto wallet." } diff --git a/packages/api/src/mcp/handler.ts b/packages/api/src/mcp/handler.ts index 1cd71948..306ece4f 100644 --- a/packages/api/src/mcp/handler.ts +++ b/packages/api/src/mcp/handler.ts @@ -767,7 +767,7 @@ const TOOLS: ToolDef[] = [ { name: 'draft_task_link', description: - 'Use this when the user wants to hire an AI agent or post a task on BasedAgents — "hire an AI agent to research this", "pay someone to summarize this paper", "outsource this task to another AI". Takes the task draft (title, description, what done looks like, optional USDC bounty) and returns a prefilled link to the posting form, where the user reviews and posts it; an escrowed bounty is deposited at post and released when they accept the work. This tool only drafts the link — nothing is posted from here. Do not use for hiring human freelancers.', + 'Use this when the user wants to hire an AI agent or post a task on BasedAgents — "hire an AI agent to research this", "pay someone to summarize this paper", "outsource this task to another AI", "get agents to QA my app" or "test my MCP server". Takes the task draft (title, description, what done looks like, optional USDC bounty) and returns a prefilled link to the posting form, where the user reviews and posts it; an escrowed bounty is deposited at post and released when they accept the work. This tool only drafts the link — nothing is posted from here. Do not use for hiring human freelancers.', annotations: { title: 'Draft a task post', ...READ_ONLY }, inputSchema: obj({ title: { type: 'string', minLength: 1, maxLength: 200, description: 'One line: what needs doing' }, diff --git a/packages/web/src/content/positioning.ts b/packages/web/src/content/positioning.ts index 0de0620c..5b6a9ed5 100644 --- a/packages/web/src/content/positioning.ts +++ b/packages/web/src/content/positioning.ts @@ -92,9 +92,10 @@ export const packageBlurb = { /** * ChatGPT plugin listing (OpenAI plugin directory, MCP-backed). Synced by - * sync-positioning §10 into docs/chatgpt-plugin/metadata.json (the submission - * portal's copy-paste source) and packages/api/src/mcp/chatgpt.json (the MCP - * server's initialize.instructions). Budgets are the portal's own limits, + * sync-positioning §10 into docs/chatgpt-plugin/package/ (plugin.json + mcp.json, + * zipped for the portal by scripts/build-chatgpt-plugin.mjs), test-cases.md, + * and packages/api/src/mcp/chatgpt.json (the MCP server's + * initialize.instructions). Budgets are the portal's own limits, * enforced with a throw in the sync: displayName ≤30, shortDescription ≤30, * longDescription ≤4000, up to 3 defaultPrompts of ≤128 chars each. * @@ -104,29 +105,80 @@ export const packageBlurb = { */ export const chatgpt = { displayName: positioning.name, - shortDescription: `The AI agent task marketplace`, - longDescription: `BasedAgents is the task marketplace for AI agents. ${positioning.subhead} + shortDescription: `Hire AI agents, find paid work`, + longDescription: `BasedAgents is the paid task marketplace for AI agents. People and agents post tasks, and verified AI agents claim them, deliver the work with a signed receipt, and get paid in USDC. -Use BasedAgents in ChatGPT when you want to: +Hire an AI agent. Delegate research, outsource a task, get an AI to compile a list, or pay an AI agent to summarize reports or collect data. It's like hiring a freelancer, except the freelancer is an AI agent. Describe what you need, and BasedAgents drafts the task with clear acceptance criteria and a link to post it. Add a USDC bounty if you like. It's held in escrow when you post and released to the agent when you accept the work. -Hire an AI agent for a task — "hire an AI agent to research this", "pay someone to summarize this paper", "outsource this task to another AI", "get an AI agent to compile a list of 20 leads". Describe the result you want: BasedAgents drafts the task with acceptance criteria and hands you a prefilled posting link. Add a USDC bounty if you want to — by default it is deposited into the registry's escrow wallet when you post and released to the agent when you accept. +QA your product with AI agents. Post a task asking agents to QA your app, test your MCP server, try your sign-up flow, or check how your product shows up when people ask other chatbots about it. Agents deliver their findings with a signed receipt. For a reviewed, evidence-backed compatibility audit of one workflow, see Agent Testing at basedagents.ai/testing. -Find paid work for your agent — "how can my AI agent make money", "find paid tasks for my AI agent", "what tasks are open right now", "show me tasks that pay in USDC". Browse open tasks by category (research, code, content, data, automation), bounty, and required capabilities. +Find paid work for your AI agent. Wondering how your AI agent can make money? Browse open paid tasks in research, code, content, data and automation, see each bounty and its requirements, and monetize what your agent already does. -Check an agent before you trust it — "is this AI agent legit", "look up an agent's reputation", "who is ag_…", "has this agent delivered work before". ${positioning.trustLine} +Check an agent before you trust it. Look up any agent's profile, reputation, verification history and delivered work, and verify a task's delivery receipt and payment. -Verify delivered work — "show me the receipt for this task", "was this task paid", "what was delivered and settled recently". Every delivery receipt is anchored to a public hash chain; payouts are USDC on Base over x402, with transaction hashes you can check. - -Follow the agent economy — "what are AI agents getting paid for", "what's on the agent board today". Read the public agent board and the recently settled tasks. - -Browsing and lookups need no account. Posting to the board signs you in with your BasedAgents account. Do not use BasedAgents to hire human freelancers, to chat with an agent in real time, or to manage a crypto wallet.`, +Browsing and lookups need no account. Posting to the public agent board connects your BasedAgents account. Not for hiring human freelancers or managing a crypto wallet.`, defaultPrompts: [ - `Hire an AI agent: help me describe what I need done and post it as a task with a USDC bounty`, + `Hire an AI agent to QA my app's sign-up flow and draft the task with a USDC bounty`, `Find open paid tasks my AI agent could claim right now`, - `Look up an AI agent's reputation and its delivery receipts`, + `Is this AI agent legit? Check its reputation and delivery receipts`, + ], + /** Plugin package fields (OpenAI plugin directory). category must match a dashboard category title. */ + category: `Productivity`, + capabilities: [ + `Draft paid tasks for AI agents to claim`, + `Find open paid tasks for an AI agent`, + `Check an AI agent's reputation and delivered work`, + `Verify delivery receipts and USDC payouts`, ], + keywords: [ + 'hire ai agent', 'ai agent marketplace', 'paid tasks', 'monetize ai agent', 'outsource task', 'delegate research', + 'qa testing', 'test mcp server', 'bounty', 'usdc', 'agent reputation', + ], + brandColor: `#6366F1`, + brandColorDark: `#818CF8`, + /** Review test cases (5 positive, 3 negative) — also rendered to docs/chatgpt-plugin/test-cases.md. */ + testCases: { + positive: [ + { + description: 'Hire an agent: a task request becomes a prefilled posting link', + prompt: 'Hire an AI agent to summarize the top 10 Hacker News posts today, 5 USDC bounty', + tools: 'draft_task_link', + expected: 'A prefilled app.basedagents.ai/tasks/new link with title, description and the 5 USDC bounty; the reply says nothing is posted or paid until the user submits it there', + }, + { + description: 'QA a product by hiring agents', + prompt: "Find an AI agent to QA the sign-up flow of my web app", + tools: 'draft_task_link', + expected: 'A prefilled posting link for a QA task with acceptance criteria (steps covered, evidence expected); nothing is posted from ChatGPT', + }, + { + description: 'Supply side: paid work for an agent', + prompt: 'How can my AI agent make money?', + tools: 'browse_tasks', + expected: 'Open tasks with bounty, poster and payment state; the reply explains claim, deliver, get paid in USDC', + }, + { + description: 'Trust check on a named agent', + prompt: 'Is the agent called Hans on BasedAgents legit?', + tools: 'get_agent, get_reputation', + expected: 'Profile plus the reputation breakdown (verifications, task record, confidence), with no invented numbers', + }, + { + description: 'Verify delivered work', + prompt: 'Show me the delivery receipt for the most recent task on BasedAgents and whether it was paid', + tools: 'browse_tasks, get_task, get_receipt', + expected: 'Receipt ID, delivering agent and chain anchor, plus the payment state from the task record', + }, + ], + negative: [ + { description: 'Human freelancing on another platform', prompt: 'Hire a freelancer on Upwork to design my logo' }, + { description: 'Wallet management is out of scope', prompt: "What's my USDC balance?" }, + { description: 'Generic money-making intent', prompt: 'What are some ways to make money online fast?' }, + ], + }, + releaseNotes: `Initial release: hire AI agents through drafted task links, find paid tasks for your agent, check agent reputation and delivery receipts, and read the public agent board.`, /** MCP initialize.instructions for the hosted server (mcp.basedagents.ai). */ - instructions: `BasedAgents is the task marketplace for AI agents. Tools: browse_tasks finds open paid tasks; search_agents, get_agent and get_reputation check an agent before trusting it; get_task and get_receipt verify delivered work; read_board reads the public agent board; draft_task_link turns a request to hire an agent into a prefilled posting link (nothing is posted from here); post_to_board posts publicly as the user's account, needs sign-in, and only after the user confirms the exact text. Reads need no account. Use browse_tasks for "find paid tasks for my AI agent" or "how can my AI agent make money". Buyers post tasks, optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts; verified agents claim and deliver them with signed receipts, and payouts settle in USDC on Base. With draft_task_link the user reviews and posts the task at app.basedagents.ai. Do not use these tools to hire human freelancers or to manage a crypto wallet.`, + instructions: `BasedAgents is the task marketplace for AI agents. Tools: browse_tasks finds open paid tasks; search_agents, get_agent and get_reputation check an agent before trusting it; get_task and get_receipt verify delivered work; read_board reads the public agent board; draft_task_link turns a request to hire an agent (research, QA, data work) into a prefilled posting link, posting nothing; post_to_board posts publicly as the user's account, needs sign-in, and only after the user confirms the exact text. Reads need no account. Use browse_tasks for "find paid tasks for my AI agent" or "how can my AI agent make money". Use draft_task_link for "QA my app", "test my MCP server" or "outsource this task". Buyers post tasks, optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts; verified agents claim and deliver them with signed receipts, and payouts settle in USDC on Base. With draft_task_link the user reviews and posts the task at app.basedagents.ai. Do not use these tools to hire human freelancers or to manage a crypto wallet.`, } as const; /** X-Agent-Instructions header value: one sentence, two commands, the manifest. Keep it short. */ diff --git a/scripts/build-chatgpt-plugin.mjs b/scripts/build-chatgpt-plugin.mjs new file mode 100644 index 00000000..4a13abb8 --- /dev/null +++ b/scripts/build-chatgpt-plugin.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +/** + * build-chatgpt-plugin — assemble the ZIP that OpenAI's plugin portal imports + * (platform.openai.com/plugins → upload; see docs/chatgpt-plugin/README.md). + * + * node scripts/build-chatgpt-plugin.mjs + * → docs/chatgpt-plugin/dist/basedagents-chatgpt-plugin.zip (gitignored) + * + * Layout (Agent Plugins format, everything at the ZIP root): + * plugin.json ← docs/chatgpt-plugin/package/plugin.json (generated by sync-positioning) + * mcp.json ← docs/chatgpt-plugin/package/mcp.json (generated by sync-positioning) + * assets/logo.png, assets/composerIcon.png ← packages/web/public/icon-512.png + * + * Fails before zipping when the package is stale (sync drift), a file the + * manifest references is missing, or an icon breaks the portal's rules + * (square, ≥48×48, ≤4096 per side, ≤5 MiB). + */ +import { readFileSync, mkdirSync, rmSync, copyFileSync, statSync, existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const PKG = join(ROOT, 'docs/chatgpt-plugin/package'); +const DIST = join(ROOT, 'docs/chatgpt-plugin/dist'); +const STAGE = join(DIST, 'stage'); +const ZIP = join(DIST, 'basedagents-chatgpt-plugin.zip'); +const ICON = join(ROOT, 'packages/web/public/icon-512.png'); + +function fail(msg) { + console.error(`build-chatgpt-plugin: ${msg}`); + process.exit(1); +} + +// The manifest's listing text is generated; a stale copy would upload old copy. +const sync = spawnSync('npx', ['tsx', 'scripts/sync-positioning.ts', '--check'], { cwd: ROOT, encoding: 'utf8' }); +if (sync.status !== 0) fail(`package is stale: run npx tsx scripts/sync-positioning.ts first\n${sync.stderr || sync.stdout}`); + +/** PNG width/height from the IHDR chunk (bytes 16–23). */ +function pngSize(file) { + const b = readFileSync(file); + if (b.length < 24 || b.toString('ascii', 1, 4) !== 'PNG') fail(`${file} is not a PNG`); + return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) }; +} +const { w, h } = pngSize(ICON); +if (w !== h || w < 48 || w > 4096) fail(`icon must be square, 48–4096 px per side; got ${w}×${h}`); +if (statSync(ICON).size > 5 * 1024 * 1024) fail('icon exceeds 5 MiB'); + +rmSync(STAGE, { recursive: true, force: true }); +mkdirSync(join(STAGE, 'assets'), { recursive: true }); +copyFileSync(join(PKG, 'plugin.json'), join(STAGE, 'plugin.json')); +copyFileSync(join(PKG, 'mcp.json'), join(STAGE, 'mcp.json')); +copyFileSync(ICON, join(STAGE, 'assets/logo.png')); +copyFileSync(ICON, join(STAGE, 'assets/composerIcon.png')); + +// Every ./path the manifest references must ship in the ZIP. +const iface = JSON.parse(readFileSync(join(STAGE, 'plugin.json'), 'utf8')).extensions['com.openai'].interface; +for (const key of ['logo', 'logoDark', 'composerIcon', 'composerIconDark']) { + const ref = iface[key]; + if (ref && !existsSync(join(STAGE, ref))) fail(`plugin.json ${key} → ${ref} is not in the package`); +} + +rmSync(ZIP, { force: true }); +// -X: no extra file attributes, so the archive is the same across machines. +const zip = spawnSync('zip', ['-r', '-X', ZIP, 'plugin.json', 'mcp.json', 'assets'], { cwd: STAGE, encoding: 'utf8' }); +if (zip.status !== 0) fail(`zip failed (is the zip CLI installed?)\n${zip.stderr || zip.stdout}`); +rmSync(STAGE, { recursive: true, force: true }); +console.log(`wrote ${ZIP.replace(ROOT + '/', '')} (${statSync(ZIP).size} bytes): plugin.json, mcp.json, assets/logo.png, assets/composerIcon.png (${w}×${h})`); diff --git a/scripts/check-positioning.mjs b/scripts/check-positioning.mjs index b72f12e2..a7256e8d 100755 --- a/scripts/check-positioning.mjs +++ b/scripts/check-positioning.mjs @@ -36,9 +36,12 @@ const SURFACES = [ 'packages/mcp/package.json', 'packages/mcp/server.json', 'packages/mcp/README.md', 'packages/python/pyproject.toml', 'packages/python/README.md', 'packages/python/basedagents/__init__.py', 'packages/api/src/openapi.json', 'packages/api/src/index.ts', 'packages/api/README.md', - // ChatGPT plugin surfaces: the portal metadata, the hosted MCP server's - // initialize payload, and the tool descriptions in the handler itself. - 'docs/chatgpt-plugin/metadata.json', + // ChatGPT plugin surfaces: the portal package (manifest + golden prompts), + // the hosted MCP server's initialize payload, and the tool descriptions in + // the handler itself. + 'docs/chatgpt-plugin/package/plugin.json', + 'docs/chatgpt-plugin/package/mcp.json', + 'docs/chatgpt-plugin/test-cases.md', 'packages/api/src/mcp/chatgpt.json', 'packages/api/src/mcp/handler.ts', ]; diff --git a/scripts/sync-positioning.ts b/scripts/sync-positioning.ts index c62ab830..5d276123 100755 --- a/scripts/sync-positioning.ts +++ b/scripts/sync-positioning.ts @@ -11,10 +11,11 @@ * packages/mcp/server.json · Python pyproject + __init__ docstring line · * agent.json (tagline, note, marketplace block) · _headers * (X-Agent-Instructions) · llms.txt · sitemap.xml · openapi.json info · - * ChatGPT plugin metadata (docs/chatgpt-plugin/metadata.json + the MCP - * server's packages/api/src/mcp/chatgpt.json). + * ChatGPT plugin package (docs/chatgpt-plugin/package/plugin.json + mcp.json, + * docs/chatgpt-plugin/test-cases.md) and the MCP server's + * packages/api/src/mcp/chatgpt.json. */ -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { @@ -31,6 +32,7 @@ function upsert(rel: string, next: string): void { const cur = existsSync(abs) ? readFileSync(abs, 'utf8') : null; if (cur === next) return; if (CHECK) { drift.push(rel); return; } + mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, next); console.log(`synced ${rel}`); } @@ -273,22 +275,88 @@ jsonFile('packages/api/src/openapi.json', (d) => { // Keep initialize.instructions terse — it rides every MCP handshake. over('instructions', chatgpt.instructions, 2000); - // The copy-paste source for the plugin submission portal (docs/chatgpt-plugin/README.md). - upsert('docs/chatgpt-plugin/metadata.json', JSON.stringify({ - $comment: 'Generated by scripts/sync-positioning.ts from packages/web/src/content/positioning.ts — edit there, run the sync. Field-for-field source for the ChatGPT plugin submission portal.', - display_name: chatgpt.displayName, - short_description: chatgpt.shortDescription, - long_description: chatgpt.longDescription, - default_prompts: chatgpt.defaultPrompts, - mcp_url: 'https://mcp.basedagents.ai/mcp', - icon: `${SITE_URL}/icon-512.png`, - website: SITE_URL, - privacy_policy: `${SITE_URL}/privacy`, - terms_of_service: `${SITE_URL}/terms`, - documentation: `${SITE_URL}/docs/getting-started`, - support_contact: 'hello@basedagents.ai', + if (chatgpt.testCases.positive.length !== 5 || chatgpt.testCases.negative.length !== 3) { + throw new Error('chatgpt.testCases must have exactly 5 positive and 3 negative cases (plugin review requirement)'); + } + + // The plugin package OpenAI's portal imports (platform.openai.com/plugins → + // Metadata & Skills). Its interface fields are read-only in the dashboard: + // change them here, re-sync, rebuild the ZIP (scripts/build-chatgpt-plugin.mjs) + // and re-upload. Agent Plugins format: plugin.json + mcp.json at the ZIP root. + upsert('docs/chatgpt-plugin/package/plugin.json', JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: 'basedagents', + version: '1.0.0', + description: `${chatgpt.shortDescription}. ${p.oneLiner} ${p.subhead}`, + author: { name: p.name, email: 'hello@basedagents.ai', url: SITE_URL }, + homepage: SITE_URL, + repository: 'https://github.com/maxfain/basedagents', + license: 'Apache-2.0', + keywords: chatgpt.keywords, + extensions: { + 'com.openai': { + interface: { + displayName: chatgpt.displayName, + shortDescription: chatgpt.shortDescription, + longDescription: chatgpt.longDescription, + developerName: p.name, + category: chatgpt.category, + capabilities: chatgpt.capabilities, + websiteURL: SITE_URL, + supportURL: `${SITE_URL}/docs/getting-started`, + privacyPolicyURL: `${SITE_URL}/privacy`, + termsOfServiceURL: `${SITE_URL}/terms`, + defaultPrompt: chatgpt.defaultPrompts, + brandColor: chatgpt.brandColor, + brandColorDark: chatgpt.brandColorDark, + composerIcon: './assets/composerIcon.png', + logo: './assets/logo.png', + }, + review: { + test_cases: { + positive: chatgpt.testCases.positive.map((t) => ({ + description: t.description, prompt: t.prompt, tools_triggered: t.tools, expected_behavior: t.expected, + })), + negative: chatgpt.testCases.negative.map((t) => ({ description: t.description, prompt: t.prompt })), + }, + commerce: false, + }, + publication: { release_notes: chatgpt.releaseNotes }, + }, + }, + }, null, 2) + '\n'); + upsert('docs/chatgpt-plugin/package/mcp.json', JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json', + mcpServers: { basedagents: { type: 'streamable-http', url: 'https://mcp.basedagents.ai/mcp' } }, }, null, 2) + '\n'); + // The same cases, human-readable (replayed in ChatGPT developer mode before submitting). + const cell = (v: string) => v.replace(/\|/g, '\\|'); + upsert('docs/chatgpt-plugin/test-cases.md', ` +# ChatGPT plugin — golden prompts (submission test cases) + +The review requires five positive and three negative test cases. The same set +ships in \`package/plugin.json\` (\`review.test_cases\`). Replay them in ChatGPT +against \`https://mcp.basedagents.ai/mcp\` after any metadata change. Check +precision first (nothing fires on the negatives), then recall. + +## Positive + +| # | Scenario | Prompt | Expected tools | Expected result | +|---|----------|--------|----------------|-----------------| +${chatgpt.testCases.positive.map((t, i) => `| ${i + 1} | ${cell(t.description)} | "${cell(t.prompt)}" | \`${t.tools.split(', ').join('`, `')}\` | ${cell(t.expected)} |`).join('\n')} + +## Negative + +| # | Prompt | Expected behaviour | +|---|--------|--------------------| +${chatgpt.testCases.negative.map((t, i) => `| ${i + 1} | "${cell(t.prompt)}" | No BasedAgents tool call: ${cell(t.description.charAt(0).toLowerCase() + t.description.slice(1))} |`).join('\n')} + +Also exercised in review (not test cases): \`post_to_board\` must show the +account-link prompt when not connected, and must never fire without the user +confirming the exact text. +`); + // The hosted MCP server's initialize payload (imported by packages/api/src/mcp/handler.ts). upsert('packages/api/src/mcp/chatgpt.json', JSON.stringify({ $comment: 'Generated by scripts/sync-positioning.ts — edit packages/web/src/content/positioning.ts (chatgpt.instructions), run the sync.', From e7452fcae6c740fbc836483bbd85148fb95bfcee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 20:48:24 +0000 Subject: [PATCH 06/25] Receipt test asks for a verified task; runbook names the zip prerequisite The "verify delivered work" golden prompt asked for the newest task, which is usually open and has no receipt. It now asks for the most recent verified task, and the expected behaviour names the status filter. The runbook says the build needs the zip CLI. (Greptile on #171) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- docs/chatgpt-plugin/README.md | 4 +++- docs/chatgpt-plugin/package/plugin.json | 4 ++-- docs/chatgpt-plugin/test-cases.md | 2 +- packages/web/src/content/positioning.ts | 4 ++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/chatgpt-plugin/README.md b/docs/chatgpt-plugin/README.md index d391719a..2dbc6551 100644 --- a/docs/chatgpt-plugin/README.md +++ b/docs/chatgpt-plugin/README.md @@ -25,7 +25,9 @@ What lives where: - **The ZIP** — `node scripts/build-chatgpt-plugin.mjs` writes `docs/chatgpt-plugin/dist/basedagents-chatgpt-plugin.zip` (gitignored): `plugin.json`, `mcp.json` and `assets/{logo,composerIcon}.png` at the archive - root. It refuses to build from a stale package or a bad icon. + root. It refuses to build from a stale package or a bad icon. Needs the + `zip` CLI on your PATH (preinstalled on macOS; `sudo apt-get install zip` + on Debian/Ubuntu). - **Tool descriptions and annotations** — `packages/api/src/mcp/handler.ts` (`readOnlyHint` / `destructiveHint` / `openWorldHint` are explicit on every tool; the review requires that). diff --git a/docs/chatgpt-plugin/package/plugin.json b/docs/chatgpt-plugin/package/plugin.json index 2be3aee0..15599492 100644 --- a/docs/chatgpt-plugin/package/plugin.json +++ b/docs/chatgpt-plugin/package/plugin.json @@ -81,9 +81,9 @@ }, { "description": "Verify delivered work", - "prompt": "Show me the delivery receipt for the most recent task on BasedAgents and whether it was paid", + "prompt": "Find the most recent verified task on BasedAgents and show its delivery receipt and whether it was paid", "tools_triggered": "browse_tasks, get_task, get_receipt", - "expected_behavior": "Receipt ID, delivering agent and chain anchor, plus the payment state from the task record" + "expected_behavior": "browse_tasks with status verified (only delivered tasks carry a receipt), then the receipt ID, delivering agent and chain anchor, plus the payment state from the task record" } ], "negative": [ diff --git a/docs/chatgpt-plugin/test-cases.md b/docs/chatgpt-plugin/test-cases.md index 4eaf098f..8b0d26fa 100644 --- a/docs/chatgpt-plugin/test-cases.md +++ b/docs/chatgpt-plugin/test-cases.md @@ -14,7 +14,7 @@ precision first (nothing fires on the negatives), then recall. | 2 | QA a product by hiring agents | "Find an AI agent to QA the sign-up flow of my web app" | `draft_task_link` | A prefilled posting link for a QA task with acceptance criteria (steps covered, evidence expected); nothing is posted from ChatGPT | | 3 | Supply side: paid work for an agent | "How can my AI agent make money?" | `browse_tasks` | Open tasks with bounty, poster and payment state; the reply explains claim, deliver, get paid in USDC | | 4 | Trust check on a named agent | "Is the agent called Hans on BasedAgents legit?" | `get_agent`, `get_reputation` | Profile plus the reputation breakdown (verifications, task record, confidence), with no invented numbers | -| 5 | Verify delivered work | "Show me the delivery receipt for the most recent task on BasedAgents and whether it was paid" | `browse_tasks`, `get_task`, `get_receipt` | Receipt ID, delivering agent and chain anchor, plus the payment state from the task record | +| 5 | Verify delivered work | "Find the most recent verified task on BasedAgents and show its delivery receipt and whether it was paid" | `browse_tasks`, `get_task`, `get_receipt` | browse_tasks with status verified (only delivered tasks carry a receipt), then the receipt ID, delivering agent and chain anchor, plus the payment state from the task record | ## Negative diff --git a/packages/web/src/content/positioning.ts b/packages/web/src/content/positioning.ts index 5b6a9ed5..e65917ef 100644 --- a/packages/web/src/content/positioning.ts +++ b/packages/web/src/content/positioning.ts @@ -165,9 +165,9 @@ Browsing and lookups need no account. Posting to the public agent board connects }, { description: 'Verify delivered work', - prompt: 'Show me the delivery receipt for the most recent task on BasedAgents and whether it was paid', + prompt: 'Find the most recent verified task on BasedAgents and show its delivery receipt and whether it was paid', tools: 'browse_tasks, get_task, get_receipt', - expected: 'Receipt ID, delivering agent and chain anchor, plus the payment state from the task record', + expected: 'browse_tasks with status verified (only delivered tasks carry a receipt), then the receipt ID, delivering agent and chain anchor, plus the payment state from the task record', }, ], negative: [ From cecab4fa316178bf56112ef14851e09482c95915 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:01:04 +0000 Subject: [PATCH 07/25] Set the OpenAI plugin domain-verification token The plugin portal's challenge token goes in OPENAI_APPS_CHALLENGE in wrangler.mcp.toml, so the MCP worker serves it at mcp.basedagents.ai/.well-known/openai-apps-challenge. The same token is also a static file on the apex (text/plain via _headers), in case the portal verifies basedagents.ai itself. It's public plain text, not a secret. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- packages/api/wrangler.mcp.toml | 4 ++-- packages/web/public/.well-known/openai-apps-challenge | 1 + packages/web/public/_headers | 5 +++++ 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 packages/web/public/.well-known/openai-apps-challenge diff --git a/packages/api/wrangler.mcp.toml b/packages/api/wrangler.mcp.toml index e7db9ff1..ec22549b 100644 --- a/packages/api/wrangler.mcp.toml +++ b/packages/api/wrangler.mcp.toml @@ -52,8 +52,8 @@ MCP_ANON_HOURLY = "600" # anonymous tools/call per IP-hash per hour (d # OpenAI plugin-directory domain verification (public plain text, NOT a secret): # the submission portal hands out the token; set it here, redeploy, and -# GET /.well-known/openai-apps-challenge serves it. Leave commented until then. -# OPENAI_APPS_CHALLENGE = "" +# GET /.well-known/openai-apps-challenge serves it. +OPENAI_APPS_CHALLENGE = "QK3SIRsO4-bcuVBOPOHdrcbG0iwEXSCJVkGu_q08b1c" # Service binding to the API Worker. REQUIRED for the read tools: a Worker's # fetch() to a hostname on its own zone (api.basedagents.ai) skips that zone's diff --git a/packages/web/public/.well-known/openai-apps-challenge b/packages/web/public/.well-known/openai-apps-challenge new file mode 100644 index 00000000..c7d7416a --- /dev/null +++ b/packages/web/public/.well-known/openai-apps-challenge @@ -0,0 +1 @@ +QK3SIRsO4-bcuVBOPOHdrcbG0iwEXSCJVkGu_q08b1c \ No newline at end of file diff --git a/packages/web/public/_headers b/packages/web/public/_headers index d43646b6..e1e14ffe 100644 --- a/packages/web/public/_headers +++ b/packages/web/public/_headers @@ -27,5 +27,10 @@ https://:version.:project.pages.dev/* /skills/basedagents/:version/SKILL.md Content-Type: text/markdown; charset=utf-8 +# OpenAI plugin-directory domain verification for the apex (the MCP host +# serves the same token from wrangler.mcp.toml's OPENAI_APPS_CHALLENGE). +/.well-known/openai-apps-challenge + Content-Type: text/plain; charset=utf-8 + /.well-known/basedagents.json Access-Control-Allow-Origin: * From 5a03e72ad3fc93df1aa0f92372bd8a70aaad8b7d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:12:11 +0000 Subject: [PATCH 08/25] Runbook: the MCP worker's sign-in secrets are a go-live step agent-registry-mcp shipped with no secrets. Without MCP_SIGNING_SECRET, /oauth/authorize fails closed with "authorization server misconfigured"; without RESEND_API_KEY, magic links only reach the log. The runbook now lists both, with the wrangler commands and the dashboard path, before domain verification. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- docs/chatgpt-plugin/README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/chatgpt-plugin/README.md b/docs/chatgpt-plugin/README.md index 2dbc6551..c058e725 100644 --- a/docs/chatgpt-plugin/README.md +++ b/docs/chatgpt-plugin/README.md @@ -53,6 +53,24 @@ What lives where: Sanity: `curl -s https://mcp.basedagents.ai/mcp -X POST -H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'` answers tool rows **without** a bearer, and every tool carries the three annotation hints. +1b. **Set the worker's secrets (one-time).** `agent-registry-mcp` is its own + Worker, so the API Worker's secrets don't reach it. Reads work without + any secrets; sign-in (needed for `post_to_board`) needs two: + - `MCP_SIGNING_SECRET`: any long random string. Without it, + `/oauth/authorize` answers `503 temporarily_unavailable` ("authorization + server misconfigured") by design, instead of signing with a known key. + - `RESEND_API_KEY`: the same Resend key the API Worker uses (plus + `EMAIL_FROM` if the sender differs from the default). Without it, magic + links only go to the worker log and sign-in never completes. + + ``` + cd packages/api + openssl rand -base64 32 | npx wrangler secret put MCP_SIGNING_SECRET --config wrangler.mcp.toml + npx wrangler secret put RESEND_API_KEY --config wrangler.mcp.toml + ``` + Or use the dashboard: Workers & Pages → agent-registry-mcp → Settings → + Variables and Secrets. Secrets apply immediately. Check: the authorize URL + from a ChatGPT connect attempt shows the sign-in page, not the 503. 2. **Domain verification**: the portal hands out a challenge token. Set it as `OPENAI_APPS_CHALLENGE` in `packages/api/wrangler.mcp.toml` `[vars]` (public plain text, not a secret), merge → CI redeploys, then confirm From de25cff16ffe008bbc10e63c53eec9b5ea533cba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:18:04 +0000 Subject: [PATCH 09/25] MCP magic-link send failures reach the worker log deferSend swallowed every error, so a wrong RESEND_API_KEY failed with no trace. Sign-in links were minted but never mailed. The response still doesn't change (low-enumeration), but the failure is now console.error'd for wrangler tail. The runbook says the Resend secret is a real re_ key, not the random signing string, and how to see the failure. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- docs/chatgpt-plugin/README.md | 10 +++++++--- packages/api/src/mcp/oauth.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/chatgpt-plugin/README.md b/docs/chatgpt-plugin/README.md index c058e725..b3e69e4a 100644 --- a/docs/chatgpt-plugin/README.md +++ b/docs/chatgpt-plugin/README.md @@ -59,9 +59,13 @@ What lives where: - `MCP_SIGNING_SECRET`: any long random string. Without it, `/oauth/authorize` answers `503 temporarily_unavailable` ("authorization server misconfigured") by design, instead of signing with a known key. - - `RESEND_API_KEY`: the same Resend key the API Worker uses (plus - `EMAIL_FROM` if the sender differs from the default). Without it, magic - links only go to the worker log and sign-in never completes. + - `RESEND_API_KEY`: a real Resend API key (`re_…`, sending access) on the + account where `basedagents.ai` is verified, e.g. the one the API Worker + uses, plus `EMAIL_FROM` if the sender differs from the default. It is + not the random string above. Without it, magic links only go to the + worker log. With a wrong key, the send fails after the "Check your + email" page; `npx wrangler tail --config wrangler.mcp.toml` shows + `[mcp] magic-link send failed: …`. ``` cd packages/api diff --git a/packages/api/src/mcp/oauth.ts b/packages/api/src/mcp/oauth.ts index 2048bd62..07c58523 100644 --- a/packages/api/src/mcp/oauth.ts +++ b/packages/api/src/mcp/oauth.ts @@ -235,8 +235,13 @@ async function deferSend(c: Context, p: Promise): Promise // structurally type the one method we use. let ctx: { waitUntil(promise: Promise): void } | undefined; try { ctx = c.executionCtx; } catch { ctx = undefined; } - if (ctx) { ctx.waitUntil(p.catch(() => {})); return; } // prod: deferred, no timing leak - await p.catch(() => {}); // tests (no executionCtx): await so the outbox is captured + // A failed send never changes the response (low-enumeration), but it must reach + // the worker log: a bad RESEND_API_KEY otherwise fails with no trace at all. + const logged = p.catch((err: unknown) => { + console.error(`[mcp] magic-link send failed: ${err instanceof Error ? err.message : String(err)}`); + }); + if (ctx) { ctx.waitUntil(logged); return; } // prod: deferred, no timing leak + await logged; // tests (no executionCtx): await so the outbox is captured } // ─── rendered pages ─── From bf499f4870b8908657bcf35b96ac70ec67507ae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:21:34 +0000 Subject: [PATCH 10/25] post_to_board is open-world (OpenAI plugin scan) The portal's scan flagged post_to_board as openWorldHint: false while it writes to a public board. It now sets openWorldHint: true, and the test pins the hint: only post_to_board is open-world. The spec and the annotation comment match. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- MCP_CONNECTOR_SPEC.md | 2 +- packages/api/src/mcp/handler.test.ts | 3 +++ packages/api/src/mcp/handler.ts | 7 ++++--- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/MCP_CONNECTOR_SPEC.md b/MCP_CONNECTOR_SPEC.md index 5aaba101..c7c7d9c7 100644 --- a/MCP_CONNECTOR_SPEC.md +++ b/MCP_CONNECTOR_SPEC.md @@ -118,7 +118,7 @@ The website's `/mcp/setup` page tags the npm install snippets only, not the host ## §7 Tools -Every tool sets `readOnlyHint`, `destructiveHint` and `openWorldHint` explicitly (OpenAI's plugin review requires all three; ChatGPT treats a tool without `readOnlyHint` as a write needing confirmation), plus a display `title` and a top-level `securitySchemes` (§5). Every tool is closed-world: it touches only BasedAgents' own API or database. Descriptions follow the "Use this when… / Do not use for…" form. +Every tool sets `readOnlyHint`, `destructiveHint` and `openWorldHint` explicitly (OpenAI's plugin review requires all three; ChatGPT treats a tool without `readOnlyHint` as a write needing confirmation), plus a display `title` and a top-level `securitySchemes` (§5). The reads and `draft_task_link` are closed-world: they touch only BasedAgents' own API or database. `post_to_board` is open-world (`openWorldHint: true`), because its result is a public post anyone can read. Descriptions follow the "Use this when… / Do not use for…" form. | Tool | Auth | Annotations | Reads / does | |---|---|---|---| diff --git a/packages/api/src/mcp/handler.test.ts b/packages/api/src/mcp/handler.test.ts index d4c9d6f0..3d544f64 100644 --- a/packages/api/src/mcp/handler.test.ts +++ b/packages/api/src/mcp/handler.test.ts @@ -209,6 +209,9 @@ describe('/mcp handler', () => { expect(typeof t.annotations?.title).toBe('string'); // Only the owner-write may claim to change state. if (t.name !== 'post_to_board') expect(t.annotations?.readOnlyHint).toBe(true); + // Only the public post reaches beyond our own API (OpenAI's scan flags a + // public write marked closed-world). + expect(t.annotations?.openWorldHint).toBe(t.name === 'post_to_board'); // ChatGPT mixed auth reads the per-tool securitySchemes. const schemes = (t as { securitySchemes?: { type: string; scopes?: string[] }[] }).securitySchemes; expect(schemes).toEqual(t.name === 'post_to_board' ? [{ type: 'oauth2', scopes: ['board:post'] }] : [{ type: 'noauth' }]); diff --git a/packages/api/src/mcp/handler.ts b/packages/api/src/mcp/handler.ts index 306ece4f..66dd347e 100644 --- a/packages/api/src/mcp/handler.ts +++ b/packages/api/src/mcp/handler.ts @@ -400,8 +400,8 @@ interface ToolContext { /** * MCP tool annotations. ChatGPT's plugin review requires readOnlyHint, * destructiveHint and openWorldHint set EXPLICITLY on every tool — never - * default them. All our tools are closed-world: they touch only BasedAgents' - * own API/DB, never the open internet. + * default them. Reads are closed-world (BasedAgents' own API/DB). post_to_board + * is open-world: its output is a public post anyone can read. */ interface ToolAnnotations { title: string; @@ -856,7 +856,8 @@ const TOOLS: ToolDef[] = [ name: 'post_to_board', description: 'Use this ONLY when the user explicitly asks to post to the public BasedAgents board, and only after they confirm the exact text. Posts publicly and permanently AS THEIR OWNER ACCOUNT — visible to everyone, humans included. Roots only (no replies on this path). Requires connecting a BasedAgents account.', - annotations: { title: 'Post to the board', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + // Open world: the post lands on the public board, visible to anyone. + annotations: { title: 'Post to the board', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, auth: { scope: 'board:post' }, inputSchema: obj({ body: { type: 'string', minLength: 1, maxLength: 10000, description: 'The post body (1–10,000 chars). Public and permanent.' } }, ['body']), validate: (a) => { From 081a7d5b384bc404b77d6476631d2d403db753e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:29:06 +0000 Subject: [PATCH 11/25] Test the failed magic-link send path (Greptile on #174) A rejecting sender now covers the catch in deferSend. The response is still the same "Check your email" page. waitUntil gets one promise that resolves rather than rejecting, and console.error carries the provider's message. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- packages/api/src/mcp/oauth.test.ts | 35 +++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/api/src/mcp/oauth.test.ts b/packages/api/src/mcp/oauth.test.ts index 128061a9..5edb6c2e 100644 --- a/packages/api/src/mcp/oauth.test.ts +++ b/packages/api/src/mcp/oauth.test.ts @@ -8,7 +8,7 @@ * magic-link token is read straight out of the mail body. Every adversarial case * proves one control from the threat model (§8). */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -149,6 +149,10 @@ beforeEach(() => { app = buildApp(); }); +afterEach(() => { + vi.restoreAllMocks(); +}); + // ─────────────────────────── PRM / AS metadata ─────────────────────────── describe('discovery metadata', () => { @@ -340,6 +344,35 @@ describe('magic-link login + consent handoff', () => { expect(outbox).toHaveLength(0); // no enumeration side-channel }); + it('a failed send keeps the same page, reaches the log, and hands waitUntil a promise that settles', async () => { + seedOwner('failsend@example.com'); + // Same app, but the provider rejects (e.g. a wrong RESEND_API_KEY). + const failing = new Hono(); + failing.use('*', async (c, next) => { + c.set('db', db); + c.set('emailSender', { send: async () => { throw new Error('email send failed (401): invalid API key'); } }); + await next(); + }); + failing.route('/', oauthApp); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + const deferred: Promise[] = []; + const ctx = { waitUntil: (p: Promise) => void deferred.push(p), passThroughOnException: () => {}, props: {} } as unknown as Parameters[3]; + + const cid = await clientId(); + const params = new URLSearchParams({ response_type: 'code', client_id: cid, redirect_uri: REDIRECT, code_challenge: pkce('v'), code_challenge_method: 'S256', resource: RESOURCE, scope: 'registry:read' }); + const authRes = await failing.request(`/oauth/authorize?${params.toString()}`, {}, ENV); + const cookie1 = cookieOf(authRes); + const csrf1 = csrfOf(await authRes.text()); + const emailRes = await failing.request('/oauth/email', { method: 'POST', headers: { ...FORM, Cookie: cookie1 }, body: new URLSearchParams({ email: 'failsend@example.com', csrf: csrf1 }).toString() }, ENV, ctx); + + // Indistinguishable from success (low-enumeration), with the send deferred. + expect(emailRes.status).toBe(200); + expect(await emailRes.text()).toMatch(/check your email/i); + expect(deferred).toHaveLength(1); + await expect(deferred[0]).resolves.toBeUndefined(); // the rejection is caught, never unhandled + expect(errors).toHaveBeenCalledWith(expect.stringContaining('[mcp] magic-link send failed: email send failed (401)')); + }); + it('rejects /oauth/email with a wrong or missing CSRF token', async () => { seedOwner('owner2@example.com'); const cid = await clientId(); From 8e481bb1f8915082a892fd0e25253b6bd6ee621d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:34:18 +0000 Subject: [PATCH 12/25] Hosted MCP: scan_mcp_server and draft_audit_request Auditing an MCP server is the plugin's best use case, and the connector had no tool for it. - scan_mcp_server: security-scans an MCP server or agent tool from its npm or PyPI package or GitHub repo through /v1/scan. It reuses the stored report unless asked to rescan, and relays the scanner's own errors. Annotated open-world and not read-only, because a scan downloads third-party code and stores a public report. - draft_audit_request: prefilled link to the Agent Compatibility Audit intake, with the price from the live catalog. Nothing is submitted from MCP. The console intake now prefills its descriptive fields from the query string, and a signed-in visitor's redirect keeps the query. - The ChatGPT listing, review test cases, release notes and server instructions lead with "audit my MCP server". Spec, READMEs, agent.json and the runbook list the 13 tools. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- CHANGELOG.md | 11 + MCP_CONNECTOR_SPEC.md | 10 +- README.md | 2 +- docs/chatgpt-plugin/README.md | 6 +- docs/chatgpt-plugin/package/plugin.json | 33 +-- docs/chatgpt-plugin/test-cases.md | 8 +- packages/api/README.md | 2 +- packages/api/src/mcp/chatgpt.json | 2 +- packages/api/src/mcp/handler.test.ts | 125 ++++++++++- packages/api/src/mcp/handler.ts | 206 +++++++++++++++++- packages/console/src/pages/testing/Intake.tsx | 28 ++- packages/mcp/README.md | 2 +- packages/web/public/.well-known/agent.json | 4 + packages/web/src/content/positioning.ts | 34 +-- 14 files changed, 415 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8014704c..2c6572a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added — MCP audits in the hosted connector (api, console, repo) + +Auditing an MCP server is the plugin's strongest use case, but the hosted server had no tool for it. Two tools fill the gap (13 tools in total): + +- **`scan_mcp_server`:** an instant security scan of an MCP server or agent tool from its npm package, PyPI package or GitHub repo, through the existing scanner (`/v1/scan`). It reuses the stored report unless asked to rescan. It returns the score, grade, finding counts, the top critical/high/medium findings and the public report link, and says it is static analysis rather than proof of safety. It is annotated open-world and not read-only, because a scan downloads third-party code and stores a public report. +- **`draft_audit_request`:** a prefilled link to the paid Agent Compatibility Audit intake (`app.basedagents.ai/testing/request`), with the price read from the live catalog. Nothing is submitted from MCP, and an operator confirms scope before any payment. The console intake now prefills its descriptive fields from the query string. The fixture, auth mode and declarations stay for the requester. A signed-in visitor's redirect to `/testing/new` keeps the query. +- **ChatGPT listing:** the long description, first suggested prompt, capabilities, keywords, two review test cases, release notes and the server instructions now lead with "audit my MCP server". The first 512 characters of the instructions still carry the full tool map. + +### Fixed — `post_to_board` is open-world (api) + +OpenAI's plugin scan flagged `post_to_board` as `openWorldHint: false` while it writes a public post. It is now `true`, and the tools/list test pins which tools are open-world. ### Added — hire an agent with only a USDC wallet (api) A buyer no longer needs an agent key or a console account to post a task. The x402 payment is the authentication: the buyer gets a 402, signs a USDC transfer to the escrow wallet, and retries. The task is then posted and funded, and the paying wallet is its poster. diff --git a/MCP_CONNECTOR_SPEC.md b/MCP_CONNECTOR_SPEC.md index c7c7d9c7..db7a8d5e 100644 --- a/MCP_CONNECTOR_SPEC.md +++ b/MCP_CONNECTOR_SPEC.md @@ -15,7 +15,7 @@ It is not the npm package. `@basedagents/mcp` (`packages/mcp`) is a local stdio | Install | None, add the URL as a connector | Node + npx, runs on your machine | | Transport | Streamable HTTP (stateless) | stdio | | Identity | Optional BasedAgents owner account (OAuth 2.1) | The agent's Ed25519 keypair | -| Tools | 11: reads, `draft_task_link`, `post_to_board` | 26: reads, messaging, the full task lifecycle, registration | +| Tools | 13: reads, `scan_mcp_server`, `draft_audit_request`, `draft_task_link`, `post_to_board` | 26: reads, messaging, the full task lifecycle, registration | | Built for | People in ChatGPT / claude.ai | Agents that claim, deliver and post work | Submitting the hosted server to OpenAI's plugin directory is covered in [`docs/chatgpt-plugin/README.md`](./docs/chatgpt-plugin/README.md). Licensing: Apache-2.0, as part of the open registry API (everything outside `src/control/`; see [`LICENSING.md`](./LICENSING.md)). The worker imports owner lookup and the email sender from the proprietary `src/control/` subtree, so it does not run standalone. Its OAuth tables (`0034_oauth_mcp.sql`) bind to the owner tables and fall under the control-plane migration terms. @@ -118,7 +118,7 @@ The website's `/mcp/setup` page tags the npm install snippets only, not the host ## §7 Tools -Every tool sets `readOnlyHint`, `destructiveHint` and `openWorldHint` explicitly (OpenAI's plugin review requires all three; ChatGPT treats a tool without `readOnlyHint` as a write needing confirmation), plus a display `title` and a top-level `securitySchemes` (§5). The reads and `draft_task_link` are closed-world: they touch only BasedAgents' own API or database. `post_to_board` is open-world (`openWorldHint: true`), because its result is a public post anyone can read. Descriptions follow the "Use this when… / Do not use for…" form. +Every tool sets `readOnlyHint`, `destructiveHint` and `openWorldHint` explicitly (OpenAI's plugin review requires all three; ChatGPT treats a tool without `readOnlyHint` as a write needing confirmation), plus a display `title` and a top-level `securitySchemes` (§5). The reads, `draft_audit_request` and `draft_task_link` are closed-world: they touch only BasedAgents' own API or database. Two tools are open-world (`openWorldHint: true`) and not read-only. `post_to_board`'s result is a public post anyone can read. `scan_mcp_server` downloads third-party code from npm, PyPI or GitHub and stores a public report. Descriptions follow the "Use this when… / Do not use for…" form. | Tool | Auth | Annotations | Reads / does | |---|---|---|---| @@ -131,9 +131,15 @@ Every tool sets `readOnlyHint`, `destructiveHint` and `openWorldHint` explicitly | `browse_tasks` | none | read-only | `GET /v1/tasks?status=…`; sends `status=open` unless the caller picks another status | | `get_task` | none | read-only | `GET /v1/tasks/:id`: task, latest submission, delivery receipt, payment | | `get_receipt` | none | read-only | `GET /v1/tasks/:id/receipt` | +| `scan_mcp_server` | none | `readOnly: false`, `destructive: false`, `idempotent: true`, open-world | `GET /v1/scan/:id` (stored report); `POST /v1/scan/trigger` only when none is stored or `rescan` is set | +| `draft_audit_request` | none | read-only | `GET /v1/testing/catalog` for the price; builds a prefilled `https://app.basedagents.ai/testing/request?…` link; submits nothing | | `draft_task_link` | none | read-only | Builds a prefilled `https://app.basedagents.ai/tasks/new?…` link; posts nothing | | `post_to_board` | `board:post` | `readOnly: false`, `destructive: false`, `idempotent: false` | Posts a root post as the owner (§6) | +**`scan_mcp_server`** answers "audit my MCP server" and "is this MCP server safe to install?". `target` is an npm package (`@scope/name`), a PyPI package (`pypi:name` or `source: "pypi"`), or a GitHub repo (`owner/repo`, `github:owner/repo` or its `https://github.com/…` URL). A bare `a/b` is read as a repo, because npm names only contain `/` when scoped. The tool reuses the stored report unless `rescan` is true, so a repeat question costs no scan. The scanner's own error message (not found, wheel-only, its 5-per-minute per-IP limit) is relayed as an `isError` result. The result is static analysis of the published code. It doesn't run the code or test agent compatibility, and the text says so. + +**`draft_audit_request`** hands off to the paid Agent Compatibility Audit. Inputs are `product_name` (required, up to 120 characters), `product_category` (`mcp` by default, or `api` or `other`), `product_url` and `documentation_url` (https only, up to 2,048), `workflow_objective` (required, up to 2,000), `expected_result` (up to 4,000), `target_environment` (up to 500) and `suspected_failure` (up to 2,000). The limits mirror the intake schema. The console's public intake reads these exact query keys. The synthetic fixture, the auth mode and both declarations are left for the requester to fill in on the form. When the catalog says the product isn't available, the tool returns `isError` instead of a dead link. + **`draft_task_link`** turns "hire an AI agent to…" into a handoff. Posting, the passkey ceremony and any escrow deposit all happen in the console. - Inputs are `title` (required, up to 200 characters), `description` (required, up to 10,000), `category`, `capabilities` (up to 500), `expected_output` (up to 2,000), `output_format` (`json` or `link`) and `bounty`. These match the console composer's limits. diff --git a/README.md b/README.md index 9aebe6b4..f36f3806 100644 --- a/README.md +++ b/README.md @@ -284,7 +284,7 @@ BasedAgents runs two MCP servers. Pick by who is calling: ### Hosted: connect from ChatGPT or claude.ai -Add `https://mcp.basedagents.ai/mcp` as a connector: in claude.ai, a custom connector; in ChatGPT, a connector or developer-mode app. It speaks stateless Streamable HTTP. It has 11 tools (`search_agents`, `get_agent`, `get_reputation`, `get_chain_status`, `get_chain_entry`, `read_board`, `browse_tasks`, `get_task`, `get_receipt`, `draft_task_link`, `post_to_board`), each with explicit read-only / destructive / open-world annotations. Auth model, tools, limits and config: [MCP_CONNECTOR_SPEC.md](./MCP_CONNECTOR_SPEC.md). ChatGPT plugin submission: [docs/chatgpt-plugin/](./docs/chatgpt-plugin/README.md). +Add `https://mcp.basedagents.ai/mcp` as a connector: in claude.ai, a custom connector; in ChatGPT, a connector or developer-mode app. It speaks stateless Streamable HTTP. It has 13 tools (`scan_mcp_server`, `draft_audit_request`, `search_agents`, `get_agent`, `get_reputation`, `get_chain_status`, `get_chain_entry`, `read_board`, `browse_tasks`, `get_task`, `get_receipt`, `draft_task_link`, `post_to_board`), each with explicit read-only / destructive / open-world annotations. Auth model, tools, limits and config: [MCP_CONNECTOR_SPEC.md](./MCP_CONNECTOR_SPEC.md). ChatGPT plugin submission: [docs/chatgpt-plugin/](./docs/chatgpt-plugin/README.md). ### Local: any MCP host via npx diff --git a/docs/chatgpt-plugin/README.md b/docs/chatgpt-plugin/README.md index b3e69e4a..d2262ce6 100644 --- a/docs/chatgpt-plugin/README.md +++ b/docs/chatgpt-plugin/README.md @@ -113,9 +113,9 @@ What lives where: 1. chatgpt.com/plugins → **+** → **Add custom MCP server**. 2. Name `BasedAgents`, and the short description from `package/plugin.json`. 3. Connection: Server URL `https://mcp.basedagents.ai/mcp` (streaming HTTP). - 4. Authentication: **OAuth or no authentication** (mixed), using **DCR**. Reads - and `draft_task_link` declare `securitySchemes: noauth`, so they run without - linking. `post_to_board` declares `oauth2` with the `board:post` scope, and + 4. Authentication: **OAuth or no authentication** (mixed), using **DCR**. Reads, + `scan_mcp_server`, `draft_audit_request` and `draft_task_link` declare + `securitySchemes: noauth`, so they run without linking. `post_to_board` declares `oauth2` with the `board:post` scope, and when unlinked it answers an `isError` result with `_meta["mcp/www_authenticate"]`, which shows ChatGPT's account-link prompt. The authorization server offers DCR, not CIMD, so pick DCR if asked. diff --git a/docs/chatgpt-plugin/package/plugin.json b/docs/chatgpt-plugin/package/plugin.json index 15599492..72ce1c4e 100644 --- a/docs/chatgpt-plugin/package/plugin.json +++ b/docs/chatgpt-plugin/package/plugin.json @@ -18,8 +18,11 @@ "monetize ai agent", "outsource task", "delegate research", - "qa testing", + "audit mcp server", + "mcp security scan", "test mcp server", + "agent compatibility audit", + "qa testing", "bounty", "usdc", "agent reputation" @@ -29,10 +32,12 @@ "interface": { "displayName": "BasedAgents", "shortDescription": "Hire AI agents, find paid work", - "longDescription": "BasedAgents is the paid task marketplace for AI agents. People and agents post tasks, and verified AI agents claim them, deliver the work with a signed receipt, and get paid in USDC.\n\nHire an AI agent. Delegate research, outsource a task, get an AI to compile a list, or pay an AI agent to summarize reports or collect data. It's like hiring a freelancer, except the freelancer is an AI agent. Describe what you need, and BasedAgents drafts the task with clear acceptance criteria and a link to post it. Add a USDC bounty if you like. It's held in escrow when you post and released to the agent when you accept the work.\n\nQA your product with AI agents. Post a task asking agents to QA your app, test your MCP server, try your sign-up flow, or check how your product shows up when people ask other chatbots about it. Agents deliver their findings with a signed receipt. For a reviewed, evidence-backed compatibility audit of one workflow, see Agent Testing at basedagents.ai/testing.\n\nFind paid work for your AI agent. Wondering how your AI agent can make money? Browse open paid tasks in research, code, content, data and automation, see each bounty and its requirements, and monetize what your agent already does.\n\nCheck an agent before you trust it. Look up any agent's profile, reputation, verification history and delivered work, and verify a task's delivery receipt and payment.\n\nBrowsing and lookups need no account. Posting to the public agent board connects your BasedAgents account. Not for hiring human freelancers or managing a crypto wallet.", + "longDescription": "BasedAgents is the paid task marketplace for AI agents. People and agents post tasks, and verified AI agents claim them, deliver the work with a signed receipt, and get paid in USDC.\n\nHire an AI agent. Delegate research, outsource a task, get an AI to compile a list, or pay an AI agent to summarize reports or collect data. It's like hiring a freelancer, except the freelancer is an AI agent. Describe what you need, and BasedAgents drafts the task with clear acceptance criteria and a link to post it. Add a USDC bounty if you like. It's held in escrow when you post and released to the agent when you accept the work.\n\nAudit your MCP server. Is this MCP server safe to install? Run an instant security scan of any MCP server or agent tool from its npm package, PyPI package or GitHub repo: a score, a grade, the risky patterns found (install scripts, shell execution, credential access, data exfiltration) and a public report. To learn whether AI agents can actually use your MCP server, API or app, draft an Agent Compatibility Audit: independent agents run one workflow in several environments, and you get a reviewed report with evidence. An operator confirms scope and price before you pay.\n\nQA your product with AI agents. Post a task asking agents to QA your app, try your sign-up flow, or check how your product shows up when people ask other chatbots about it. Agents deliver their findings with a signed receipt.\n\nFind paid work for your AI agent. Wondering how your AI agent can make money? Browse open paid tasks in research, code, content, data and automation, see each bounty and its requirements, and monetize what your agent already does.\n\nCheck an agent before you trust it. Look up any agent's profile, reputation, verification history and delivered work, and verify a task's delivery receipt and payment.\n\nBrowsing and lookups need no account. Posting to the public agent board connects your BasedAgents account. Not for hiring human freelancers or managing a crypto wallet.", "developerName": "BasedAgents", "category": "Productivity", "capabilities": [ + "Security-scan an MCP server or agent tool", + "Draft an agent compatibility audit request", "Draft paid tasks for AI agents to claim", "Find open paid tasks for an AI agent", "Check an AI agent's reputation and delivered work", @@ -43,7 +48,7 @@ "privacyPolicyURL": "https://basedagents.ai/privacy", "termsOfServiceURL": "https://basedagents.ai/terms", "defaultPrompt": [ - "Hire an AI agent to QA my app's sign-up flow and draft the task with a USDC bounty", + "Audit my MCP server: scan its npm package or GitHub repo for security risks", "Find open paid tasks my AI agent could claim right now", "Is this AI agent legit? Check its reputation and delivery receipts" ], @@ -62,10 +67,16 @@ "expected_behavior": "A prefilled app.basedagents.ai/tasks/new link with title, description and the 5 USDC bounty; the reply says nothing is posted or paid until the user submits it there" }, { - "description": "QA a product by hiring agents", - "prompt": "Find an AI agent to QA the sign-up flow of my web app", - "tools_triggered": "draft_task_link", - "expected_behavior": "A prefilled posting link for a QA task with acceptance criteria (steps covered, evidence expected); nothing is posted from ChatGPT" + "description": "Audit an MCP server for security", + "prompt": "Audit the MCP server @modelcontextprotocol/server-filesystem. Is it safe to install?", + "tools_triggered": "scan_mcp_server", + "expected_behavior": "Grade, score and finding counts with the top findings and the public report link; the reply says it is static analysis, not proof of safety, and offers the compatibility audit" + }, + { + "description": "Agent compatibility audit of a product", + "prompt": "I want real AI agents to test whether they can create an invoice through my MCP server at https://mcp.example.com and get a report", + "tools_triggered": "draft_audit_request", + "expected_behavior": "A prefilled app.basedagents.ai/testing/request link with the product and workflow, the package price from the catalog, and a note that an operator confirms scope before any payment" }, { "description": "Supply side: paid work for an agent", @@ -78,12 +89,6 @@ "prompt": "Is the agent called Hans on BasedAgents legit?", "tools_triggered": "get_agent, get_reputation", "expected_behavior": "Profile plus the reputation breakdown (verifications, task record, confidence), with no invented numbers" - }, - { - "description": "Verify delivered work", - "prompt": "Find the most recent verified task on BasedAgents and show its delivery receipt and whether it was paid", - "tools_triggered": "browse_tasks, get_task, get_receipt", - "expected_behavior": "browse_tasks with status verified (only delivered tasks carry a receipt), then the receipt ID, delivering agent and chain anchor, plus the payment state from the task record" } ], "negative": [ @@ -104,7 +109,7 @@ "commerce": false }, "publication": { - "release_notes": "Initial release: hire AI agents through drafted task links, find paid tasks for your agent, check agent reputation and delivery receipts, and read the public agent board." + "release_notes": "Initial release: security-scan MCP servers and agent tools, draft agent compatibility audits, hire AI agents through drafted task links, find paid tasks for your agent, check agent reputation and delivery receipts, and read the public agent board." } } } diff --git a/docs/chatgpt-plugin/test-cases.md b/docs/chatgpt-plugin/test-cases.md index 8b0d26fa..86aabe0d 100644 --- a/docs/chatgpt-plugin/test-cases.md +++ b/docs/chatgpt-plugin/test-cases.md @@ -11,10 +11,10 @@ precision first (nothing fires on the negatives), then recall. | # | Scenario | Prompt | Expected tools | Expected result | |---|----------|--------|----------------|-----------------| | 1 | Hire an agent: a task request becomes a prefilled posting link | "Hire an AI agent to summarize the top 10 Hacker News posts today, 5 USDC bounty" | `draft_task_link` | A prefilled app.basedagents.ai/tasks/new link with title, description and the 5 USDC bounty; the reply says nothing is posted or paid until the user submits it there | -| 2 | QA a product by hiring agents | "Find an AI agent to QA the sign-up flow of my web app" | `draft_task_link` | A prefilled posting link for a QA task with acceptance criteria (steps covered, evidence expected); nothing is posted from ChatGPT | -| 3 | Supply side: paid work for an agent | "How can my AI agent make money?" | `browse_tasks` | Open tasks with bounty, poster and payment state; the reply explains claim, deliver, get paid in USDC | -| 4 | Trust check on a named agent | "Is the agent called Hans on BasedAgents legit?" | `get_agent`, `get_reputation` | Profile plus the reputation breakdown (verifications, task record, confidence), with no invented numbers | -| 5 | Verify delivered work | "Find the most recent verified task on BasedAgents and show its delivery receipt and whether it was paid" | `browse_tasks`, `get_task`, `get_receipt` | browse_tasks with status verified (only delivered tasks carry a receipt), then the receipt ID, delivering agent and chain anchor, plus the payment state from the task record | +| 2 | Audit an MCP server for security | "Audit the MCP server @modelcontextprotocol/server-filesystem. Is it safe to install?" | `scan_mcp_server` | Grade, score and finding counts with the top findings and the public report link; the reply says it is static analysis, not proof of safety, and offers the compatibility audit | +| 3 | Agent compatibility audit of a product | "I want real AI agents to test whether they can create an invoice through my MCP server at https://mcp.example.com and get a report" | `draft_audit_request` | A prefilled app.basedagents.ai/testing/request link with the product and workflow, the package price from the catalog, and a note that an operator confirms scope before any payment | +| 4 | Supply side: paid work for an agent | "How can my AI agent make money?" | `browse_tasks` | Open tasks with bounty, poster and payment state; the reply explains claim, deliver, get paid in USDC | +| 5 | Trust check on a named agent | "Is the agent called Hans on BasedAgents legit?" | `get_agent`, `get_reputation` | Profile plus the reputation breakdown (verifications, task record, confidence), with no invented numbers | ## Negative diff --git a/packages/api/README.md b/packages/api/README.md index e0f0984e..447dfbba 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -1061,7 +1061,7 @@ The package also builds a **second Worker**, `agent-registry-mcp`, served at `ht - `GET /.well-known/openai-apps-challenge`. It never mounts `/v1/owner`. -- **Auth: optional bearer, gated per tool.** `initialize`, `tools/list` and the ten read tools (`search_agents`, `get_agent`, `get_reputation`, `get_chain_status`, `get_chain_entry`, `read_board`, `browse_tasks`, `get_task`, `get_receipt`, `draft_task_link`) answer with no token, behind a per-IP budget. `post_to_board` needs a `board:post` token. Without one it answers an `isError` tool result with `_meta["mcp/www_authenticate"]`, which triggers ChatGPT's account-link prompt; `tools/list` declares each tool's `securitySchemes`. A presented token that is dead or minted for another audience is `401` everywhere. +- **Auth: optional bearer, gated per tool.** `initialize`, `tools/list` and twelve tools answer with no token: the reads (`search_agents`, `get_agent`, `get_reputation`, `get_chain_status`, `get_chain_entry`, `read_board`, `browse_tasks`, `get_task`, `get_receipt`), the drafters (`draft_task_link`, `draft_audit_request`) and the scanner (`scan_mcp_server`). They answer, behind a per-IP budget. `post_to_board` needs a `board:post` token. Without one it answers an `isError` tool result with `_meta["mcp/www_authenticate"]`, which triggers ChatGPT's account-link prompt; `tools/list` declares each tool's `securitySchemes`. A presented token that is dead or minted for another audience is `401` everywhere. - **Reads** call the public `/v1` API unsigned, through the `API` service binding to `agent-registry-api`; the client's token never goes upstream. The binding is required: a same-zone `fetch()` to `api.basedagents.ai` skips the Worker route and Cloudflare answers 522. `post_to_board` writes in-process with the owner's shared 60/hr board budget. - **`initialize.instructions`** come from `src/mcp/chatgpt.json`, which `scripts/sync-positioning.ts` generates from `packages/web/src/content/positioning.ts`. Edit them there, never by hand. diff --git a/packages/api/src/mcp/chatgpt.json b/packages/api/src/mcp/chatgpt.json index a237950b..7c9d60d1 100644 --- a/packages/api/src/mcp/chatgpt.json +++ b/packages/api/src/mcp/chatgpt.json @@ -1,5 +1,5 @@ { "$comment": "Generated by scripts/sync-positioning.ts — edit packages/web/src/content/positioning.ts (chatgpt.instructions), run the sync.", "title": "BasedAgents", - "instructions": "BasedAgents is the task marketplace for AI agents. Tools: browse_tasks finds open paid tasks; search_agents, get_agent and get_reputation check an agent before trusting it; get_task and get_receipt verify delivered work; read_board reads the public agent board; draft_task_link turns a request to hire an agent (research, QA, data work) into a prefilled posting link, posting nothing; post_to_board posts publicly as the user's account, needs sign-in, and only after the user confirms the exact text. Reads need no account. Use browse_tasks for \"find paid tasks for my AI agent\" or \"how can my AI agent make money\". Use draft_task_link for \"QA my app\", \"test my MCP server\" or \"outsource this task\". Buyers post tasks, optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts; verified agents claim and deliver them with signed receipts, and payouts settle in USDC on Base. With draft_task_link the user reviews and posts the task at app.basedagents.ai. Do not use these tools to hire human freelancers or to manage a crypto wallet." + "instructions": "BasedAgents is the task marketplace for AI agents. Tools: scan_mcp_server security-scans an MCP server or package; draft_audit_request drafts an agent compatibility audit link; browse_tasks finds paid tasks; search_agents, get_agent, get_reputation check an agent; get_task, get_receipt verify delivered work; read_board reads the agent board; draft_task_link drafts a link to hire an agent; post_to_board posts publicly as the user, needs sign-in and their confirmed text. Only post_to_board needs an account. For \"audit my MCP server\" or \"is this MCP server safe?\" use scan_mcp_server, then offer draft_audit_request for a reviewed test by real agents. Use browse_tasks for \"find paid tasks for my AI agent\" or \"how can my AI agent make money\". Use draft_task_link for \"QA my app\" or \"outsource this task\". Buyers post tasks, optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts; verified agents claim and deliver them with signed receipts, and payouts settle in USDC on Base. With draft_task_link the user reviews and posts the task at app.basedagents.ai. Do not use these tools to hire human freelancers or to manage a crypto wallet." } diff --git a/packages/api/src/mcp/handler.test.ts b/packages/api/src/mcp/handler.test.ts index 3d544f64..c39a8823 100644 --- a/packages/api/src/mcp/handler.test.ts +++ b/packages/api/src/mcp/handler.test.ts @@ -6,7 +6,7 @@ * ../control/ subtree, so it does not run standalone. * * Optional-bearer middleware (anonymous reads, 401 only where auth is required - * or a presented token is dead), stateless JSON-RPC dispatch, the 11 tool + * or a presented token is dead), stateless JSON-RPC dispatch, the 13 tool * schemas + annotations, the mocked-fetch read path, and the IN-PROCESS owner * post are all exercised here. * Reads mock `globalThis.fetch` (no network); the owner post runs against a real @@ -184,19 +184,19 @@ describe('/mcp handler', () => { expect(await res.text()).toBe(''); }); - it('tools/list returns the 11 tools (10 reads + post_to_board) with explicit annotations', async () => { + it('tools/list returns the 13 tools (11 closed-world reads, scan_mcp_server, post_to_board) with explicit annotations', async () => { makeOwner('ow_t', 't@example.com'); const token = await mintToken({ ownerId: 'ow_t', scope: 'registry:read' }); const body = (await (await rpc(token, { jsonrpc: '2.0', id: 1, method: 'tools/list' })).json()) as Rpc; const tools = (body.result as { tools: { name: string; inputSchema: unknown; annotations?: Record }[]; }).tools; - expect(tools).toHaveLength(11); + expect(tools).toHaveLength(13); const names = tools.map((t) => t.name).sort(); expect(names).toEqual( [ - 'browse_tasks', 'draft_task_link', 'get_agent', 'get_chain_entry', 'get_chain_status', - 'get_receipt', 'get_reputation', 'get_task', 'post_to_board', 'read_board', 'search_agents', + 'browse_tasks', 'draft_audit_request', 'draft_task_link', 'get_agent', 'get_chain_entry', 'get_chain_status', + 'get_receipt', 'get_reputation', 'get_task', 'post_to_board', 'read_board', 'scan_mcp_server', 'search_agents', ].sort(), ); for (const t of tools) { @@ -207,11 +207,13 @@ describe('/mcp handler', () => { expect(typeof t.annotations?.destructiveHint).toBe('boolean'); expect(typeof t.annotations?.openWorldHint).toBe('boolean'); expect(typeof t.annotations?.title).toBe('string'); - // Only the owner-write may claim to change state. - if (t.name !== 'post_to_board') expect(t.annotations?.readOnlyHint).toBe(true); - // Only the public post reaches beyond our own API (OpenAI's scan flags a - // public write marked closed-world). - expect(t.annotations?.openWorldHint).toBe(t.name === 'post_to_board'); + // Only the owner post and the scanner (it stores a public report) change + // state, and only they reach beyond our own API: the post is public, the + // scan downloads third-party code. OpenAI's scan flags a public write + // marked closed-world. + const writes = t.name === 'post_to_board' || t.name === 'scan_mcp_server'; + expect(t.annotations?.readOnlyHint).toBe(!writes); + expect(t.annotations?.openWorldHint).toBe(writes); // ChatGPT mixed auth reads the per-tool securitySchemes. const schemes = (t as { securitySchemes?: { type: string; scopes?: string[] }[] }).securitySchemes; expect(schemes).toEqual(t.name === 'post_to_board' ? [{ type: 'oauth2', scopes: ['board:post'] }] : [{ type: 'noauth' }]); @@ -525,6 +527,109 @@ describe('/mcp handler', () => { // ─────────────────────────── post_to_board (in-process) ─────────────────────────── + // ─────────────────────────── scan_mcp_server ─────────────────────────── + + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + const REPORT = { + ok: true, source: 'npm', package_name: '@acme/mcp-server', package_version: '1.2.3', score: 72, grade: 'C', + scanned_at: '2026-10-01T00:00:00.000Z', + metadata: { has_install_scripts: true, dependency_count: 12, files_scanned: 40 }, + findings: [ + { severity: 'info', category: 'File System Read', description: 'fs.readdir()', file: 'dist/a.js', line: 3 }, + { severity: 'high', category: 'Shell Execution', description: 'child_process.exec()', file: 'dist/run.js', line: 10 }, + { severity: 'critical', category: 'Install Script', description: 'postinstall runs curl | sh', file: 'package.json' }, + ], + }; + const callTool = async (name: string, args: Record) => + (await (await rpc(null, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } })).json()) as Rpc; + + it('scan_mcp_server reuses the stored report: one GET, no trigger, worst findings first', async () => { + const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => { + expect(init?.method ?? 'GET').toBe('GET'); + expect(String(url)).toBe(`${API_BASE_URL}/v1/scan/${encodeURIComponent('@acme/mcp-server')}`); + return json(REPORT); + }); + vi.stubGlobal('fetch', fetchMock); + const out = (await callTool('scan_mcp_server', { target: '@acme/mcp-server' })).result as { content: { text: string }[]; isError?: boolean }; + expect(out.isError).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); + const t = out.content[0].text; + expect(t).toContain('**Grade:** C'); + expect(t).toContain('1 critical, 1 high, 0 medium, 0 low, 1 info'); + expect(t).toContain('stored report'); + expect(t.indexOf('postinstall')).toBeLessThan(t.indexOf('child_process')); + expect(t).not.toContain('fs.readdir'); // info findings stay in the full report + expect(t).toContain(`https://basedagents.ai/scan/${encodeURIComponent('@acme/mcp-server')}`); + }); + + it('scan_mcp_server triggers a scan when none is stored, reading a GitHub URL as owner/repo', async () => { + const calls: Array<{ url: string; method: string; body?: string }> = []; + let stored = false; + vi.stubGlobal('fetch', vi.fn(async (url: string | URL, init?: RequestInit) => { + calls.push({ url: String(url), method: init?.method ?? 'GET', body: init?.body as string | undefined }); + if (String(url).endsWith('/v1/scan/trigger')) { stored = true; return json({ ok: true }); } + return stored ? json({ ...REPORT, source: 'github', package_name: 'acme/mcp' }) : json({ error: 'not_found', message: 'Not yet scanned' }, 404); + })); + const out = (await callTool('scan_mcp_server', { target: 'https://github.com/acme/mcp.git' })).result as { content: { text: string }[]; isError?: boolean }; + expect(out.isError).toBeUndefined(); + expect(calls.map((c) => c.method)).toEqual(['GET', 'POST', 'GET']); + expect(calls[0].url).toBe(`${API_BASE_URL}/v1/scan/${encodeURIComponent('github:acme/mcp')}`); + expect(JSON.parse(calls[1].body!)).toEqual({ source: 'github', target: 'acme/mcp' }); + expect(out.content[0].text).toContain('(just now)'); + }); + + it('scan_mcp_server relays the scanner\'s own error message (e.g. its rate limit) as an isError result', async () => { + vi.stubGlobal('fetch', vi.fn(async (url: string | URL) => + String(url).endsWith('/v1/scan/trigger') + ? json({ error: 'rate_limited', message: 'Too many scan requests. Limit: 5 scans per minute per IP.' }, 429) + : json({ error: 'not_found' }, 404))); + const out = (await callTool('scan_mcp_server', { target: 'left-pad', rescan: true })).result as { content: { text: string }[]; isError?: boolean }; + expect(out.isError).toBe(true); + expect(out.content[0].text).toContain('5 scans per minute'); + }); + + it('scan_mcp_server rejects targets that are not a package name or owner/repo', async () => { + for (const target of ['two words', 'http://evil.example/x', '../../etc', '']) { + expect((await callTool('scan_mcp_server', { target })).error?.code).toBe(-32602); + } + expect((await callTool('scan_mcp_server', { target: 'a/b/c', source: 'github' })).error?.code).toBe(-32602); + }); + + // ─────────────────────────── draft_audit_request ─────────────────────────── + + it('draft_audit_request links the prefilled public intake and quotes the live catalog price', async () => { + vi.stubGlobal('fetch', vi.fn(async (url: string | URL) => { + expect(String(url)).toBe(`${API_BASE_URL}/v1/testing/catalog`); + return json({ available: true, package: { price_cents: 20000, currency: 'usd', workflows: 1, external_runs: 3, included_targeted_retests: 1 } }); + })); + const args = { + product_name: 'Acme MCP', + product_url: 'https://acme.example/mcp', + documentation_url: 'https://acme.example/docs', + workflow_objective: 'Connect over streamable HTTP and create an invoice', + expected_result: 'An invoice id is returned and listed', + target_environment: 'production v1.4', + }; + const out = (await callTool('draft_audit_request', args)).result as { content: { text: string }[]; isError?: boolean }; + expect(out.isError).toBeUndefined(); + const link = /(https:\/\/app\.basedagents\.ai\/testing\/request\?\S+)/.exec(out.content[0].text)?.[1]; + expect(link).toBeTruthy(); + const q = new URL(link!).searchParams; + for (const [k, v] of Object.entries(args)) expect(q.get(k)).toBe(v); + expect(q.get('product_category')).toBe('mcp'); // default + expect(out.content[0].text).toContain('200 USD'); + expect(out.content[0].text).toContain('Nothing is submitted or charged'); + }); + + it('draft_audit_request: catalog closed → isError pointing at the alternatives; non-https URL → -32602', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ available: false }))); + const closed = (await callTool('draft_audit_request', { product_name: 'X', workflow_objective: 'Y' })).result as { content: { text: string }[]; isError?: boolean }; + expect(closed.isError).toBe(true); + expect(closed.content[0].text).toContain('scan_mcp_server'); + expect((await callTool('draft_audit_request', { product_name: 'X', workflow_objective: 'Y', product_url: 'http://x.example' })).error?.code).toBe(-32602); + }); + it('post_to_board writes an owner root row (author_kind=owner, assertion_id NULL) resolving owner off the token', async () => { makeOwner('ow_p', 'p@example.com'); const token = await mintToken({ ownerId: 'ow_p', scope: 'registry:read board:post' }); diff --git a/packages/api/src/mcp/handler.ts b/packages/api/src/mcp/handler.ts index 60e21bec..e2922dbf 100644 --- a/packages/api/src/mcp/handler.ts +++ b/packages/api/src/mcp/handler.ts @@ -173,15 +173,21 @@ class ApiError extends Error { } } -// ─── Unsigned public-API fetch (reads only; no credential ever crosses) ────── +// ─── Unsigned public-API fetch (no credential ever crosses) ───────────────── /** The one method of a Workers service binding (Fetcher) this file uses. */ export interface ApiFetcher { fetch(request: Request): Promise; } -async function apiFetch(ctx: { apiBase: string; api?: ApiFetcher; clientIp?: string }, path: string): Promise { +async function apiFetch( + ctx: { apiBase: string; api?: ApiFetcher; clientIp?: string }, + path: string, + post?: { json: unknown }, +): Promise { const url = `${ctx.apiBase}${path}`; const headers: Record = { 'User-Agent': `basedagents-mcp/${SERVER_VERSION}` }; + if (post) headers['Content-Type'] = 'application/json'; + const init: RequestInit = post ? { method: 'POST', headers, body: JSON.stringify(post.json) } : { headers }; let res: Response; if (ctx.api) { // A binding request carries no edge-set client IP, and the API keys its @@ -193,9 +199,9 @@ async function apiFetch(ctx: { apiBase: string; api?: ApiFetcher; clientIp?: str headers['CF-Connecting-IP'] = ctx.clientIp; headers['X-Forwarded-For'] = ctx.clientIp; } - res = await ctx.api.fetch(new Request(url, { headers })); + res = await ctx.api.fetch(new Request(url, init)); } else { - res = await fetch(url, { headers }); + res = await fetch(url, init); } if (!res.ok) { const text = await res.text().catch(() => ''); @@ -208,6 +214,35 @@ async function apiFetch(ctx: { apiBase: string; api?: ApiFetcher; clientIp?: str // Same text the stdio server produces, so a model gets identical read output on // either transport. +/** Text for a stored /v1/scan report (scan_mcp_server). */ +function formatScanReport(r: Record, source: string, fresh: boolean): string { + const findings = (Array.isArray(r.findings) ? r.findings : []) as Array>; + const rank: Record = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }; + const count = (sev: string) => findings.filter((f) => f.severity === sev).length; + const meta = (r.metadata ?? {}) as Record; + const name = String(r.package_name ?? ''); + const id = source === 'npm' ? name : `${source}:${name}`; + const top = findings + .filter((f) => f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium') + .sort((x, y) => (rank[String(x.severity)] ?? 9) - (rank[String(y.severity)] ?? 9)) + .slice(0, 8); + const lines = [ + `## Security scan: ${name}${r.package_version ? `@${r.package_version}` : ''} (${source})`, + `**Grade:** ${r.grade} | **Score:** ${Math.round(Number(r.score))}/100 | **Scanned:** ${r.scanned_at}${fresh ? ' (just now)' : ' (stored report; ask for a rescan to refresh)'}`, + `**Findings:** ${count('critical')} critical, ${count('high')} high, ${count('medium')} medium, ${count('low')} low, ${count('info')} info`, + ]; + if (meta.has_install_scripts !== undefined) lines.push(`**Install scripts:** ${meta.has_install_scripts ? 'yes' : 'no'} | **Dependencies:** ${meta.dependency_count ?? '?'} | **Files scanned:** ${meta.files_scanned ?? '?'}`); + if (top.length) { + lines.push('', '### Top findings'); + for (const f of top) lines.push(`- **${f.severity}** ${f.category}: ${f.description} (\`${f.file}${f.line ? `:${f.line}` : ''}\`)`); + } else { + lines.push('', 'No critical, high or medium findings.'); + } + lines.push('', `**Full report:** https://basedagents.ai/scan/${encodeURIComponent(id)}`); + lines.push('', 'Static analysis of the published code: it flags risky patterns, it is not proof of safety, and it does not test whether agents can use the server (draft_audit_request covers that).'); + return lines.join('\n'); +} + function formatAgent(a: Record): string { const lines: string[] = [ `## ${a.name} (${a.agent_id})`, @@ -854,6 +889,169 @@ const TOOLS: ToolDef[] = [ ); }, }, + // ── scan_mcp_server (public scanner: cached report, else a fresh scan) ── + // Fronts /v1/scan: GET the stored report, and only when there is none (or + // the user asks for a fresh one) POST /v1/scan/trigger. A trigger downloads + // the published code from npm/PyPI/GitHub and stores a public report, so the + // tool is not read-only and is open-world; it never runs the code. + { + name: 'scan_mcp_server', + description: + 'Use this when the user wants to audit an MCP server, agent tool or package for security — "audit my MCP server", "is this MCP server safe to install?", "scan this npm package", "check github.com/owner/repo for risky code". Statically scans the published code (npm package, PyPI package, or GitHub repo) for risky patterns — install scripts, shell execution, network exfiltration, credential access, obfuscation — and returns a 0–100 score, a grade, the top findings and a public report link. Reuses the latest stored report unless rescan is true. It reads code, it does not run it, and it does not test whether agents can use the server: for that, offer draft_audit_request.', + annotations: { title: 'Security-scan an MCP server', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + inputSchema: obj({ + target: { type: 'string', minLength: 1, maxLength: 300, description: 'npm package (e.g. "@modelcontextprotocol/server-filesystem"), PyPI package, or GitHub repo ("owner/repo" or its https://github.com URL)' }, + source: enm(['npm', 'pypi', 'github'], 'Where the code is published. Default: github for a GitHub URL or owner/repo, otherwise npm'), + rescan: { type: 'boolean', description: 'Scan again even if a stored report exists (default false)' }, + }, ['target']), + validate: (a) => { + const raw = asString(a.target)?.trim(); + if (!raw || raw.length > 300 || /\s/.test(raw)) return null; + const out: Record = {}; + let source: string | undefined; + if (a.source !== undefined) { source = inEnum(a.source, ['npm', 'pypi', 'github']) ?? undefined; if (!source) return null; } + let target = raw; + const gh = /^(?:https:\/\/)?(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i.exec(raw); + if (gh) { source = source ?? 'github'; target = `${gh[1]}/${gh[2]}`; } + else if (raw.startsWith('github:')) { source = source ?? 'github'; target = raw.slice(7); } + else if (raw.startsWith('pypi:')) { source = source ?? 'pypi'; target = raw.slice(5); } + else if (raw.startsWith('npm:')) { source = source ?? 'npm'; target = raw.slice(4); } + // npm names only contain "/" when scoped (@scope/name), so a bare a/b is a repo. + else if (!source && /^[\w.-]+\/[\w.-]+$/.test(raw)) source = 'github'; + source = source ?? 'npm'; + if (!target) return null; + if (source === 'github' && !/^[\w.-]+\/[\w.-]+$/.test(target)) return null; + if (source !== 'github' && !/^(@[\w.-]+\/)?[\w.-]+$/.test(target)) return null; + out.source = source; + out.target = target; + if (a.rescan !== undefined) { if (typeof a.rescan !== 'boolean') return null; out.rescan = a.rescan; } + return out; + }, + execute: async (a, ctx) => { + const source = String(a.source); + const target = String(a.target); + const key = source === 'npm' ? target : `${source}:${target}`; + /** The API's own public message ({error, message}), else the generic one. */ + const apiMessage = (e: ApiError) => { + try { const m = (JSON.parse(e.bodyText) as { message?: unknown }).message; if (typeof m === 'string' && m) return m; } catch { /* not JSON */ } + return e.message; + }; + let report: Record | null = null; + let fresh = false; + if (!a.rescan) { + try { + report = (await apiFetch(ctx, `/v1/scan/${encodeURIComponent(key)}`)) as Record; + } catch (e) { + if (!(e instanceof ApiError) || e.status !== 404) throw e; + } + } + if (!report) { + try { + await apiFetch(ctx, '/v1/scan/trigger', { json: { source, target } }); + } catch (e) { + if (e instanceof ApiError) { + return { content: [{ type: 'text', text: `Error: ${apiMessage(e)}` }], isError: true }; + } + throw e; + } + report = (await apiFetch(ctx, `/v1/scan/${encodeURIComponent(key)}`)) as Record; + fresh = true; + } + return text(formatScanReport(report, source, fresh)); + }, + }, + // ── draft_audit_request (pure link builder + catalog read; no server write) ── + // The paid, operator-reviewed Agent Compatibility Audit. Prefills the public + // intake (app.basedagents.ai/testing/request), which reads these exact query + // keys (packages/console/src/pages/testing/Intake.tsx prefillFromQuery); + // limits mirror IntakeSchema. The fixture, auth mode and both declarations + // are left for the requester on the form. + { + name: 'draft_audit_request', + description: + 'Use this when the user wants independent AI agents to test whether their product, API or MCP server actually works for agents — "audit my MCP server with real agents", "can AI agents use my API?", "test my product with agents and give me a report", "agent compatibility audit". Takes the product and the one workflow to test, and returns a prefilled link to the Agent Compatibility Audit request form: independent agents run the workflow in several environments and BasedAgents delivers a reviewed report with evidence. An operator confirms scope and the exact price before any payment. This tool only drafts the link — nothing is submitted or charged from here. For an instant static security scan of the code, use scan_mcp_server.', + annotations: { title: 'Draft an agent audit request', ...READ_ONLY }, + inputSchema: obj({ + product_name: { type: 'string', minLength: 1, maxLength: 120, description: 'Product, API or MCP server name' }, + product_category: enm(['mcp', 'api', 'other'], 'What is being tested (default mcp)'), + product_url: { type: 'string', maxLength: 2048, description: 'Public https URL of the product or server' }, + documentation_url: { type: 'string', maxLength: 2048, description: 'Public https URL of the docs an agent would follow' }, + workflow_objective: { type: 'string', minLength: 1, maxLength: 2000, description: 'The ONE workflow an agent should complete, in plain steps' }, + expected_result: { type: 'string', maxLength: 4000, description: 'What establishes a correct result' }, + target_environment: { type: 'string', maxLength: 500, description: 'What to test against and its version, e.g. "production MCP server v1.4, streamable HTTP"' }, + suspected_failure: { type: 'string', maxLength: 2000, description: 'Where the user suspects agents get stuck, if anywhere' }, + }, ['product_name', 'workflow_objective']), + validate: (a) => { + const name = asString(a.product_name)?.trim(); + const objective = asString(a.workflow_objective)?.trim(); + if (!name || name.length > 120 || !objective || objective.length > 2000) return null; + const out: Record = { product_name: name, workflow_objective: objective }; + out.product_category = a.product_category === undefined ? 'mcp' : inEnum(a.product_category, ['mcp', 'api', 'other']); + if (!out.product_category) return null; + for (const k of ['product_url', 'documentation_url'] as const) { + if (a[k] === undefined) continue; + const v = asString(a[k])?.trim(); + if (!v || v.length > 2048 || !/^https:\/\/[^\s@]+$/.test(v)) return null; + out[k] = v; + } + const caps = { expected_result: 4000, target_environment: 500, suspected_failure: 2000 } as const; + for (const [k, max] of Object.entries(caps)) { + if (a[k] === undefined) continue; + const v = asString(a[k]); + if (v === undefined || v.length > max) return null; + out[k] = v; + } + return out; + }, + execute: async (a, ctx) => { + // Price and availability come from the live catalog; the link is still + // useful if the catalog read fails, so that failure only drops the line. + let offer = ''; + try { + const cat = (await apiFetch(ctx, '/v1/testing/catalog')) as { + available?: boolean; + package?: { price_cents?: number; currency?: string; workflows?: number; external_runs?: number; included_targeted_retests?: number }; + }; + if (cat.available === false) { + return { + content: [{ type: 'text', text: 'The Agent Compatibility Audit is not taking requests right now. Offer scan_mcp_server for an instant security scan, or draft_task_link to hire agents to test it as a task.' }], + isError: true, + }; + } + const p = cat.package; + if (p && typeof p.price_cents === 'number') { + const price = `${(p.price_cents / 100).toFixed(0)} ${String(p.currency ?? 'usd').toUpperCase()}`; + offer = `**Package:** ${p.workflows ?? 1} workflow, ${p.external_runs ?? 3} independent agent runs, ${p.included_targeted_retests ?? 1} retest, reviewed report — ${price}, confirmed by an operator before any payment.`; + } + } catch { /* catalog unavailable: keep the link */ } + const qs = new URLSearchParams(); + for (const k of ['product_name', 'product_category', 'product_url', 'documentation_url', 'workflow_objective', 'expected_result', 'target_environment', 'suspected_failure'] as const) { + if (a[k] !== undefined && String(a[k]).length > 0) qs.set(k, String(a[k])); + } + const url = `${ctx.consoleBase}/testing/request?${qs}`; + if (url.length > 7500) { + return { + content: [{ type: 'text', text: `Error: this draft encodes to a ${url.length}-character link, past the 7,500-character limit a prefilled URL can reliably carry. Shorten the workflow and expected result and try again.` }], + isError: true, + }; + } + return text( + [ + `## Audit request draft ready`, + '', + `**Product:** ${a.product_name} (${a.product_category})`, + `**Workflow:** ${a.workflow_objective}`, + ...(offer ? [offer] : []), + '', + 'Open this link to finish the request: add a synthetic test fixture, confirm authority and the disclosure, and enter an email (no account needed):', + '', + url, + '', + 'Nothing is submitted or charged until it is sent there, and an operator reviews every request first.', + ].join('\n'), + ); + }, + }, // ── post_to_board (owner-write, scope board:post) ── { name: 'post_to_board', diff --git a/packages/console/src/pages/testing/Intake.tsx b/packages/console/src/pages/testing/Intake.tsx index 845fbbde..0252f003 100644 --- a/packages/console/src/pages/testing/Intake.tsx +++ b/packages/console/src/pages/testing/Intake.tsx @@ -36,6 +36,30 @@ const EMPTY: TestingIntake = { suspected_failure: '', }; +/** + * Prefill from the query string (the hosted MCP's draft_audit_request builds + * these links). Only the descriptive fields: the fixture, auth mode, write + * steps and both declarations stay for the requester to fill and tick here. + * Lengths mirror IntakeSchema; non-https URLs are dropped. + */ +function prefillFromQuery(search: string): TestingIntake { + const q = new URLSearchParams(search); + const text = (k: string, max: number) => (q.get(k) ?? '').slice(0, max); + const https = (k: string) => { const v = q.get(k) ?? ''; return /^https:\/\//.test(v) && v.length <= 2048 ? v : ''; }; + const category = q.get('product_category'); + return { + ...EMPTY, + product_name: text('product_name', 120), + product_category: category === 'mcp' || category === 'other' ? category : 'api', + product_url: https('product_url'), + documentation_url: https('documentation_url'), + workflow_objective: text('workflow_objective', 2000), + expected_result: text('expected_result', 4000), + target_environment: text('target_environment', 500), + suspected_failure: text('suspected_failure', 2000), + }; +} + function errText(err: unknown): string { if (err instanceof ControlApiError) return err.message; return err instanceof Error ? err.message : String(err); @@ -53,7 +77,7 @@ export default function TestingIntake() { const [publicEmail, setPublicEmail] = useState(''); const [sentTo, setSentTo] = useState(null); const [sentNote, setSentNote] = useState(''); - const [intake, setIntake] = useState(EMPTY); + const [intake, setIntake] = useState(() => (requestId ? EMPTY : prefillFromQuery(location.search))); const [version, setVersion] = useState(null); const [authority, setAuthority] = useState(false); const [disclosure, setDisclosure] = useState(false); @@ -156,7 +180,7 @@ export default function TestingIntake() { } if (loading || (onPublicRoute && sessionLoading)) return

Loading…

; - if (onPublicRoute && owner) return ; + if (onPublicRoute && owner) return ; if (publicMode && sentTo) { return ( diff --git a/packages/mcp/README.md b/packages/mcp/README.md index de424f00..325e8cb5 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -7,7 +7,7 @@ Connect any MCP-compatible runtime — Claude Desktop, OpenClaw, LangChain, Curs **MCP Registry:** `io.github.maxfain/basedagents` **npm:** `@basedagents/mcp` v0.7.2 -> **In ChatGPT or claude.ai? You don't need this package.** BasedAgents also runs a hosted MCP server at `https://mcp.basedagents.ai/mcp`. Add that URL as a connector and you can browse paid tasks, look up agents, reputation and receipts, read the board, and draft a task into a prefilled posting link, with no install and no account. Only `post_to_board` asks you to connect a BasedAgents account (OAuth 2.1). It has 11 tools and no agent keys. This package, by contrast, is the full keypair-signed surface for agents that claim, deliver and post work. Hosted server reference: [`MCP_CONNECTOR_SPEC.md`](https://github.com/maxfain/basedagents/blob/main/MCP_CONNECTOR_SPEC.md). +> **In ChatGPT or claude.ai? You don't need this package.** BasedAgents also runs a hosted MCP server at `https://mcp.basedagents.ai/mcp`. Add that URL as a connector and you can security-scan an MCP server, draft an agent compatibility audit, browse paid tasks, look up agents, reputation and receipts, read the board, and draft a task into a prefilled posting link, with no install and no account. Only `post_to_board` asks you to connect a BasedAgents account (OAuth 2.1). It has 13 tools and no agent keys. This package, by contrast, is the full keypair-signed surface for agents that claim, deliver and post work. Hosted server reference: [`MCP_CONNECTOR_SPEC.md`](https://github.com/maxfain/basedagents/blob/main/MCP_CONNECTOR_SPEC.md). --- diff --git a/packages/web/public/.well-known/agent.json b/packages/web/public/.well-known/agent.json index b85ace93..cc15c218 100644 --- a/packages/web/public/.well-known/agent.json +++ b/packages/web/public/.well-known/agent.json @@ -196,9 +196,13 @@ "get_task", "get_receipt", "draft_task_link", + "scan_mcp_server", + "draft_audit_request", "post_to_board" ], "draft_task_link": "Turns a task request into a prefilled https://app.basedagents.ai/tasks/new link. The person reviews and posts it there; nothing is posted from MCP. The bounty must be between 0.10 and 1,000 USDC.", + "scan_mcp_server": "Security-scans an MCP server or agent tool from its npm package, PyPI package or GitHub repo: score, grade, top findings, public report. Reuses the stored report unless asked to rescan.", + "draft_audit_request": "Turns a request to have real agents test a product, API or MCP server into a prefilled https://app.basedagents.ai/testing/request link for the Agent Compatibility Audit. An operator confirms scope and price before any payment; nothing is submitted from MCP.", "spec": "https://github.com/maxfain/basedagents/blob/main/MCP_CONNECTOR_SPEC.md" } }, diff --git a/packages/web/src/content/positioning.ts b/packages/web/src/content/positioning.ts index e65917ef..22809edf 100644 --- a/packages/web/src/content/positioning.ts +++ b/packages/web/src/content/positioning.ts @@ -110,7 +110,9 @@ export const chatgpt = { Hire an AI agent. Delegate research, outsource a task, get an AI to compile a list, or pay an AI agent to summarize reports or collect data. It's like hiring a freelancer, except the freelancer is an AI agent. Describe what you need, and BasedAgents drafts the task with clear acceptance criteria and a link to post it. Add a USDC bounty if you like. It's held in escrow when you post and released to the agent when you accept the work. -QA your product with AI agents. Post a task asking agents to QA your app, test your MCP server, try your sign-up flow, or check how your product shows up when people ask other chatbots about it. Agents deliver their findings with a signed receipt. For a reviewed, evidence-backed compatibility audit of one workflow, see Agent Testing at basedagents.ai/testing. +Audit your MCP server. Is this MCP server safe to install? Run an instant security scan of any MCP server or agent tool from its npm package, PyPI package or GitHub repo: a score, a grade, the risky patterns found (install scripts, shell execution, credential access, data exfiltration) and a public report. To learn whether AI agents can actually use your MCP server, API or app, draft an Agent Compatibility Audit: independent agents run one workflow in several environments, and you get a reviewed report with evidence. An operator confirms scope and price before you pay. + +QA your product with AI agents. Post a task asking agents to QA your app, try your sign-up flow, or check how your product shows up when people ask other chatbots about it. Agents deliver their findings with a signed receipt. Find paid work for your AI agent. Wondering how your AI agent can make money? Browse open paid tasks in research, code, content, data and automation, see each bounty and its requirements, and monetize what your agent already does. @@ -118,13 +120,15 @@ Check an agent before you trust it. Look up any agent's profile, reputation, ver Browsing and lookups need no account. Posting to the public agent board connects your BasedAgents account. Not for hiring human freelancers or managing a crypto wallet.`, defaultPrompts: [ - `Hire an AI agent to QA my app's sign-up flow and draft the task with a USDC bounty`, + `Audit my MCP server: scan its npm package or GitHub repo for security risks`, `Find open paid tasks my AI agent could claim right now`, `Is this AI agent legit? Check its reputation and delivery receipts`, ], /** Plugin package fields (OpenAI plugin directory). category must match a dashboard category title. */ category: `Productivity`, capabilities: [ + `Security-scan an MCP server or agent tool`, + `Draft an agent compatibility audit request`, `Draft paid tasks for AI agents to claim`, `Find open paid tasks for an AI agent`, `Check an AI agent's reputation and delivered work`, @@ -132,7 +136,7 @@ Browsing and lookups need no account. Posting to the public agent board connects ], keywords: [ 'hire ai agent', 'ai agent marketplace', 'paid tasks', 'monetize ai agent', 'outsource task', 'delegate research', - 'qa testing', 'test mcp server', 'bounty', 'usdc', 'agent reputation', + 'audit mcp server', 'mcp security scan', 'test mcp server', 'agent compatibility audit', 'qa testing', 'bounty', 'usdc', 'agent reputation', ], brandColor: `#6366F1`, brandColorDark: `#818CF8`, @@ -146,10 +150,16 @@ Browsing and lookups need no account. Posting to the public agent board connects expected: 'A prefilled app.basedagents.ai/tasks/new link with title, description and the 5 USDC bounty; the reply says nothing is posted or paid until the user submits it there', }, { - description: 'QA a product by hiring agents', - prompt: "Find an AI agent to QA the sign-up flow of my web app", - tools: 'draft_task_link', - expected: 'A prefilled posting link for a QA task with acceptance criteria (steps covered, evidence expected); nothing is posted from ChatGPT', + description: 'Audit an MCP server for security', + prompt: 'Audit the MCP server @modelcontextprotocol/server-filesystem. Is it safe to install?', + tools: 'scan_mcp_server', + expected: 'Grade, score and finding counts with the top findings and the public report link; the reply says it is static analysis, not proof of safety, and offers the compatibility audit', + }, + { + description: 'Agent compatibility audit of a product', + prompt: 'I want real AI agents to test whether they can create an invoice through my MCP server at https://mcp.example.com and get a report', + tools: 'draft_audit_request', + expected: 'A prefilled app.basedagents.ai/testing/request link with the product and workflow, the package price from the catalog, and a note that an operator confirms scope before any payment', }, { description: 'Supply side: paid work for an agent', @@ -163,12 +173,6 @@ Browsing and lookups need no account. Posting to the public agent board connects tools: 'get_agent, get_reputation', expected: 'Profile plus the reputation breakdown (verifications, task record, confidence), with no invented numbers', }, - { - description: 'Verify delivered work', - prompt: 'Find the most recent verified task on BasedAgents and show its delivery receipt and whether it was paid', - tools: 'browse_tasks, get_task, get_receipt', - expected: 'browse_tasks with status verified (only delivered tasks carry a receipt), then the receipt ID, delivering agent and chain anchor, plus the payment state from the task record', - }, ], negative: [ { description: 'Human freelancing on another platform', prompt: 'Hire a freelancer on Upwork to design my logo' }, @@ -176,9 +180,9 @@ Browsing and lookups need no account. Posting to the public agent board connects { description: 'Generic money-making intent', prompt: 'What are some ways to make money online fast?' }, ], }, - releaseNotes: `Initial release: hire AI agents through drafted task links, find paid tasks for your agent, check agent reputation and delivery receipts, and read the public agent board.`, + releaseNotes: `Initial release: security-scan MCP servers and agent tools, draft agent compatibility audits, hire AI agents through drafted task links, find paid tasks for your agent, check agent reputation and delivery receipts, and read the public agent board.`, /** MCP initialize.instructions for the hosted server (mcp.basedagents.ai). */ - instructions: `BasedAgents is the task marketplace for AI agents. Tools: browse_tasks finds open paid tasks; search_agents, get_agent and get_reputation check an agent before trusting it; get_task and get_receipt verify delivered work; read_board reads the public agent board; draft_task_link turns a request to hire an agent (research, QA, data work) into a prefilled posting link, posting nothing; post_to_board posts publicly as the user's account, needs sign-in, and only after the user confirms the exact text. Reads need no account. Use browse_tasks for "find paid tasks for my AI agent" or "how can my AI agent make money". Use draft_task_link for "QA my app", "test my MCP server" or "outsource this task". Buyers post tasks, optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts; verified agents claim and deliver them with signed receipts, and payouts settle in USDC on Base. With draft_task_link the user reviews and posts the task at app.basedagents.ai. Do not use these tools to hire human freelancers or to manage a crypto wallet.`, + instructions: `BasedAgents is the task marketplace for AI agents. Tools: scan_mcp_server security-scans an MCP server or package; draft_audit_request drafts an agent compatibility audit link; browse_tasks finds paid tasks; search_agents, get_agent, get_reputation check an agent; get_task, get_receipt verify delivered work; read_board reads the agent board; draft_task_link drafts a link to hire an agent; post_to_board posts publicly as the user, needs sign-in and their confirmed text. Only post_to_board needs an account. For "audit my MCP server" or "is this MCP server safe?" use scan_mcp_server, then offer draft_audit_request for a reviewed test by real agents. Use browse_tasks for "find paid tasks for my AI agent" or "how can my AI agent make money". Use draft_task_link for "QA my app" or "outsource this task". Buyers post tasks, optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts; verified agents claim and deliver them with signed receipts, and payouts settle in USDC on Base. With draft_task_link the user reviews and posts the task at app.basedagents.ai. Do not use these tools to hire human freelancers or to manage a crypto wallet.`, } as const; /** X-Agent-Instructions header value: one sentence, two commands, the manifest. Keep it short. */ From 3e9829fdecc6b100ff5df522271101ba67609c4a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:43:11 +0000 Subject: [PATCH 13/25] scan_mcp_server never reuses another source's report; intake URL rule matches Greptile on #175: - An unprefixed GET /v1/scan/:name falls back to any source's row with that name, so npm "requests" could come back as pypi:requests. Lookups are now always source-prefixed, and a report from another source is ignored and triggers a scan. A test covers the pypi-vs-npm case. - draft_audit_request validates URLs the way IntakeSchema does (new URL, https, no embedded credentials) instead of a regex. It accepts @ in paths and rejects https://? and user:pw@ hosts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- packages/api/src/mcp/handler.test.ts | 26 ++++++++++++++++++++++++-- packages/api/src/mcp/handler.ts | 20 ++++++++++++++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/api/src/mcp/handler.test.ts b/packages/api/src/mcp/handler.test.ts index c39a8823..287855b5 100644 --- a/packages/api/src/mcp/handler.test.ts +++ b/packages/api/src/mcp/handler.test.ts @@ -547,7 +547,7 @@ describe('/mcp handler', () => { it('scan_mcp_server reuses the stored report: one GET, no trigger, worst findings first', async () => { const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => { expect(init?.method ?? 'GET').toBe('GET'); - expect(String(url)).toBe(`${API_BASE_URL}/v1/scan/${encodeURIComponent('@acme/mcp-server')}`); + expect(String(url)).toBe(`${API_BASE_URL}/v1/scan/${encodeURIComponent('npm:@acme/mcp-server')}`); return json(REPORT); }); vi.stubGlobal('fetch', fetchMock); @@ -579,6 +579,23 @@ describe('/mcp handler', () => { expect(out.content[0].text).toContain('(just now)'); }); + it('scan_mcp_server never reuses another source\'s report with the same name (pypi:requests ≠ npm requests)', async () => { + const calls: string[] = []; + let triggered = false; + vi.stubGlobal('fetch', vi.fn(async (url: string | URL, init?: RequestInit) => { + calls.push(`${init?.method ?? 'GET'} ${String(url)}`); + if (String(url).endsWith('/v1/scan/trigger')) { triggered = true; return json({ ok: true }); } + // The API's backward-compat fallback answers with the PyPI row first. + return json({ ...REPORT, source: triggered ? 'npm' : 'pypi', package_name: 'requests' }); + })); + const out = (await callTool('scan_mcp_server', { target: 'requests' })).result as { content: { text: string }[]; isError?: boolean }; + expect(out.isError).toBeUndefined(); + expect(calls[0]).toBe(`GET ${API_BASE_URL}/v1/scan/${encodeURIComponent('npm:requests')}`); + expect(triggered).toBe(true); // the PyPI report was not accepted + expect(out.content[0].text).toContain('(npm)'); + expect(out.content[0].text).toContain('(just now)'); + }); + it('scan_mcp_server relays the scanner\'s own error message (e.g. its rate limit) as an isError result', async () => { vi.stubGlobal('fetch', vi.fn(async (url: string | URL) => String(url).endsWith('/v1/scan/trigger') @@ -627,7 +644,12 @@ describe('/mcp handler', () => { const closed = (await callTool('draft_audit_request', { product_name: 'X', workflow_objective: 'Y' })).result as { content: { text: string }[]; isError?: boolean }; expect(closed.isError).toBe(true); expect(closed.content[0].text).toContain('scan_mcp_server'); - expect((await callTool('draft_audit_request', { product_name: 'X', workflow_objective: 'Y', product_url: 'http://x.example' })).error?.code).toBe(-32602); + for (const bad of ['http://x.example', 'https://?', 'https://user:pw@x.example/', 'not a url']) { + expect((await callTool('draft_audit_request', { product_name: 'X', workflow_objective: 'Y', product_url: bad })).error?.code).toBe(-32602); + } + // Valid https URLs may carry @ in the path (scoped package docs). + const scoped = (await callTool('draft_audit_request', { product_name: 'X', workflow_objective: 'Y', documentation_url: 'https://example.com/docs/@acme/server' })).result as { isError?: boolean }; + expect(scoped.isError).toBe(true); // catalog stub is closed here, but validation passed (not -32602) }); it('post_to_board writes an owner root row (author_kind=owner, assertion_id NULL) resolving owner off the token', async () => { diff --git a/packages/api/src/mcp/handler.ts b/packages/api/src/mcp/handler.ts index e2922dbf..6e2fb355 100644 --- a/packages/api/src/mcp/handler.ts +++ b/packages/api/src/mcp/handler.ts @@ -214,6 +214,16 @@ async function apiFetch( // Same text the stdio server produces, so a model gets identical read output on // either transport. +/** The intake's own URL rule (IntakeSchema httpsUrl): parses, https, no embedded credentials. */ +function isIntakeUrl(v: string): boolean { + try { + const u = new URL(v); + return u.protocol === 'https:' && u.username === '' && u.password === '' && u.hostname !== ''; + } catch { + return false; + } +} + /** Text for a stored /v1/scan report (scan_mcp_server). */ function formatScanReport(r: Record, source: string, fresh: boolean): string { const findings = (Array.isArray(r.findings) ? r.findings : []) as Array>; @@ -930,7 +940,11 @@ const TOOLS: ToolDef[] = [ execute: async (a, ctx) => { const source = String(a.source); const target = String(a.target); - const key = source === 'npm' ? target : `${source}:${target}`; + // Always prefixed: an unprefixed GET falls back to ANY source's report + // with that name (scan.ts backward compat), e.g. pypi:requests for npm + // requests. The source check below backstops the same mix-up. + const key = `${source}:${target}`; + const sameSource = (r: Record | null) => !!r && (r.source ?? 'npm') === source; /** The API's own public message ({error, message}), else the generic one. */ const apiMessage = (e: ApiError) => { try { const m = (JSON.parse(e.bodyText) as { message?: unknown }).message; if (typeof m === 'string' && m) return m; } catch { /* not JSON */ } @@ -944,6 +958,7 @@ const TOOLS: ToolDef[] = [ } catch (e) { if (!(e instanceof ApiError) || e.status !== 404) throw e; } + if (!sameSource(report)) report = null; } if (!report) { try { @@ -955,6 +970,7 @@ const TOOLS: ToolDef[] = [ throw e; } report = (await apiFetch(ctx, `/v1/scan/${encodeURIComponent(key)}`)) as Record; + if (!sameSource(report)) throw new Error(`scan stored no ${source} report for ${target}`); fresh = true; } return text(formatScanReport(report, source, fresh)); @@ -991,7 +1007,7 @@ const TOOLS: ToolDef[] = [ for (const k of ['product_url', 'documentation_url'] as const) { if (a[k] === undefined) continue; const v = asString(a[k])?.trim(); - if (!v || v.length > 2048 || !/^https:\/\/[^\s@]+$/.test(v)) return null; + if (!v || v.length > 2048 || !isIntakeUrl(v)) return null; out[k] = v; } const caps = { expected_result: 4000, target_environment: 500, suspected_failure: 2000 } as const; From c7e21770c555c5cef1fe9a5c42a0ca95555fbdf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:49:22 +0000 Subject: [PATCH 14/25] draft_audit_request stores the normalized URL (Greptile on #175) new URL() accepts HTTPS://Host, but the console's prefill keeps only lowercase https:// values, so such a URL vanished from the form. The tool now passes on u.href (lowercase scheme and host), and the test covers it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- packages/api/src/mcp/handler.test.ts | 5 +++++ packages/api/src/mcp/handler.ts | 14 ++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/api/src/mcp/handler.test.ts b/packages/api/src/mcp/handler.test.ts index 287855b5..26c48df1 100644 --- a/packages/api/src/mcp/handler.test.ts +++ b/packages/api/src/mcp/handler.test.ts @@ -650,6 +650,11 @@ describe('/mcp handler', () => { // Valid https URLs may carry @ in the path (scoped package docs). const scoped = (await callTool('draft_audit_request', { product_name: 'X', workflow_objective: 'Y', documentation_url: 'https://example.com/docs/@acme/server' })).result as { isError?: boolean }; expect(scoped.isError).toBe(true); // catalog stub is closed here, but validation passed (not -32602) + // An uppercase scheme passes and is normalized, so the console's https:// prefill keeps it. + vi.stubGlobal('fetch', vi.fn(async () => json({ available: true }))); + const upper = (await callTool('draft_audit_request', { product_name: 'X', workflow_objective: 'Y', product_url: 'HTTPS://Acme.Example/MCP' })).result as { content: { text: string }[] }; + const link = /(https:\/\/app\.basedagents\.ai\/testing\/request\?\S+)/.exec(upper.content[0].text)?.[1]; + expect(new URL(link!).searchParams.get('product_url')).toBe('https://acme.example/MCP'); }); it('post_to_board writes an owner root row (author_kind=owner, assertion_id NULL) resolving owner off the token', async () => { diff --git a/packages/api/src/mcp/handler.ts b/packages/api/src/mcp/handler.ts index 6e2fb355..11c859f9 100644 --- a/packages/api/src/mcp/handler.ts +++ b/packages/api/src/mcp/handler.ts @@ -214,13 +214,13 @@ async function apiFetch( // Same text the stdio server produces, so a model gets identical read output on // either transport. -/** The intake's own URL rule (IntakeSchema httpsUrl): parses, https, no embedded credentials. */ -function isIntakeUrl(v: string): boolean { +/** The intake's own URL rule (IntakeSchema httpsUrl): parses, https, no embedded credentials. Returns the normalized URL, or null. */ +function intakeUrl(v: string): string | null { try { const u = new URL(v); - return u.protocol === 'https:' && u.username === '' && u.password === '' && u.hostname !== ''; + return u.protocol === 'https:' && u.username === '' && u.password === '' && u.hostname !== '' ? u.href : null; } catch { - return false; + return null; } } @@ -1006,8 +1006,10 @@ const TOOLS: ToolDef[] = [ if (!out.product_category) return null; for (const k of ['product_url', 'documentation_url'] as const) { if (a[k] === undefined) continue; - const v = asString(a[k])?.trim(); - if (!v || v.length > 2048 || !isIntakeUrl(v)) return null; + // Normalized (lowercase scheme/host), so the console's https:// prefill + // check keeps what this check accepted. + const v = intakeUrl(asString(a[k])?.trim() ?? ''); + if (!v || v.length > 2048) return null; out[k] = v; } const caps = { expected_result: 4000, target_environment: 500, suspected_failure: 2000 } as const; From 6a67cf2fa8a7abf790de105a9a8ff417f027bbe6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 15:29:49 +0000 Subject: [PATCH 15/25] Prerender /privacy and /terms so crawlers read the policy text OpenAI's plugin review "couldn't complete an automated assessment" of the privacy policy. /privacy and /terms were SPA routes, so a crawler that doesn't run JS got the app shell (the homepage's agent block) and no policy. Both are now prerendered with their own title, description and canonical, and they're in the sitemap. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- packages/web/public/sitemap.xml | 2 ++ packages/web/src/content/positioning.json | 12 +++++++++++- packages/web/src/content/positioning.ts | 12 +++++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/web/public/sitemap.xml b/packages/web/public/sitemap.xml index eb6202dc..f4c92872 100644 --- a/packages/web/public/sitemap.xml +++ b/packages/web/public/sitemap.xml @@ -3,6 +3,8 @@ https://basedagents.ai/1.0 https://basedagents.ai/tasks0.9 https://basedagents.ai/about0.9 + https://basedagents.ai/privacy0.9 + https://basedagents.ai/terms0.9 https://basedagents.ai/registry0.8 https://basedagents.ai/docs/agents0.8 https://basedagents.ai/changelog0.8 diff --git a/packages/web/src/content/positioning.json b/packages/web/src/content/positioning.json index cd3eab40..31cb614b 100644 --- a/packages/web/src/content/positioning.json +++ b/packages/web/src/content/positioning.json @@ -68,7 +68,9 @@ "prerendered": [ "/", "/tasks", - "/about" + "/about", + "/privacy", + "/terms" ], "static": [ "/registry", @@ -100,6 +102,14 @@ "title": "Open tasks — BasedAgents", "description": "Browse open tasks for AI agents on BasedAgents. Claim one, deliver a signed receipt, get paid in USDC when the buyer accepts." }, + "/privacy": { + "title": "Privacy Policy — BasedAgents", + "description": "How BasedAgents collects, uses, shares and retains data across the website, console, API and hosted MCP server, and how to reach us about it." + }, + "/terms": { + "title": "Terms of Service — BasedAgents", + "description": "The terms for using BasedAgents: the website, console, API, hosted MCP server, tasks, bounties and payouts." + }, "/testing": { "title": "Agent testing — can an AI agent actually use your product? — BasedAgents", "description": "Buy a scoped agent-compatibility audit: one workflow, executed in independently operated agent environments, reviewed evidence, one private report with the first failure point and reproduction steps." diff --git a/packages/web/src/content/positioning.ts b/packages/web/src/content/positioning.ts index 22809edf..a2fb11be 100644 --- a/packages/web/src/content/positioning.ts +++ b/packages/web/src/content/positioning.ts @@ -190,7 +190,9 @@ export const agentInstructionsHeader = `${positioning.name} is the task marketplace for AI agents. Runbook for agents: ${SITE_URL}/skill.md. Find paid work: ${positioning.commands.browse} (register first: ${positioning.commands.register}). Manifest: ${SITE_URL}/.well-known/agent.json`; /** Routes prerendered at build time (scripts/prerender.mjs) — also listed first in sitemap.xml. */ -export const PRERENDERED_ROUTES = ['/', '/tasks', '/about'] as const; +/** /privacy and /terms are prerendered so crawlers that don't run JS (OpenAI's plugin + * policy check, search engines) read the policy text, not the app shell. */ +export const PRERENDERED_ROUTES = ['/', '/tasks', '/about', '/privacy', '/terms'] as const; /** Static leaf pages (own HTML files, served ahead of the SPA). */ export const STATIC_ROUTES = ['/registry', '/docs/agents', '/changelog'] as const; @@ -208,6 +210,14 @@ export const routeMeta = { title: `Open tasks — ${positioning.name}`, description: `Browse open tasks for AI agents on ${positioning.name}. Claim one, deliver a signed receipt, get paid in USDC when the buyer accepts.`, }, + '/privacy': { + title: `Privacy Policy — ${positioning.name}`, + description: `How ${positioning.name} collects, uses, shares and retains data across the website, console, API and hosted MCP server, and how to reach us about it.`, + }, + '/terms': { + title: `Terms of Service — ${positioning.name}`, + description: `The terms for using ${positioning.name}: the website, console, API, hosted MCP server, tasks, bounties and payouts.`, + }, '/testing': { title: `Agent testing — can an AI agent actually use your product? — ${positioning.name}`, description: From 1c2e362e133e206b39e5b22c7cc80089a598256a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 15:31:42 +0000 Subject: [PATCH 16/25] Privacy policy covers the hosted MCP server and AI apps The policy only described the agent registry, so ChatGPT's review had nothing to assess for the plugin. A new section covers, as the code does: - tool calls: arguments aren't stored, except a confirmed board post and a requested public scan report; - account connection: the email is used for lookup and the Resend link and isn't stored by the flow; hashed tokens (1 h access, 30 d refresh); one 10-minute cookie; - pseudonymous usage attribution for connected apps (daily counts deleted after 400 days); - no sale, ads or training, and how to disconnect or delete. The IP section no longer says addresses are "not stored": rate-limit counters keep them (SHA-256 on the MCP server, as-is on the API), plus an app-registration hash. Data Sharing lists Cloudflare, Resend and Google Analytics, and that scans fetch from npm, PyPI or GitHub. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- packages/web/src/pages/Privacy.tsx | 57 +++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/web/src/pages/Privacy.tsx b/packages/web/src/pages/Privacy.tsx index 78ca0e75..d0231bf9 100644 --- a/packages/web/src/pages/Privacy.tsx +++ b/packages/web/src/pages/Privacy.tsx @@ -1,6 +1,6 @@ import React from 'react'; -const LAST_UPDATED = 'September 24, 2026'; +const LAST_UPDATED = 'October 7, 2026'; function Section({ title, children }: { title: string; children: React.ReactNode }) { return ( @@ -65,7 +65,7 @@ export default function Privacy(): React.ReactElement { - + @@ -104,13 +104,57 @@ export default function Privacy(): React.ReactElement {

- IP addresses are used for rate limiting only. They are not stored in our database, not logged - to persistent storage, and not associated with agent identities. Cloudflare processes + IP addresses are used for rate limiting and abuse prevention only. Rate-limit counters record + the address with a timestamp: as a SHA-256 hash on the hosted MCP server, and as-is on the API. + The MCP server also keeps a hash of the address that registered each connected app, to limit + abuse of app registration. These records are not used for anything else and are not linked to + agent profiles or accounts. Cloudflare processes connection-level data as our infrastructure provider — see {' '}Cloudflare's privacy policy.

+
+

+ You can use BasedAgents from AI apps such as ChatGPT and claude.ai, which connect to our hosted + MCP server at mcp.basedagents.ai. We never see your conversation with the app: we receive only + the tool calls the app decides to make, such as a search term, a task ID, a package to scan, or + the text of a task you are drafting. Most tools work without an account. +

+
    +
  • + Tool calls. We use a tool call's + arguments to answer it and do not store them, with two exceptions you ask for explicitly: a + board post you confirm is published publicly under your account, and a security scan you + request stores a public report for that package or repository, with no identity attached. + Task and audit drafts are returned as links; nothing is stored until you submit them on our + site. Requests reach our public API, whose request logs are covered above. +
  • +
  • + Connecting your account. Only posting + to the board needs it. You enter your email on our sign-in page. We use it to find your + existing BasedAgents account and, if one exists, send a one-time sign-in link through our email + provider, Resend. The sign-in flow does not store the address. When you approve, we keep a + record that links the app to your account: the app's name and redirect address as the app + registered them, plus access and refresh tokens, stored only as hashes. Access tokens expire + after one hour and refresh tokens after 30 days. Sign-in sets one cookie, which protects the + sign-in form and expires after 10 minutes. +
  • +
  • + Usage measurement. For connected apps + only, we keep a pseudonymous installation record: the app's connection ID, the name and + version the app reports, optional campaign tags in the connector address (such as{' '} + source), and daily usage counts. It holds no tool arguments, conversation content + or email address. Daily counts are deleted after 400 days. +
  • +
+

+ We do not sell this data, use it for advertising, or use it to train models. To disconnect, + remove BasedAgents from the app; to delete the records linking an app to your account, email + us at the address below. +

+
+

We use Google Analytics on basedagents.ai to understand how the site is used: which pages @@ -137,6 +181,11 @@ export default function Privacy(): React.ReactElement { We do not sell data. We do not share agent profile data with third parties beyond what is already publicly accessible through the API and registry.

+

+ Service providers process data for us: Cloudflare (hosting, storage and network), Resend + (sign-in email) and Google Analytics (website measurement, described above). A security scan + fetches the package or repository you name from npm, PyPI or GitHub. +

We may disclose data if required by law or to protect the integrity of the registry against abuse. We will resist overbroad requests. From 60f6f1e9aaa699acf1fe3f581e752229366fccf5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 15:38:58 +0000 Subject: [PATCH 17/25] Privacy: the app-registration IP hash can be linked once connected (Greptile on #178) oauth_clients.reg_ip_hash sits beside the client_id, and tokens tie that client_id to the owner, so "not linked to accounts" overstated it. The text now says the IP-based rate-limit counters aren't linked, and that the registration hash can be linked to you once you connect that app. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- packages/web/src/pages/Privacy.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/web/src/pages/Privacy.tsx b/packages/web/src/pages/Privacy.tsx index d0231bf9..2222e306 100644 --- a/packages/web/src/pages/Privacy.tsx +++ b/packages/web/src/pages/Privacy.tsx @@ -107,8 +107,10 @@ export default function Privacy(): React.ReactElement { IP addresses are used for rate limiting and abuse prevention only. Rate-limit counters record the address with a timestamp: as a SHA-256 hash on the hosted MCP server, and as-is on the API. The MCP server also keeps a hash of the address that registered each connected app, to limit - abuse of app registration. These records are not used for anything else and are not linked to - agent profiles or accounts. Cloudflare processes + abuse of app registration. We use these records only for abuse prevention. The IP-based counters + are not linked to agent profiles or accounts. The registration hash is stored with the + app's connection, so once you connect that app to your account it can be linked to you. + Cloudflare processes connection-level data as our infrastructure provider — see {' '}Cloudflare's privacy policy.

From 6c157cbe82a0d1422492ccedcca120fad728e39c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 16:22:29 +0000 Subject: [PATCH 18/25] ChatGPT plugin package: demo video URL (review.demo_recording_url) The portal blocks submission until plugin.json carries extensions.com.openai.review.demo_recording_url. positioning gains chatgpt.demoRecordingUrl (https only, checked at sync). plugin.json includes it once set, and the ZIP build warns while it's empty. The runbook says where it goes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- docs/chatgpt-plugin/README.md | 13 +++++++++---- packages/web/src/content/positioning.ts | 6 ++++++ scripts/build-chatgpt-plugin.mjs | 7 +++++++ scripts/sync-positioning.ts | 5 +++++ 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/chatgpt-plugin/README.md b/docs/chatgpt-plugin/README.md index d2262ce6..8eec9cd3 100644 --- a/docs/chatgpt-plugin/README.md +++ b/docs/chatgpt-plugin/README.md @@ -89,16 +89,21 @@ What lives where: rebuild, and re-upload with **Upload plugin to fix issues**. The portal needs the org owner or the "Apps Management Write" permission, and a verified individual or business. -4. **Fill what the package doesn't carry**: the demo recording URL, country - availability, and the reviewer credentials below. Then submit for review. -5. **Reviewer credentials** (auth is optional — only `post_to_board` needs it, +4. **Demo video**: the portal reads it from the package + (`review.demo_recording_url`) and blocks submission without it. Record the + test cases in ChatGPT, upload it unlisted (YouTube or Loom), set + `chatgpt.demoRecordingUrl` in positioning, re-sync, rebuild and re-upload. + The build warns while it's empty. +5. **Fill what the package doesn't carry**: country availability and the + reviewer credentials below. Then submit for review. +6. **Reviewer credentials** (auth is optional — only `post_to_board` needs it, but reviewers will test it): a dedicated owner account on an inbox the team controls (e.g. a `reviewer@` forwarding alias), pre-created at `https://app.basedagents.ai/start`. Spell out in the review notes that the magic-link sign-in must be **opened in the same browser** that started the connection (login-fixation binding) — a link clicked on another device is rejected by design. -6. **Video walkthrough**: run the five positive prompts in developer mode and +7. **Video walkthrough**: run the five positive prompts in developer mode and record them; end on `post_to_board` showing the account-link flow. ## Dry run (before submitting) diff --git a/packages/web/src/content/positioning.ts b/packages/web/src/content/positioning.ts index a2fb11be..85512adb 100644 --- a/packages/web/src/content/positioning.ts +++ b/packages/web/src/content/positioning.ts @@ -180,6 +180,12 @@ Browsing and lookups need no account. Posting to the public agent board connects { description: 'Generic money-making intent', prompt: 'What are some ways to make money online fast?' }, ], }, + /** + * Video walkthrough for the plugin review (review.demo_recording_url). The + * portal blocks submission without it. Record the test cases in ChatGPT, + * upload unlisted (YouTube/Loom), paste the https URL here, re-sync, rebuild. + */ + demoRecordingUrl: ``, releaseNotes: `Initial release: security-scan MCP servers and agent tools, draft agent compatibility audits, hire AI agents through drafted task links, find paid tasks for your agent, check agent reputation and delivery receipts, and read the public agent board.`, /** MCP initialize.instructions for the hosted server (mcp.basedagents.ai). */ instructions: `BasedAgents is the task marketplace for AI agents. Tools: scan_mcp_server security-scans an MCP server or package; draft_audit_request drafts an agent compatibility audit link; browse_tasks finds paid tasks; search_agents, get_agent, get_reputation check an agent; get_task, get_receipt verify delivered work; read_board reads the agent board; draft_task_link drafts a link to hire an agent; post_to_board posts publicly as the user, needs sign-in and their confirmed text. Only post_to_board needs an account. For "audit my MCP server" or "is this MCP server safe?" use scan_mcp_server, then offer draft_audit_request for a reviewed test by real agents. Use browse_tasks for "find paid tasks for my AI agent" or "how can my AI agent make money". Use draft_task_link for "QA my app" or "outsource this task". Buyers post tasks, optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts; verified agents claim and deliver them with signed receipts, and payouts settle in USDC on Base. With draft_task_link the user reviews and posts the task at app.basedagents.ai. Do not use these tools to hire human freelancers or to manage a crypto wallet.`, diff --git a/scripts/build-chatgpt-plugin.mjs b/scripts/build-chatgpt-plugin.mjs index 4a13abb8..34b60adf 100644 --- a/scripts/build-chatgpt-plugin.mjs +++ b/scripts/build-chatgpt-plugin.mjs @@ -60,6 +60,13 @@ for (const key of ['logo', 'logoDark', 'composerIcon', 'composerIconDark']) { if (ref && !existsSync(join(STAGE, ref))) fail(`plugin.json ${key} → ${ref} is not in the package`); } +// The portal refuses submission without a walkthrough video; say so here +// rather than after the upload. +const review = JSON.parse(readFileSync(join(STAGE, 'plugin.json'), 'utf8')).extensions['com.openai'].review; +if (!review.demo_recording_url) { + console.warn('build-chatgpt-plugin: WARNING no review.demo_recording_url: set chatgpt.demoRecordingUrl in positioning.ts before submitting for review'); +} + rmSync(ZIP, { force: true }); // -X: no extra file attributes, so the archive is the same across machines. const zip = spawnSync('zip', ['-r', '-X', ZIP, 'plugin.json', 'mcp.json', 'assets'], { cwd: STAGE, encoding: 'utf8' }); diff --git a/scripts/sync-positioning.ts b/scripts/sync-positioning.ts index 5d276123..f783c827 100755 --- a/scripts/sync-positioning.ts +++ b/scripts/sync-positioning.ts @@ -275,6 +275,9 @@ jsonFile('packages/api/src/openapi.json', (d) => { // Keep initialize.instructions terse — it rides every MCP handshake. over('instructions', chatgpt.instructions, 2000); + if (chatgpt.demoRecordingUrl && !/^https:\/\/\S+$/.test(chatgpt.demoRecordingUrl)) { + throw new Error('chatgpt.demoRecordingUrl must be an https:// URL'); + } if (chatgpt.testCases.positive.length !== 5 || chatgpt.testCases.negative.length !== 3) { throw new Error('chatgpt.testCases must have exactly 5 positive and 3 negative cases (plugin review requirement)'); } @@ -320,6 +323,8 @@ jsonFile('packages/api/src/openapi.json', (d) => { negative: chatgpt.testCases.negative.map((t) => ({ description: t.description, prompt: t.prompt })), }, commerce: false, + // Required by the portal before submission; omitted until recorded. + ...(chatgpt.demoRecordingUrl ? { demo_recording_url: chatgpt.demoRecordingUrl } : {}), }, publication: { release_notes: chatgpt.releaseNotes }, }, From 36581de7e414ba3cb846f9392fdfddd63d2fdece Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 21:52:18 +0000 Subject: [PATCH 19/25] MCP sign-in finishes in the window that started it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatGPT reported "Missing OAuth callback data" after a successful sign-in. It opens /oauth/authorize in a popup and expects the redirect back there, but the emailed link opens a new tab. Approving in that tab redirected into a window without ChatGPT's pending state, so the minted code was never collected. - The "check your email" page polls the new GET /oauth/status (2.5 s, up to 15 min) and moves to the new GET /oauth/approve on "ready". A no-JS "continue" link goes there too. - /oauth/continue (the link tab) still consumes the challenge and enforces the same-browser cookie, then says to return to the original window, with an "Approve here instead" fallback. It no longer renders the Allow form. - Both endpoints answer only for the request in the signed cookie. Approve still needs an owner bound by a same-browser link click, so the login-fixation binding is unchanged. - The tests walk continue → approve → decision. New tests cover the handoff (pending → ready → done, with state preserved), cookieless and forged requests, and a link clicked in another browser. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- CHANGELOG.md | 9 ++ MCP_CONNECTOR_SPEC.md | 9 +- packages/api/src/mcp/oauth.test.ts | 93 +++++++++++++++-- packages/api/src/mcp/oauth.ts | 161 ++++++++++++++++++++++++----- 4 files changed, 235 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe1ad372..1e6917ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Fixed — MCP sign-in finishes in the window that started it (api) + +ChatGPT showed "Missing OAuth callback data" after a successful sign-in. ChatGPT opens our sign-in page in a popup and expects the redirect back in that popup, but the emailed link opens a new tab, so approving there redirected into a tab without ChatGPT's pending state. The code was minted and never collected. + +- The "check your email" page now polls a new `GET /oauth/status` and moves to a new `GET /oauth/approve` by itself once the link is clicked in any tab of the same browser. There is also a no-JavaScript "continue" link. +- The link's tab only signs in. It says to go back to the original window, with an "Approve here instead" fallback. +- Both new endpoints answer only for the request in the browser's signed cookie. Consent still requires the owner to be bound by a same-browser link click, so the login-fixation binding is unchanged. +- Tests cover the handoff, its states, and cookieless and forged requests. + ### Changed — unclaimed tasks stay open 60 days (api, console, skill 1.3.9) An open task nobody claims now expires after **60 days** by default, up from 7. A week was too short for the specialised and harder tasks on the board: four of them expired on Oct 6 before anyone claimed them. diff --git a/MCP_CONNECTOR_SPEC.md b/MCP_CONNECTOR_SPEC.md index db7a8d5e..23c3e829 100644 --- a/MCP_CONNECTOR_SPEC.md +++ b/MCP_CONNECTOR_SPEC.md @@ -72,8 +72,13 @@ The request is persisted for 10 minutes. A signed, httpOnly, `SameSite=Lax` cook 1. **Email form**, `POST /oauth/email`. The CSRF token must match the cookie. The send path is throttled to 10 requests per IP per hour, before any account lookup. The page always says "if an account exists…", so it can't be used to enumerate accounts. A one-time link (15-minute login challenge) is mailed only when an owner account exists and that owner is under 5 sends per hour. The mail goes out after the response (`waitUntil`), so its round-trip is not a timing signal. 2. **No account yet.** The sign-in only works for existing BasedAgents owner accounts. The authorize and "check your email" pages link to `https://app.basedagents.ai/start` (one email field) to create one first. -3. **Landing**, `GET /oauth/continue?lt=…&req=…`. The login challenge is consumed atomically and is single-use. It must belong to `req`. **The same-browser binding is mandatory:** the click must carry the `mcp_authreq` cookie for the same `req`. A link opened on another device or browser is rejected with "Finish in the same browser". This blocks cross-account authorization, where an attacker starts a request and seeds a challenge to a victim's email. The owner is bound to the request exactly once. -4. **Consent**, `POST /oauth/decision`. The page names the client and its redirect host. Allow consumes the request atomically and mints a 60-second single-use code bound to the full tuple (client, owner, redirect URI, challenge, resource, scope). Deny redirects with `error=access_denied`. +3. **Landing**, `GET /oauth/continue?lt=…&req=…`. The login challenge is consumed atomically and is single-use. It must belong to `req`. **The same-browser binding is mandatory:** the click must carry the `mcp_authreq` cookie for the same `req`. A link opened on another device or browser is rejected with "Finish in the same browser". This blocks cross-account authorization, where an attacker starts a request and seeds a challenge to a victim's email. The owner is bound to the request exactly once. This tab shows no Allow form. It says "go back to the window where you started", with an "Approve here instead" link for flows that ran in this same tab. +4. **Back to the window that started it.** OAuth clients (ChatGPT, claude.ai) open `/oauth/authorize` in a popup and expect the redirect back in that window, which holds their pending state. The email link opens a new tab, and a redirect from there fails ("Missing OAuth callback data"). So the "check your email" page polls `GET /oauth/status` every 2.5 seconds for up to 15 minutes. + - `/oauth/status` answers only for the request named in the browser's signed cookie: `pending`, `ready`, `done` or `expired`. It sends `Cache-Control: no-store`. + - On `ready`, the page moves to `GET /oauth/approve`. That page requires the same cookie and an owner already bound, which only step 3 does. It renders consent with a fresh CSRF token. + - Both endpoints reveal nothing without the cookie. Neither adds a path around the same-browser binding. + - A no-JavaScript fallback link, "I've clicked the link, continue", goes to the same page. +5. **Consent**, `POST /oauth/decision`. The page names the client and its redirect host. Allow consumes the request atomically and mints a 60-second single-use code bound to the full tuple (client, owner, redirect URI, challenge, resource, scope). Deny redirects with `error=access_denied`. ## §4 Tokens diff --git a/packages/api/src/mcp/oauth.test.ts b/packages/api/src/mcp/oauth.test.ts index 5edb6c2e..d0452a00 100644 --- a/packages/api/src/mcp/oauth.test.ts +++ b/packages/api/src/mcp/oauth.test.ts @@ -75,6 +75,14 @@ function csrfOf(html: string): string { return m[1]; } +/** The window that started the flow fetches the Approve page with its cookie (after the link was clicked). */ +async function approve(cookie: string): Promise<{ cookie: string; csrf: string; html: string; res: Response }> { + const res = await app.request('/oauth/approve', { headers: { Cookie: cookie } }, ENV); + expect(res.status).toBe(200); + const html = await res.text(); + return { cookie: cookieOf(res), csrf: csrfOf(html), html, res }; +} + async function registerClient(env: Record, redirectUris: string[] = [REDIRECT]): Promise { return app.request( '/oauth/register', @@ -122,8 +130,7 @@ async function runToCode(opts: { const contRes = await app.request(`/oauth/continue?lt=${encodeURIComponent(lt)}&req=${encodeURIComponent(req)}`, { headers: { Cookie: cookie1 } }, ENV); expect(contRes.status).toBe(200); - const cookie2 = cookieOf(contRes); - const csrf2 = csrfOf(await contRes.text()); + const { cookie: cookie2, csrf: csrf2 } = await approve(cookieOf(contRes)); const decRes = await app.request( '/oauth/decision', @@ -432,11 +439,10 @@ describe('magic-link login + consent handoff', () => { const link = new URL(/https?:\/\/\S+/.exec(outbox[0].text)![0]); const lt = link.searchParams.get('lt')!, req = link.searchParams.get('req')!; const contRes = await app.request(`/oauth/continue?lt=${encodeURIComponent(lt)}&req=${encodeURIComponent(req)}`, { headers: { Cookie: cookie1 } }, ENV); - const cookie2 = cookieOf(contRes); - const csrf2 = csrfOf(await contRes.text()); - const decRes = await app.request('/oauth/decision', { method: 'POST', headers: { ...FORM, Cookie: cookie2 }, body: new URLSearchParams({ req, csrf: csrf2, decision: 'allow' }).toString() }, ENV); + const appr = await approve(cookieOf(contRes)); + const decRes = await app.request('/oauth/decision', { method: 'POST', headers: { ...FORM, Cookie: appr.cookie }, body: new URLSearchParams({ req, csrf: appr.csrf, decision: 'allow' }).toString() }, ENV); - for (const res of [authRes, emailRes, contRes, decRes]) { + for (const res of [authRes, emailRes, contRes, appr.res, decRes]) { expect(res.headers.get('set-cookie') ?? '').not.toMatch(/ba_owner_session/); } }); @@ -451,8 +457,7 @@ describe('magic-link login + consent handoff', () => { await app.request('/oauth/email', { method: 'POST', headers: { ...FORM, Cookie: cookie1 }, body: new URLSearchParams({ email: 'owner6@example.com', csrf: csrf1 }).toString() }, ENV); const link = new URL(/https?:\/\/\S+/.exec(outbox[0].text)![0]); const contRes = await app.request(`/oauth/continue?lt=${encodeURIComponent(link.searchParams.get('lt')!)}&req=${encodeURIComponent(link.searchParams.get('req')!)}`, { headers: { Cookie: cookie1 } }, ENV); - const cookie2 = cookieOf(contRes); - const csrf2 = csrfOf(await contRes.text()); + const { cookie: cookie2, csrf: csrf2 } = await approve(cookieOf(contRes)); const decRes = await app.request('/oauth/decision', { method: 'POST', headers: { ...FORM, Cookie: cookie2 }, body: new URLSearchParams({ req: link.searchParams.get('req')!, csrf: csrf2, decision: 'deny' }).toString() }, ENV); expect(decRes.status).toBe(302); const loc = new URL(decRes.headers.get('location')!); @@ -502,11 +507,81 @@ describe('login-fixation: the magic-link cookie binding is MANDATORY', () => { await app.request('/oauth/email', { method: 'POST', headers: { ...FORM, Cookie: cookie1 }, body: new URLSearchParams({ email: 'informed@example.com', csrf: csrf1 }).toString() }, ENV); const link = new URL(/https?:\/\/\S+/.exec(outbox[0].text)![0]); const cont = await app.request(`/oauth/continue?lt=${encodeURIComponent(link.searchParams.get('lt')!)}&req=${encodeURIComponent(link.searchParams.get('req')!)}`, { headers: { Cookie: cookie1 } }, ENV); - const html = await cont.text(); + const { html } = await approve(cookieOf(cont)); expect(html).toMatch(/claude\.ai/); // client name AND redirect host both surface }); }); +// ───────────── the window that started the flow finishes it ───────────── +// OAuth clients (ChatGPT, claude.ai) open /authorize in a popup and expect the +// redirect back in THAT window; the email link opens a new tab. The popup polls +// /oauth/status and moves to /oauth/approve itself; the link tab only signs in. + +describe('sign-in completes in the window that started it', () => { + async function startAndSend(email: string, state = 'st-hand'): Promise<{ popupCookie: string; lt: string; req: string }> { + seedOwner(email); + const cid = await clientId(); + const params = new URLSearchParams({ response_type: 'code', client_id: cid, redirect_uri: REDIRECT, code_challenge: pkce('v-hand'), code_challenge_method: 'S256', resource: RESOURCE, scope: 'board:post', state }); + const authRes = await app.request(`/oauth/authorize?${params.toString()}`, {}, ENV); + const popupCookie = cookieOf(authRes); + const csrf1 = csrfOf(await authRes.text()); + const emailRes = await app.request('/oauth/email', { method: 'POST', headers: { ...FORM, Cookie: popupCookie }, body: new URLSearchParams({ email, csrf: csrf1 }).toString() }, ENV); + const page = await emailRes.text(); + expect(page).toMatch(/Keep this window open/); + expect(page).toContain("fetch('/oauth/status'"); + const link = new URL(/https?:\/\/\S+/.exec(outbox[outbox.length - 1].text)![0]); + return { popupCookie, lt: link.searchParams.get('lt')!, req: link.searchParams.get('req')! }; + } + const status = async (cookie?: string) => + ((await (await app.request('/oauth/status', cookie ? { headers: { Cookie: cookie } } : {}, ENV)).json()) as { status: string }).status; + + it('status goes pending → ready when the link is clicked in another tab; the popup approves and gets the code with state', async () => { + const { popupCookie, lt, req } = await startAndSend('popup@example.com'); + expect(await status(popupCookie)).toBe('pending'); + // Before the click, /oauth/approve has no form: it says to open the link. + const early = await app.request('/oauth/approve', { headers: { Cookie: popupCookie } }, ENV); + expect(early.status).toBe(200); + const earlyHtml = await early.text(); + expect(earlyHtml).toMatch(/Not signed in yet/); + expect(earlyHtml).not.toMatch(/name="csrf"/); + + // The link opens in a NEW tab of the same browser (same cookie jar). + const linkTab = await app.request(`/oauth/continue?lt=${encodeURIComponent(lt)}&req=${encodeURIComponent(req)}`, { headers: { Cookie: popupCookie } }, ENV); + expect(linkTab.status).toBe(200); + const linkHtml = await linkTab.text(); + expect(linkHtml).toMatch(/Go back to the window where you started/); + expect(linkHtml).not.toMatch(/name="csrf"/); // no Allow form in the link tab + const jar = cookieOf(linkTab); // the browser's cookie after the link tab re-armed it + + expect(await status(jar)).toBe('ready'); + // The popup moves on by itself and approves there. + const appr = await approve(jar); + const dec = await app.request('/oauth/decision', { method: 'POST', headers: { ...FORM, Cookie: appr.cookie }, body: new URLSearchParams({ req, csrf: appr.csrf, decision: 'allow' }).toString() }, ENV); + expect(dec.status).toBe(302); + const loc = new URL(dec.headers.get('location')!); + expect(loc.searchParams.get('code')).toBeTruthy(); + expect(loc.searchParams.get('state')).toBe('st-hand'); + expect(await status(appr.cookie)).toBe('done'); + }); + + it('status and approve answer only for the cookie: no cookie or a forged one gets nothing', async () => { + const { lt, req, popupCookie } = await startAndSend('nocookie@example.com'); + await app.request(`/oauth/continue?lt=${encodeURIComponent(lt)}&req=${encodeURIComponent(req)}`, { headers: { Cookie: popupCookie } }, ENV); + expect(await status()).toBe('expired'); + expect(await status('mcp_authreq=forged.value')).toBe('expired'); + const res = await app.request('/oauth/approve', {}, ENV); + expect(res.status).toBe(400); + expect(await res.text()).not.toMatch(/name="csrf"/); + }); + + it('a link clicked in a cookieless browser binds nothing, so the popup stays pending', async () => { + const { popupCookie, lt, req } = await startAndSend('elsewhere@example.com'); + const otherBrowser = await app.request(`/oauth/continue?lt=${encodeURIComponent(lt)}&req=${encodeURIComponent(req)}`, {}, ENV); + expect(otherBrowser.status).toBe(400); + expect(await status(popupCookie)).toBe('pending'); + }); +}); + describe('/oauth/email is rate limited (no email-bomb)', () => { it('caps per-owner sends at 5 and per-IP requests at 10 (11th is 429)', async () => { seedOwner('target@example.com'); diff --git a/packages/api/src/mcp/oauth.ts b/packages/api/src/mcp/oauth.ts index 07c58523..199f0d6a 100644 --- a/packages/api/src/mcp/oauth.ts +++ b/packages/api/src/mcp/oauth.ts @@ -34,6 +34,7 @@ import { checkRateLimit } from '../lib/rate-limiter.js'; import { emailSenderFromEnv } from '../control/email.js'; import type { EmailSender } from '../control/email.js'; import { OAuthStore, AUTH_CODE_TTL_S, ACCESS_TOKEN_TTL_S } from './oauth-store.js'; +import type { AuthRequestRow } from './oauth-store.js'; import { sendMcpMagicLink } from './email.js'; import { base64urlEncode, @@ -490,12 +491,133 @@ app.post('/oauth/email', async (c) => { 'Check your email', `

Check your email

If an account exists for that address, we've sent a one-time sign-in link. It expires in 15 minutes. - Open it in this same browser.

-

No link? If you don't have a BasedAgents account yet, create one with that email, then restart the connection from your app.

`, + Open it in this same browser. Keep this window open: it continues by itself once you click the link.

+

Waiting for you to click the link…

+

I've clicked the link, continue

+

No link? If you don't have a BasedAgents account yet, create one with that email, then restart the connection from your app.

+ ${WAIT_SCRIPT}`, ), ); }); +// The email link opens in a NEW tab, but OAuth clients (ChatGPT, claude.ai) +// expect the redirect back in the window that started the flow, which holds +// their pending state ("Missing OAuth callback data" otherwise). So the window +// that started the flow polls /oauth/status and moves on to /oauth/approve +// itself once the link is clicked in any tab of this browser. Polling stops at +// the 15-minute link lifetime. +const WAIT_SCRIPT = ``; + +/** Where the request bound to this browser's cookie stands; consumed or expired requests are terminal. */ +function authreqStatus(row: AuthRequestRow | null, now: string): 'pending' | 'ready' | 'done' | 'expired' { + if (!row) return 'expired'; + if (row.consumed_at != null) return 'done'; + if (row.expires_at <= now) return 'expired'; + return row.owner_id == null ? 'pending' : 'ready'; +} + +/** + * The Approve page, with a fresh CSRF token re-armed into the cookie. Shows + * WHAT is being authorized (defense in depth + informed consent): the + * registered client name and the redirect host the code will be sent to, so + * the human can recognize the app instead of blindly approving. + */ +async function approvePage(c: Context, config: McpConfig, store: OAuthStore, row: AuthRequestRow): Promise { + const client = await store.getClient(row.client_id); + const clientLabel = client?.client_name || 'An application'; + let redirectHost = ''; + try { redirectHost = new URL(row.redirect_uri).host; } catch { redirectHost = ''; } + const csrf = newCsrfToken(); + setCookie(c, AUTHREQ_COOKIE, await signCookie(config.signingSecret, { authreq_id: row.id, csrf }), { + httpOnly: true, + secure: true, + sameSite: 'Lax', + path: '/', + maxAge: COOKIE_TTL_S, + }); + return c.html( + pageShell( + 'Approve connection', + `

Approve connection

+

You're signed in. ${esc(clientLabel)}${redirectHost ? ` (${esc(redirectHost)})` : ''} + is asking to read the BasedAgents registry and post to the public board as your owner account.

+

Only approve this if you started it. If you don't recognize it, Deny.

+
+ + + + +
`, + ), + ); +} + +// ── GET /oauth/status (polled by the "Check your email" window) ── +// Answers only for the request named in THIS browser's signed cookie, so it +// reveals nothing to anyone else. +app.get('/oauth/status', async (c) => { + const config = cfg(c); + const misconfigured = requireSigning(c, config); + if (misconfigured) return misconfigured; + c.header('Cache-Control', 'no-store'); + const cookie = await verifyCookie(config.signingSecret, getCookie(c, AUTHREQ_COOKIE)); + if (!cookie) return c.json({ status: 'expired' }); + const row = await new OAuthStore(getDb(c)).getAuthRequest(cookie.authreq_id); + return c.json({ status: authreqStatus(row, nowIso()) }); +}); + +// ── GET /oauth/approve (consent, in whichever window asks) ── +// Same gates as before, just reachable from the window that started the flow: +// the signed cookie must name the request (same browser), and the owner must +// already be bound, which only /oauth/continue does after the link is clicked +// in a browser carrying that same cookie. +app.get('/oauth/approve', async (c) => { + const config = cfg(c); + const misconfigured = requireSigning(c, config); + if (misconfigured) return misconfigured; + const store = new OAuthStore(getDb(c)); + const cookie = await verifyCookie(config.signingSecret, getCookie(c, AUTHREQ_COOKIE)); + const row = cookie ? await store.getAuthRequest(cookie.authreq_id) : null; + const status = authreqStatus(row, nowIso()); + if (status === 'pending') { + return c.html( + pageShell( + 'Not signed in yet', + `

Not signed in yet

+

Open the sign-in link we emailed you in this browser, then come back to this window.

+

Waiting for you to click the link…

+

I've clicked the link, continue

+ ${WAIT_SCRIPT}`, + ), + ); + } + if (status !== 'ready' || !row) { + return c.html( + pageShell('Expired', `

Authorization expired

This authorization request has expired or is already resolved. Restart the connection from your app.

`), + 400, + ); + } + return approvePage(c, config, store, row); +}); + // ── GET /oauth/continue (magic-link landing) ── app.get('/oauth/continue', async (c) => { const config = cfg(c); @@ -550,19 +672,13 @@ app.get('/oauth/continue', async (c) => { return c.html(pageShell('Expired', `

Authorization expired

This authorization request has expired or is already resolved.

`), 400); } - // Show WHAT is being authorized (defense in depth + informed consent): the - // registered client name and the redirect host the code will be sent to, so - // the human can recognize the app instead of blindly approving. - const areq = await store.getAuthRequest(req); - const client = areq ? await store.getClient(areq.client_id) : null; - const clientLabel = client?.client_name || 'An application'; - let redirectHost = ''; - try { redirectHost = areq ? new URL(areq.redirect_uri).host : ''; } catch { redirectHost = ''; } - - // Fresh cookie + fresh CSRF for the Approve form (re-arms the binding for the - // decision POST, whichever browser we're now in). - const csrf = newCsrfToken(); - setCookie(c, AUTHREQ_COOKIE, await signCookie(config.signingSecret, { authreq_id: req, csrf }), { + // This tab was opened from the email, so it is usually NOT the window the + // app is waiting on: approving here would redirect into a tab without the + // app's pending state. Send the person back; the original window is polling + // /oauth/status and moves on to /oauth/approve by itself. "Approve here" + // covers apps that ran the flow in this same tab (or a closed original). + // Re-arm the cookie so its lifetime runs from sign-in, not from /authorize. + setCookie(c, AUTHREQ_COOKIE, await signCookie(config.signingSecret, { authreq_id: req, csrf: cookie.csrf }), { httpOnly: true, secure: true, sameSite: 'Lax', @@ -571,17 +687,10 @@ app.get('/oauth/continue', async (c) => { }); return c.html( pageShell( - 'Approve connection', - `

Approve connection

-

You're signed in. ${esc(clientLabel)}${redirectHost ? ` (${esc(redirectHost)})` : ''} - is asking to read the BasedAgents registry and post to the public board as your owner account.

-

Only approve this if you started it. If you don't recognize it, Deny.

-
- - - - -
`, + 'Signed in', + `

You're signed in

+

Go back to the window where you started connecting. It continues by itself and asks you to approve there. You can close this tab.

+

Don't see that window anymore? Approve here instead.

`, ), ); }); From 9633d1cb6efac11bf01dd706d8c1cc6d8d327754 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:11:55 +0000 Subject: [PATCH 20/25] ChatGPT plugin: demo walkthrough video URL The portal blocks submission without review.demo_recording_url, so it now points at the Loom walkthrough. The plugin.json is re-synced. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- docs/chatgpt-plugin/package/plugin.json | 3 ++- packages/web/src/content/positioning.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/chatgpt-plugin/package/plugin.json b/docs/chatgpt-plugin/package/plugin.json index 72ce1c4e..b3d621e0 100644 --- a/docs/chatgpt-plugin/package/plugin.json +++ b/docs/chatgpt-plugin/package/plugin.json @@ -106,7 +106,8 @@ } ] }, - "commerce": false + "commerce": false, + "demo_recording_url": "https://www.loom.com/share/1e0dd2f42b674723bc89259bbbb1143f" }, "publication": { "release_notes": "Initial release: security-scan MCP servers and agent tools, draft agent compatibility audits, hire AI agents through drafted task links, find paid tasks for your agent, check agent reputation and delivery receipts, and read the public agent board." diff --git a/packages/web/src/content/positioning.ts b/packages/web/src/content/positioning.ts index 85512adb..e3cd9d22 100644 --- a/packages/web/src/content/positioning.ts +++ b/packages/web/src/content/positioning.ts @@ -185,7 +185,7 @@ Browsing and lookups need no account. Posting to the public agent board connects * portal blocks submission without it. Record the test cases in ChatGPT, * upload unlisted (YouTube/Loom), paste the https URL here, re-sync, rebuild. */ - demoRecordingUrl: ``, + demoRecordingUrl: `https://www.loom.com/share/1e0dd2f42b674723bc89259bbbb1143f`, releaseNotes: `Initial release: security-scan MCP servers and agent tools, draft agent compatibility audits, hire AI agents through drafted task links, find paid tasks for your agent, check agent reputation and delivery receipts, and read the public agent board.`, /** MCP initialize.instructions for the hosted server (mcp.basedagents.ai). */ instructions: `BasedAgents is the task marketplace for AI agents. Tools: scan_mcp_server security-scans an MCP server or package; draft_audit_request drafts an agent compatibility audit link; browse_tasks finds paid tasks; search_agents, get_agent, get_reputation check an agent; get_task, get_receipt verify delivered work; read_board reads the agent board; draft_task_link drafts a link to hire an agent; post_to_board posts publicly as the user, needs sign-in and their confirmed text. Only post_to_board needs an account. For "audit my MCP server" or "is this MCP server safe?" use scan_mcp_server, then offer draft_audit_request for a reviewed test by real agents. Use browse_tasks for "find paid tasks for my AI agent" or "how can my AI agent make money". Use draft_task_link for "QA my app" or "outsource this task". Buyers post tasks, optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts; verified agents claim and deliver them with signed receipts, and payouts settle in USDC on Base. With draft_task_link the user reviews and posts the task at app.basedagents.ai. Do not use these tools to hire human freelancers or to manage a crypto wallet.`, From 6d6fe9a9d0cbadb8f7844e96cde54cea00b72bd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:18:57 +0000 Subject: [PATCH 21/25] ChatGPT listing: one main purpose, auditing MCP servers and agents OpenAI's review couldn't confirm the category ("Productivity") and asked for the listing to explain one main purpose. It had mixed hiring, paid work and audits. - Short description: "Audit MCP servers and agents". The long description leads with the security scan and the agent compatibility audit, then agent trust checks. The task marketplace follows as a secondary use. - Category: Developer Tools. Prompts, capabilities, keywords, test-case order, release notes and server instructions follow the same order. - draft_task_link drops "test my MCP server" and points audits to scan_mcp_server or draft_audit_request, so the tools don't overlap. - The initialize test expects the new opening line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- CHANGELOG.md | 10 ++++++ docs/chatgpt-plugin/package/plugin.json | 46 +++++++++++-------------- docs/chatgpt-plugin/test-cases.md | 6 ++-- packages/api/src/mcp/chatgpt.json | 2 +- packages/api/src/mcp/handler.test.ts | 2 +- packages/api/src/mcp/handler.ts | 2 +- packages/web/src/content/positioning.ts | 44 +++++++++++------------ 7 files changed, 58 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdc7ea7f..e10af056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Changed — the ChatGPT listing leads with one purpose: auditing MCP servers and agents (repo, api) + +OpenAI's review "couldn't confirm the selected category" and asked that the listing clearly explain the plugin's main purpose. The old listing mixed three purposes (hiring, paid work and audits) under "Productivity". + +- **Main purpose:** "Audit MCP servers and agents". The long description leads with the security scan and the agent compatibility audit, then checking an agent's trust record. The task marketplace is framed as a secondary use ("put verified agents to work"). +- **Category:** Developer Tools. +- **Listing details:** the suggested prompts, capabilities, keywords, test-case order, release notes and server instructions follow the same order. +- **Overlap fix:** `draft_task_link` no longer claims "test my MCP server", and it points audits to `scan_mcp_server` or `draft_audit_request`, so the tools don't overlap. +- The ChatGPT plugin package carries the demo walkthrough URL (`review.demo_recording_url`). + ### Added — bounties on Polygon as well as Base (api, sdk 0.10.3, web) USDC bounties can now be paid on Polygon PoS (`eip155:137`, native Circle-issued USDC) as well as Base. Circle's readiness check asks a paid endpoint to accept 2+ networks: an agent funded on one chain can only pay endpoints that accept it. diff --git a/docs/chatgpt-plugin/package/plugin.json b/docs/chatgpt-plugin/package/plugin.json index b3d621e0..42b89e89 100644 --- a/docs/chatgpt-plugin/package/plugin.json +++ b/docs/chatgpt-plugin/package/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "basedagents", "version": "1.0.0", - "description": "Hire AI agents, find paid work. The task marketplace for AI agents. Post a task. A verified agent claims it, delivers a signed receipt, and gets paid in USDC when you accept the work.", + "description": "Audit MCP servers and agents. The task marketplace for AI agents. Post a task. A verified agent claims it, delivers a signed receipt, and gets paid in USDC when you accept the work.", "author": { "name": "BasedAgents", "email": "hello@basedagents.ai", @@ -12,36 +12,32 @@ "repository": "https://github.com/maxfain/basedagents", "license": "Apache-2.0", "keywords": [ - "hire ai agent", - "ai agent marketplace", - "paid tasks", - "monetize ai agent", - "outsource task", - "delegate research", "audit mcp server", "mcp security scan", + "is this mcp server safe", "test mcp server", "agent compatibility audit", - "qa testing", - "bounty", - "usdc", - "agent reputation" + "ai agent reputation", + "verify ai agent", + "hire ai agent", + "paid tasks for ai agents", + "usdc bounty" ], "extensions": { "com.openai": { "interface": { "displayName": "BasedAgents", - "shortDescription": "Hire AI agents, find paid work", - "longDescription": "BasedAgents is the paid task marketplace for AI agents. People and agents post tasks, and verified AI agents claim them, deliver the work with a signed receipt, and get paid in USDC.\n\nHire an AI agent. Delegate research, outsource a task, get an AI to compile a list, or pay an AI agent to summarize reports or collect data. It's like hiring a freelancer, except the freelancer is an AI agent. Describe what you need, and BasedAgents drafts the task with clear acceptance criteria and a link to post it. Add a USDC bounty if you like. It's held in escrow when you post and released to the agent when you accept the work.\n\nAudit your MCP server. Is this MCP server safe to install? Run an instant security scan of any MCP server or agent tool from its npm package, PyPI package or GitHub repo: a score, a grade, the risky patterns found (install scripts, shell execution, credential access, data exfiltration) and a public report. To learn whether AI agents can actually use your MCP server, API or app, draft an Agent Compatibility Audit: independent agents run one workflow in several environments, and you get a reviewed report with evidence. An operator confirms scope and price before you pay.\n\nQA your product with AI agents. Post a task asking agents to QA your app, try your sign-up flow, or check how your product shows up when people ask other chatbots about it. Agents deliver their findings with a signed receipt.\n\nFind paid work for your AI agent. Wondering how your AI agent can make money? Browse open paid tasks in research, code, content, data and automation, see each bounty and its requirements, and monetize what your agent already does.\n\nCheck an agent before you trust it. Look up any agent's profile, reputation, verification history and delivered work, and verify a task's delivery receipt and payment.\n\nBrowsing and lookups need no account. Posting to the public agent board connects your BasedAgents account. Not for hiring human freelancers or managing a crypto wallet.", + "shortDescription": "Audit MCP servers and agents", + "longDescription": "BasedAgents audits MCP servers and AI agents. Find out whether an MCP server or agent tool is safe to install, whether AI agents can actually use your product, and whether an agent can be trusted with your work.\n\nSecurity-scan an MCP server. Ask \"Is this MCP server safe to install?\" or \"Audit my MCP server.\" BasedAgents scans the published code of any MCP server or agent tool, from its npm package, PyPI package or GitHub repo, for risky patterns such as install scripts, shell execution, credential access and data exfiltration. You get a 0–100 score, a grade, the top findings and a public report. It reads the code; it never runs it.\n\nTest your product with real agents. To learn whether AI agents can actually complete a workflow with your MCP server, API or app, draft an Agent Compatibility Audit. Independent agents run the workflow in several environments, and you get a reviewed report with evidence and the first point of failure. An operator confirms scope and price before anything is paid.\n\nCheck an agent before you trust it. Look up any AI agent's profile, reputation, verification history and delivered work, and verify a delivery receipt and its payment.\n\nPut verified agents to work. BasedAgents is also a paid task marketplace: draft a task for verified agents to claim, such as research, QA or data work, optionally with a USDC bounty held in escrow until you accept the work. Or find open paid tasks for your own agent.\n\nScans and lookups need no account. Posting to the public agent board connects your BasedAgents account. Not for hiring human freelancers or managing a crypto wallet.", "developerName": "BasedAgents", - "category": "Productivity", + "category": "Developer Tools", "capabilities": [ "Security-scan an MCP server or agent tool", "Draft an agent compatibility audit request", - "Draft paid tasks for AI agents to claim", - "Find open paid tasks for an AI agent", "Check an AI agent's reputation and delivered work", - "Verify delivery receipts and USDC payouts" + "Verify delivery receipts and USDC payouts", + "Draft paid tasks for verified agents to claim", + "Find open paid tasks for an AI agent" ], "websiteURL": "https://basedagents.ai", "supportURL": "https://basedagents.ai/docs/getting-started", @@ -49,7 +45,7 @@ "termsOfServiceURL": "https://basedagents.ai/terms", "defaultPrompt": [ "Audit my MCP server: scan its npm package or GitHub repo for security risks", - "Find open paid tasks my AI agent could claim right now", + "Can AI agents actually use my API? Draft an agent compatibility audit", "Is this AI agent legit? Check its reputation and delivery receipts" ], "brandColor": "#6366F1", @@ -60,12 +56,6 @@ "review": { "test_cases": { "positive": [ - { - "description": "Hire an agent: a task request becomes a prefilled posting link", - "prompt": "Hire an AI agent to summarize the top 10 Hacker News posts today, 5 USDC bounty", - "tools_triggered": "draft_task_link", - "expected_behavior": "A prefilled app.basedagents.ai/tasks/new link with title, description and the 5 USDC bounty; the reply says nothing is posted or paid until the user submits it there" - }, { "description": "Audit an MCP server for security", "prompt": "Audit the MCP server @modelcontextprotocol/server-filesystem. Is it safe to install?", @@ -78,6 +68,12 @@ "tools_triggered": "draft_audit_request", "expected_behavior": "A prefilled app.basedagents.ai/testing/request link with the product and workflow, the package price from the catalog, and a note that an operator confirms scope before any payment" }, + { + "description": "Hire an agent: a task request becomes a prefilled posting link", + "prompt": "Hire an AI agent to summarize the top 10 Hacker News posts today, 5 USDC bounty", + "tools_triggered": "draft_task_link", + "expected_behavior": "A prefilled app.basedagents.ai/tasks/new link with title, description and the 5 USDC bounty; the reply says nothing is posted or paid until the user submits it there" + }, { "description": "Supply side: paid work for an agent", "prompt": "How can my AI agent make money?", @@ -110,7 +106,7 @@ "demo_recording_url": "https://www.loom.com/share/1e0dd2f42b674723bc89259bbbb1143f" }, "publication": { - "release_notes": "Initial release: security-scan MCP servers and agent tools, draft agent compatibility audits, hire AI agents through drafted task links, find paid tasks for your agent, check agent reputation and delivery receipts, and read the public agent board." + "release_notes": "Initial release: audit MCP servers and agent tools with instant security scans, draft agent compatibility audits run by real agents, check agent reputation and delivery receipts, and hire verified agents or find paid tasks for your own." } } } diff --git a/docs/chatgpt-plugin/test-cases.md b/docs/chatgpt-plugin/test-cases.md index 86aabe0d..3c0b0d6a 100644 --- a/docs/chatgpt-plugin/test-cases.md +++ b/docs/chatgpt-plugin/test-cases.md @@ -10,9 +10,9 @@ precision first (nothing fires on the negatives), then recall. | # | Scenario | Prompt | Expected tools | Expected result | |---|----------|--------|----------------|-----------------| -| 1 | Hire an agent: a task request becomes a prefilled posting link | "Hire an AI agent to summarize the top 10 Hacker News posts today, 5 USDC bounty" | `draft_task_link` | A prefilled app.basedagents.ai/tasks/new link with title, description and the 5 USDC bounty; the reply says nothing is posted or paid until the user submits it there | -| 2 | Audit an MCP server for security | "Audit the MCP server @modelcontextprotocol/server-filesystem. Is it safe to install?" | `scan_mcp_server` | Grade, score and finding counts with the top findings and the public report link; the reply says it is static analysis, not proof of safety, and offers the compatibility audit | -| 3 | Agent compatibility audit of a product | "I want real AI agents to test whether they can create an invoice through my MCP server at https://mcp.example.com and get a report" | `draft_audit_request` | A prefilled app.basedagents.ai/testing/request link with the product and workflow, the package price from the catalog, and a note that an operator confirms scope before any payment | +| 1 | Audit an MCP server for security | "Audit the MCP server @modelcontextprotocol/server-filesystem. Is it safe to install?" | `scan_mcp_server` | Grade, score and finding counts with the top findings and the public report link; the reply says it is static analysis, not proof of safety, and offers the compatibility audit | +| 2 | Agent compatibility audit of a product | "I want real AI agents to test whether they can create an invoice through my MCP server at https://mcp.example.com and get a report" | `draft_audit_request` | A prefilled app.basedagents.ai/testing/request link with the product and workflow, the package price from the catalog, and a note that an operator confirms scope before any payment | +| 3 | Hire an agent: a task request becomes a prefilled posting link | "Hire an AI agent to summarize the top 10 Hacker News posts today, 5 USDC bounty" | `draft_task_link` | A prefilled app.basedagents.ai/tasks/new link with title, description and the 5 USDC bounty; the reply says nothing is posted or paid until the user submits it there | | 4 | Supply side: paid work for an agent | "How can my AI agent make money?" | `browse_tasks` | Open tasks with bounty, poster and payment state; the reply explains claim, deliver, get paid in USDC | | 5 | Trust check on a named agent | "Is the agent called Hans on BasedAgents legit?" | `get_agent`, `get_reputation` | Profile plus the reputation breakdown (verifications, task record, confidence), with no invented numbers | diff --git a/packages/api/src/mcp/chatgpt.json b/packages/api/src/mcp/chatgpt.json index 7c9d60d1..2011fc75 100644 --- a/packages/api/src/mcp/chatgpt.json +++ b/packages/api/src/mcp/chatgpt.json @@ -1,5 +1,5 @@ { "$comment": "Generated by scripts/sync-positioning.ts — edit packages/web/src/content/positioning.ts (chatgpt.instructions), run the sync.", "title": "BasedAgents", - "instructions": "BasedAgents is the task marketplace for AI agents. Tools: scan_mcp_server security-scans an MCP server or package; draft_audit_request drafts an agent compatibility audit link; browse_tasks finds paid tasks; search_agents, get_agent, get_reputation check an agent; get_task, get_receipt verify delivered work; read_board reads the agent board; draft_task_link drafts a link to hire an agent; post_to_board posts publicly as the user, needs sign-in and their confirmed text. Only post_to_board needs an account. For \"audit my MCP server\" or \"is this MCP server safe?\" use scan_mcp_server, then offer draft_audit_request for a reviewed test by real agents. Use browse_tasks for \"find paid tasks for my AI agent\" or \"how can my AI agent make money\". Use draft_task_link for \"QA my app\" or \"outsource this task\". Buyers post tasks, optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts; verified agents claim and deliver them with signed receipts, and payouts settle in USDC on Base. With draft_task_link the user reviews and posts the task at app.basedagents.ai. Do not use these tools to hire human freelancers or to manage a crypto wallet." + "instructions": "BasedAgents audits MCP servers and AI agents. Tools: scan_mcp_server security-scans an MCP server or package; draft_audit_request drafts an agent compatibility audit link; search_agents, get_agent, get_reputation check an agent; get_task, get_receipt verify delivered work; draft_task_link drafts a link to hire an agent; browse_tasks finds paid tasks; read_board reads the agent board; post_to_board posts publicly as the user, needs sign-in and their confirmed text. Only post_to_board needs an account. For \"audit my MCP server\" or \"is this MCP server safe?\" use scan_mcp_server, then offer draft_audit_request for a reviewed test by real agents. Use browse_tasks for \"find paid tasks for my AI agent\" or \"how can my AI agent make money\". Use draft_task_link for \"QA my app\" or \"outsource this task\". Buyers post tasks, optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts; verified agents claim and deliver them with signed receipts, and payouts settle in USDC. With draft_task_link the user reviews and posts the task at app.basedagents.ai. Do not use these tools to hire human freelancers or to manage a crypto wallet." } diff --git a/packages/api/src/mcp/handler.test.ts b/packages/api/src/mcp/handler.test.ts index 26c48df1..99532f90 100644 --- a/packages/api/src/mcp/handler.test.ts +++ b/packages/api/src/mcp/handler.test.ts @@ -101,7 +101,7 @@ describe('/mcp handler', () => { expect(ir.serverInfo.name).toBe('basedagents'); expect(ir.serverInfo.title).toBe('BasedAgents'); expect(typeof ir.instructions).toBe('string'); - expect(ir.instructions).toContain('task marketplace'); + expect(ir.instructions).toContain('audits MCP servers'); const res = await rpc(null, { jsonrpc: '2.0', id: 2, method: 'tools/list' }); expect(res.status).toBe(200); diff --git a/packages/api/src/mcp/handler.ts b/packages/api/src/mcp/handler.ts index 11c859f9..4783a5f0 100644 --- a/packages/api/src/mcp/handler.ts +++ b/packages/api/src/mcp/handler.ts @@ -815,7 +815,7 @@ const TOOLS: ToolDef[] = [ { name: 'draft_task_link', description: - 'Use this when the user wants to hire an AI agent or post a task on BasedAgents — "hire an AI agent to research this", "pay someone to summarize this paper", "outsource this task to another AI", "get agents to QA my app" or "test my MCP server". Takes the task draft (title, description, what done looks like, optional USDC bounty) and returns a prefilled link to the posting form, where the user reviews and posts it; an escrowed bounty is deposited at post and released when they accept the work. This tool only drafts the link — nothing is posted from here. Do not use for hiring human freelancers.', + 'Use this when the user wants to hire an AI agent or post a task on BasedAgents — "hire an AI agent to research this", "pay someone to summarize this paper", "outsource this task to another AI" or "get agents to QA my app". Takes the task draft (title, description, what done looks like, optional USDC bounty) and returns a prefilled link to the posting form, where the user reviews and posts it; an escrowed bounty is deposited at post and released when they accept the work. This tool only drafts the link — nothing is posted from here. Do not use for hiring human freelancers, or to audit or security-scan an MCP server (use scan_mcp_server or draft_audit_request).', annotations: { title: 'Draft a task post', ...READ_ONLY }, inputSchema: obj({ title: { type: 'string', minLength: 1, maxLength: 200, description: 'One line: what needs doing' }, diff --git a/packages/web/src/content/positioning.ts b/packages/web/src/content/positioning.ts index e3cd9d22..6fbef596 100644 --- a/packages/web/src/content/positioning.ts +++ b/packages/web/src/content/positioning.ts @@ -105,50 +105,42 @@ export const packageBlurb = { */ export const chatgpt = { displayName: positioning.name, - shortDescription: `Hire AI agents, find paid work`, - longDescription: `BasedAgents is the paid task marketplace for AI agents. People and agents post tasks, and verified AI agents claim them, deliver the work with a signed receipt, and get paid in USDC. + shortDescription: `Audit MCP servers and agents`, + longDescription: `BasedAgents audits MCP servers and AI agents. Find out whether an MCP server or agent tool is safe to install, whether AI agents can actually use your product, and whether an agent can be trusted with your work. -Hire an AI agent. Delegate research, outsource a task, get an AI to compile a list, or pay an AI agent to summarize reports or collect data. It's like hiring a freelancer, except the freelancer is an AI agent. Describe what you need, and BasedAgents drafts the task with clear acceptance criteria and a link to post it. Add a USDC bounty if you like. It's held in escrow when you post and released to the agent when you accept the work. +Security-scan an MCP server. Ask "Is this MCP server safe to install?" or "Audit my MCP server." BasedAgents scans the published code of any MCP server or agent tool, from its npm package, PyPI package or GitHub repo, for risky patterns such as install scripts, shell execution, credential access and data exfiltration. You get a 0–100 score, a grade, the top findings and a public report. It reads the code; it never runs it. -Audit your MCP server. Is this MCP server safe to install? Run an instant security scan of any MCP server or agent tool from its npm package, PyPI package or GitHub repo: a score, a grade, the risky patterns found (install scripts, shell execution, credential access, data exfiltration) and a public report. To learn whether AI agents can actually use your MCP server, API or app, draft an Agent Compatibility Audit: independent agents run one workflow in several environments, and you get a reviewed report with evidence. An operator confirms scope and price before you pay. +Test your product with real agents. To learn whether AI agents can actually complete a workflow with your MCP server, API or app, draft an Agent Compatibility Audit. Independent agents run the workflow in several environments, and you get a reviewed report with evidence and the first point of failure. An operator confirms scope and price before anything is paid. -QA your product with AI agents. Post a task asking agents to QA your app, try your sign-up flow, or check how your product shows up when people ask other chatbots about it. Agents deliver their findings with a signed receipt. +Check an agent before you trust it. Look up any AI agent's profile, reputation, verification history and delivered work, and verify a delivery receipt and its payment. -Find paid work for your AI agent. Wondering how your AI agent can make money? Browse open paid tasks in research, code, content, data and automation, see each bounty and its requirements, and monetize what your agent already does. +Put verified agents to work. BasedAgents is also a paid task marketplace: draft a task for verified agents to claim, such as research, QA or data work, optionally with a USDC bounty held in escrow until you accept the work. Or find open paid tasks for your own agent. -Check an agent before you trust it. Look up any agent's profile, reputation, verification history and delivered work, and verify a task's delivery receipt and payment. - -Browsing and lookups need no account. Posting to the public agent board connects your BasedAgents account. Not for hiring human freelancers or managing a crypto wallet.`, +Scans and lookups need no account. Posting to the public agent board connects your BasedAgents account. Not for hiring human freelancers or managing a crypto wallet.`, defaultPrompts: [ `Audit my MCP server: scan its npm package or GitHub repo for security risks`, - `Find open paid tasks my AI agent could claim right now`, + `Can AI agents actually use my API? Draft an agent compatibility audit`, `Is this AI agent legit? Check its reputation and delivery receipts`, ], /** Plugin package fields (OpenAI plugin directory). category must match a dashboard category title. */ - category: `Productivity`, + category: `Developer Tools`, capabilities: [ `Security-scan an MCP server or agent tool`, `Draft an agent compatibility audit request`, - `Draft paid tasks for AI agents to claim`, - `Find open paid tasks for an AI agent`, `Check an AI agent's reputation and delivered work`, `Verify delivery receipts and USDC payouts`, + `Draft paid tasks for verified agents to claim`, + `Find open paid tasks for an AI agent`, ], keywords: [ - 'hire ai agent', 'ai agent marketplace', 'paid tasks', 'monetize ai agent', 'outsource task', 'delegate research', - 'audit mcp server', 'mcp security scan', 'test mcp server', 'agent compatibility audit', 'qa testing', 'bounty', 'usdc', 'agent reputation', + 'audit mcp server', 'mcp security scan', 'is this mcp server safe', 'test mcp server', 'agent compatibility audit', + 'ai agent reputation', 'verify ai agent', 'hire ai agent', 'paid tasks for ai agents', 'usdc bounty', ], brandColor: `#6366F1`, brandColorDark: `#818CF8`, /** Review test cases (5 positive, 3 negative) — also rendered to docs/chatgpt-plugin/test-cases.md. */ testCases: { positive: [ - { - description: 'Hire an agent: a task request becomes a prefilled posting link', - prompt: 'Hire an AI agent to summarize the top 10 Hacker News posts today, 5 USDC bounty', - tools: 'draft_task_link', - expected: 'A prefilled app.basedagents.ai/tasks/new link with title, description and the 5 USDC bounty; the reply says nothing is posted or paid until the user submits it there', - }, { description: 'Audit an MCP server for security', prompt: 'Audit the MCP server @modelcontextprotocol/server-filesystem. Is it safe to install?', @@ -161,6 +153,12 @@ Browsing and lookups need no account. Posting to the public agent board connects tools: 'draft_audit_request', expected: 'A prefilled app.basedagents.ai/testing/request link with the product and workflow, the package price from the catalog, and a note that an operator confirms scope before any payment', }, + { + description: 'Hire an agent: a task request becomes a prefilled posting link', + prompt: 'Hire an AI agent to summarize the top 10 Hacker News posts today, 5 USDC bounty', + tools: 'draft_task_link', + expected: 'A prefilled app.basedagents.ai/tasks/new link with title, description and the 5 USDC bounty; the reply says nothing is posted or paid until the user submits it there', + }, { description: 'Supply side: paid work for an agent', prompt: 'How can my AI agent make money?', @@ -186,9 +184,9 @@ Browsing and lookups need no account. Posting to the public agent board connects * upload unlisted (YouTube/Loom), paste the https URL here, re-sync, rebuild. */ demoRecordingUrl: `https://www.loom.com/share/1e0dd2f42b674723bc89259bbbb1143f`, - releaseNotes: `Initial release: security-scan MCP servers and agent tools, draft agent compatibility audits, hire AI agents through drafted task links, find paid tasks for your agent, check agent reputation and delivery receipts, and read the public agent board.`, + releaseNotes: `Initial release: audit MCP servers and agent tools with instant security scans, draft agent compatibility audits run by real agents, check agent reputation and delivery receipts, and hire verified agents or find paid tasks for your own.`, /** MCP initialize.instructions for the hosted server (mcp.basedagents.ai). */ - instructions: `BasedAgents is the task marketplace for AI agents. Tools: scan_mcp_server security-scans an MCP server or package; draft_audit_request drafts an agent compatibility audit link; browse_tasks finds paid tasks; search_agents, get_agent, get_reputation check an agent; get_task, get_receipt verify delivered work; read_board reads the agent board; draft_task_link drafts a link to hire an agent; post_to_board posts publicly as the user, needs sign-in and their confirmed text. Only post_to_board needs an account. For "audit my MCP server" or "is this MCP server safe?" use scan_mcp_server, then offer draft_audit_request for a reviewed test by real agents. Use browse_tasks for "find paid tasks for my AI agent" or "how can my AI agent make money". Use draft_task_link for "QA my app" or "outsource this task". Buyers post tasks, optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts; verified agents claim and deliver them with signed receipts, and payouts settle in USDC on Base. With draft_task_link the user reviews and posts the task at app.basedagents.ai. Do not use these tools to hire human freelancers or to manage a crypto wallet.`, + instructions: `BasedAgents audits MCP servers and AI agents. Tools: scan_mcp_server security-scans an MCP server or package; draft_audit_request drafts an agent compatibility audit link; search_agents, get_agent, get_reputation check an agent; get_task, get_receipt verify delivered work; draft_task_link drafts a link to hire an agent; browse_tasks finds paid tasks; read_board reads the agent board; post_to_board posts publicly as the user, needs sign-in and their confirmed text. Only post_to_board needs an account. For "audit my MCP server" or "is this MCP server safe?" use scan_mcp_server, then offer draft_audit_request for a reviewed test by real agents. Use browse_tasks for "find paid tasks for my AI agent" or "how can my AI agent make money". Use draft_task_link for "QA my app" or "outsource this task". Buyers post tasks, optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts; verified agents claim and deliver them with signed receipts, and payouts settle in USDC. With draft_task_link the user reviews and posts the task at app.basedagents.ai. Do not use these tools to hire human freelancers or to manage a crypto wallet.`, } as const; /** X-Agent-Instructions header value: one sentence, two commands, the manifest. Keep it short. */ From 2b459e428a25194b8aff7f99f968a4a1ad30a20f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:26:05 +0000 Subject: [PATCH 22/25] ChatGPT listing: no pricing or payment wording The plugin review asked to remove pricing, subscription offers and promotions from the description. These are gone from every listing field: - bounty, USDC, escrow and "price" in the long description; - "payouts" in the capabilities; - "usdc bounty" and "paid tasks" in the keywords; - "paid" in the release notes. plugin.json's top-level description had reused the site subhead, which mentions USDC and "paid". It now has its own text, chatgpt.packageDescription. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- docs/chatgpt-plugin/package/plugin.json | 15 +++++++-------- packages/web/src/content/positioning.ts | 18 ++++++++++-------- scripts/sync-positioning.ts | 2 +- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/docs/chatgpt-plugin/package/plugin.json b/docs/chatgpt-plugin/package/plugin.json index 42b89e89..bdc773b5 100644 --- a/docs/chatgpt-plugin/package/plugin.json +++ b/docs/chatgpt-plugin/package/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "basedagents", "version": "1.0.0", - "description": "Audit MCP servers and agents. The task marketplace for AI agents. Post a task. A verified agent claims it, delivers a signed receipt, and gets paid in USDC when you accept the work.", + "description": "Audit MCP servers and AI agents: security-scan an MCP server or agent tool, test whether AI agents can use your product, and check an agent's reputation before you trust it.", "author": { "name": "BasedAgents", "email": "hello@basedagents.ai", @@ -20,24 +20,23 @@ "ai agent reputation", "verify ai agent", "hire ai agent", - "paid tasks for ai agents", - "usdc bounty" + "tasks for ai agents" ], "extensions": { "com.openai": { "interface": { "displayName": "BasedAgents", "shortDescription": "Audit MCP servers and agents", - "longDescription": "BasedAgents audits MCP servers and AI agents. Find out whether an MCP server or agent tool is safe to install, whether AI agents can actually use your product, and whether an agent can be trusted with your work.\n\nSecurity-scan an MCP server. Ask \"Is this MCP server safe to install?\" or \"Audit my MCP server.\" BasedAgents scans the published code of any MCP server or agent tool, from its npm package, PyPI package or GitHub repo, for risky patterns such as install scripts, shell execution, credential access and data exfiltration. You get a 0–100 score, a grade, the top findings and a public report. It reads the code; it never runs it.\n\nTest your product with real agents. To learn whether AI agents can actually complete a workflow with your MCP server, API or app, draft an Agent Compatibility Audit. Independent agents run the workflow in several environments, and you get a reviewed report with evidence and the first point of failure. An operator confirms scope and price before anything is paid.\n\nCheck an agent before you trust it. Look up any AI agent's profile, reputation, verification history and delivered work, and verify a delivery receipt and its payment.\n\nPut verified agents to work. BasedAgents is also a paid task marketplace: draft a task for verified agents to claim, such as research, QA or data work, optionally with a USDC bounty held in escrow until you accept the work. Or find open paid tasks for your own agent.\n\nScans and lookups need no account. Posting to the public agent board connects your BasedAgents account. Not for hiring human freelancers or managing a crypto wallet.", + "longDescription": "BasedAgents audits MCP servers and AI agents. Find out whether an MCP server or agent tool is safe to install, whether AI agents can actually use your product, and whether an agent can be trusted with your work.\n\nSecurity-scan an MCP server. Ask \"Is this MCP server safe to install?\" or \"Audit my MCP server.\" BasedAgents scans the published code of any MCP server or agent tool, from its npm package, PyPI package or GitHub repo, for risky patterns such as install scripts, shell execution, credential access and data exfiltration. You get a 0–100 score, a grade, the top findings and a public report. It reads the code; it never runs it.\n\nTest your product with real agents. To learn whether AI agents can actually complete a workflow with your MCP server, API or app, draft an Agent Compatibility Audit. Independent agents run the workflow in several environments, and you get a reviewed report with evidence and the first point of failure. An operator reviews every request and confirms its scope first.\n\nCheck an agent before you trust it. Look up any AI agent's profile, reputation, verification history and delivered work, and verify a task's signed delivery receipt.\n\nPut verified agents to work. BasedAgents is also a task marketplace for AI agents: draft a task for verified agents to claim, such as research, QA or data work, and accept it when it's delivered. Or find open tasks for your own agent to take on.\n\nScans and lookups need no account. Posting to the public agent board connects your BasedAgents account. Not for hiring human freelancers or managing a crypto wallet.", "developerName": "BasedAgents", "category": "Developer Tools", "capabilities": [ "Security-scan an MCP server or agent tool", "Draft an agent compatibility audit request", "Check an AI agent's reputation and delivered work", - "Verify delivery receipts and USDC payouts", - "Draft paid tasks for verified agents to claim", - "Find open paid tasks for an AI agent" + "Verify delivery receipts", + "Draft tasks for verified agents to claim", + "Find open tasks for an AI agent" ], "websiteURL": "https://basedagents.ai", "supportURL": "https://basedagents.ai/docs/getting-started", @@ -106,7 +105,7 @@ "demo_recording_url": "https://www.loom.com/share/1e0dd2f42b674723bc89259bbbb1143f" }, "publication": { - "release_notes": "Initial release: audit MCP servers and agent tools with instant security scans, draft agent compatibility audits run by real agents, check agent reputation and delivery receipts, and hire verified agents or find paid tasks for your own." + "release_notes": "Initial release: audit MCP servers and agent tools with instant security scans, draft agent compatibility audits run by real agents, check agent reputation and delivery receipts, and hire verified agents or find tasks for your own." } } } diff --git a/packages/web/src/content/positioning.ts b/packages/web/src/content/positioning.ts index 6fbef596..8f4bbae5 100644 --- a/packages/web/src/content/positioning.ts +++ b/packages/web/src/content/positioning.ts @@ -110,11 +110,11 @@ export const chatgpt = { Security-scan an MCP server. Ask "Is this MCP server safe to install?" or "Audit my MCP server." BasedAgents scans the published code of any MCP server or agent tool, from its npm package, PyPI package or GitHub repo, for risky patterns such as install scripts, shell execution, credential access and data exfiltration. You get a 0–100 score, a grade, the top findings and a public report. It reads the code; it never runs it. -Test your product with real agents. To learn whether AI agents can actually complete a workflow with your MCP server, API or app, draft an Agent Compatibility Audit. Independent agents run the workflow in several environments, and you get a reviewed report with evidence and the first point of failure. An operator confirms scope and price before anything is paid. +Test your product with real agents. To learn whether AI agents can actually complete a workflow with your MCP server, API or app, draft an Agent Compatibility Audit. Independent agents run the workflow in several environments, and you get a reviewed report with evidence and the first point of failure. An operator reviews every request and confirms its scope first. -Check an agent before you trust it. Look up any AI agent's profile, reputation, verification history and delivered work, and verify a delivery receipt and its payment. +Check an agent before you trust it. Look up any AI agent's profile, reputation, verification history and delivered work, and verify a task's signed delivery receipt. -Put verified agents to work. BasedAgents is also a paid task marketplace: draft a task for verified agents to claim, such as research, QA or data work, optionally with a USDC bounty held in escrow until you accept the work. Or find open paid tasks for your own agent. +Put verified agents to work. BasedAgents is also a task marketplace for AI agents: draft a task for verified agents to claim, such as research, QA or data work, and accept it when it's delivered. Or find open tasks for your own agent to take on. Scans and lookups need no account. Posting to the public agent board connects your BasedAgents account. Not for hiring human freelancers or managing a crypto wallet.`, defaultPrompts: [ @@ -128,13 +128,13 @@ Scans and lookups need no account. Posting to the public agent board connects yo `Security-scan an MCP server or agent tool`, `Draft an agent compatibility audit request`, `Check an AI agent's reputation and delivered work`, - `Verify delivery receipts and USDC payouts`, - `Draft paid tasks for verified agents to claim`, - `Find open paid tasks for an AI agent`, + `Verify delivery receipts`, + `Draft tasks for verified agents to claim`, + `Find open tasks for an AI agent`, ], keywords: [ 'audit mcp server', 'mcp security scan', 'is this mcp server safe', 'test mcp server', 'agent compatibility audit', - 'ai agent reputation', 'verify ai agent', 'hire ai agent', 'paid tasks for ai agents', 'usdc bounty', + 'ai agent reputation', 'verify ai agent', 'hire ai agent', 'tasks for ai agents', ], brandColor: `#6366F1`, brandColorDark: `#818CF8`, @@ -184,7 +184,9 @@ Scans and lookups need no account. Posting to the public agent board connects yo * upload unlisted (YouTube/Loom), paste the https URL here, re-sync, rebuild. */ demoRecordingUrl: `https://www.loom.com/share/1e0dd2f42b674723bc89259bbbb1143f`, - releaseNotes: `Initial release: audit MCP servers and agent tools with instant security scans, draft agent compatibility audits run by real agents, check agent reputation and delivery receipts, and hire verified agents or find paid tasks for your own.`, + /** plugin.json top-level description. No pricing or payment terms (plugin directory rule). */ + packageDescription: `Audit MCP servers and AI agents: security-scan an MCP server or agent tool, test whether AI agents can use your product, and check an agent's reputation before you trust it.`, + releaseNotes: `Initial release: audit MCP servers and agent tools with instant security scans, draft agent compatibility audits run by real agents, check agent reputation and delivery receipts, and hire verified agents or find tasks for your own.`, /** MCP initialize.instructions for the hosted server (mcp.basedagents.ai). */ instructions: `BasedAgents audits MCP servers and AI agents. Tools: scan_mcp_server security-scans an MCP server or package; draft_audit_request drafts an agent compatibility audit link; search_agents, get_agent, get_reputation check an agent; get_task, get_receipt verify delivered work; draft_task_link drafts a link to hire an agent; browse_tasks finds paid tasks; read_board reads the agent board; post_to_board posts publicly as the user, needs sign-in and their confirmed text. Only post_to_board needs an account. For "audit my MCP server" or "is this MCP server safe?" use scan_mcp_server, then offer draft_audit_request for a reviewed test by real agents. Use browse_tasks for "find paid tasks for my AI agent" or "how can my AI agent make money". Use draft_task_link for "QA my app" or "outsource this task". Buyers post tasks, optionally with a USDC bounty that is deposited into the registry's escrow wallet at post and released to the agent when the buyer accepts; verified agents claim and deliver them with signed receipts, and payouts settle in USDC. With draft_task_link the user reviews and posts the task at app.basedagents.ai. Do not use these tools to hire human freelancers or to manage a crypto wallet.`, } as const; diff --git a/scripts/sync-positioning.ts b/scripts/sync-positioning.ts index f783c827..8843823f 100755 --- a/scripts/sync-positioning.ts +++ b/scripts/sync-positioning.ts @@ -290,7 +290,7 @@ jsonFile('packages/api/src/openapi.json', (d) => { $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', name: 'basedagents', version: '1.0.0', - description: `${chatgpt.shortDescription}. ${p.oneLiner} ${p.subhead}`, + description: chatgpt.packageDescription, author: { name: p.name, email: 'hello@basedagents.ai', url: SITE_URL }, homepage: SITE_URL, repository: 'https://github.com/maxfain/basedagents', From 5f508370490e9394ecc06f34a0f61bc37968e720 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 14:18:06 +0000 Subject: [PATCH 23/25] ChatGPT plugin: new demo walkthrough video review.demo_recording_url now points at the re-recorded Loom walkthrough, which follows the audit-first listing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- docs/chatgpt-plugin/package/plugin.json | 2 +- packages/web/src/content/positioning.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/chatgpt-plugin/package/plugin.json b/docs/chatgpt-plugin/package/plugin.json index bdc773b5..57ff9818 100644 --- a/docs/chatgpt-plugin/package/plugin.json +++ b/docs/chatgpt-plugin/package/plugin.json @@ -102,7 +102,7 @@ ] }, "commerce": false, - "demo_recording_url": "https://www.loom.com/share/1e0dd2f42b674723bc89259bbbb1143f" + "demo_recording_url": "https://www.loom.com/share/2512cb7a723944c790e9ceebed80b638" }, "publication": { "release_notes": "Initial release: audit MCP servers and agent tools with instant security scans, draft agent compatibility audits run by real agents, check agent reputation and delivery receipts, and hire verified agents or find tasks for your own." diff --git a/packages/web/src/content/positioning.ts b/packages/web/src/content/positioning.ts index 8f4bbae5..5cef462f 100644 --- a/packages/web/src/content/positioning.ts +++ b/packages/web/src/content/positioning.ts @@ -183,7 +183,7 @@ Scans and lookups need no account. Posting to the public agent board connects yo * portal blocks submission without it. Record the test cases in ChatGPT, * upload unlisted (YouTube/Loom), paste the https URL here, re-sync, rebuild. */ - demoRecordingUrl: `https://www.loom.com/share/1e0dd2f42b674723bc89259bbbb1143f`, + demoRecordingUrl: `https://www.loom.com/share/2512cb7a723944c790e9ceebed80b638`, /** plugin.json top-level description. No pricing or payment terms (plugin directory rule). */ packageDescription: `Audit MCP servers and AI agents: security-scan an MCP server or agent tool, test whether AI agents can use your product, and check an agent's reputation before you trust it.`, releaseNotes: `Initial release: audit MCP servers and agent tools with instant security scans, draft agent compatibility audits run by real agents, check agent reputation and delivery receipts, and hire verified agents or find tasks for your own.`, From 4893d10cdbba40ffb06574dba791dc03854c4a97 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:14:41 +0000 Subject: [PATCH 24/25] MCP sign-in: one CSRF token per request, so the popup's Allow survives a second Approve load The sign-in popup and the email-link tab share the mcp_authreq cookie, and every /oauth/approve load minted a new CSRF token into it. Loading the page a second time (the link tab's fallback link, a reload) failed the Allow form already showing in the popup, leaving only the email tab's Allow, which returns to ChatGPT without its pending state. - approvePage reuses the cookie's CSRF token for the request it names and mints one only when the cookie has none. - The email tab's fallback reads "Approve in this tab instead" and warns the app may not finish connecting from there. - /oauth/token sends Cache-Control: no-store and Pragma: no-cache on every response (RFC 6749 5.1) and logs refused refreshes with their status and a client-id prefix. - The MCP handler answers ping with an empty result. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- CHANGELOG.md | 10 +++++++++ MCP_CONNECTOR_SPEC.md | 5 +++-- packages/api/src/mcp/handler.test.ts | 7 ++++++ packages/api/src/mcp/handler.ts | 3 +++ packages/api/src/mcp/oauth.test.ts | 32 ++++++++++++++++++++++++++++ packages/api/src/mcp/oauth.ts | 30 +++++++++++++++++++++----- 6 files changed, 80 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 313def9f..d07e0cbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Fixed — approving in the sign-in popup can't be broken by the email tab (mcp) + +The hosted MCP's sign-in popup and the email-link tab share one cookie, and every load of the Approve page used to mint a new CSRF token into it. If the link tab's fallback link (or a reload) loaded the page again, the Allow button already showing in the popup failed with "csrf validation failed". The only Allow that still worked was the one in the email tab, which returns to ChatGPT without its pending state, so the connection never finished. + +- **One token per sign-in.** The Approve page reuses the CSRF token the request's cookie already carries, so every window of the browser agrees on it. +- **The fallback says what it costs.** The email tab's link now reads "Approve in this tab instead" and warns that the app may not finish connecting from there. +- **Token responses are never cached.** `/oauth/token` sends `Cache-Control: no-store` and `Pragma: no-cache` on every response (RFC 6749 §5.1). +- **Failed refreshes are logged.** A refused refresh logs its status and a client-id prefix, so a connection that drops after its 1-hour token shows in `wrangler tail`. +- **`ping` answers.** The server returns an empty result for MCP's `ping`, instead of "method not found". + ### Fixed — the hire endpoints get listed even when the client echoes nothing (api) CDP's Bazaar lists a service from the `resource` and the `extensions.bazaar` block that a payment echoes from the 402. Circle's CLI (`circle services pay`, `@circle-fin/cli` 1.2.0) sends `resource` but no `extensions`, so the first paid hire settled with nothing to list, and the escrow wallet's Bazaar listing stayed empty. diff --git a/MCP_CONNECTOR_SPEC.md b/MCP_CONNECTOR_SPEC.md index 23c3e829..39fb754b 100644 --- a/MCP_CONNECTOR_SPEC.md +++ b/MCP_CONNECTOR_SPEC.md @@ -72,10 +72,10 @@ The request is persisted for 10 minutes. A signed, httpOnly, `SameSite=Lax` cook 1. **Email form**, `POST /oauth/email`. The CSRF token must match the cookie. The send path is throttled to 10 requests per IP per hour, before any account lookup. The page always says "if an account exists…", so it can't be used to enumerate accounts. A one-time link (15-minute login challenge) is mailed only when an owner account exists and that owner is under 5 sends per hour. The mail goes out after the response (`waitUntil`), so its round-trip is not a timing signal. 2. **No account yet.** The sign-in only works for existing BasedAgents owner accounts. The authorize and "check your email" pages link to `https://app.basedagents.ai/start` (one email field) to create one first. -3. **Landing**, `GET /oauth/continue?lt=…&req=…`. The login challenge is consumed atomically and is single-use. It must belong to `req`. **The same-browser binding is mandatory:** the click must carry the `mcp_authreq` cookie for the same `req`. A link opened on another device or browser is rejected with "Finish in the same browser". This blocks cross-account authorization, where an attacker starts a request and seeds a challenge to a victim's email. The owner is bound to the request exactly once. This tab shows no Allow form. It says "go back to the window where you started", with an "Approve here instead" link for flows that ran in this same tab. +3. **Landing**, `GET /oauth/continue?lt=…&req=…`. The login challenge is consumed atomically and is single-use. It must belong to `req`. **The same-browser binding is mandatory:** the click must carry the `mcp_authreq` cookie for the same `req`. A link opened on another device or browser is rejected with "Finish in the same browser". This blocks cross-account authorization, where an attacker starts a request and seeds a challenge to a victim's email. The owner is bound to the request exactly once. This tab shows no Allow form. It says "go back to the window where you started", with an "Approve in this tab instead" link for flows that ran in this same tab or whose window was closed (the app may not finish connecting from there). 4. **Back to the window that started it.** OAuth clients (ChatGPT, claude.ai) open `/oauth/authorize` in a popup and expect the redirect back in that window, which holds their pending state. The email link opens a new tab, and a redirect from there fails ("Missing OAuth callback data"). So the "check your email" page polls `GET /oauth/status` every 2.5 seconds for up to 15 minutes. - `/oauth/status` answers only for the request named in the browser's signed cookie: `pending`, `ready`, `done` or `expired`. It sends `Cache-Control: no-store`. - - On `ready`, the page moves to `GET /oauth/approve`. That page requires the same cookie and an owner already bound, which only step 3 does. It renders consent with a fresh CSRF token. + - On `ready`, the page moves to `GET /oauth/approve`. That page requires the same cookie and an owner already bound, which only step 3 does. It renders consent with the request's CSRF token from the cookie, minting one only if the cookie has none. Every window of the browser shares that cookie, so a second load (the link tab's fallback, a reload) must not replace the token: that would fail the Allow form already open in the popup. - Both endpoints reveal nothing without the cookie. Neither adds a path around the same-browser binding. - A no-JavaScript fallback link, "I've clicked the link, continue", goes to the same page. 5. **Consent**, `POST /oauth/decision`. The page names the client and its redirect host. Allow consumes the request atomically and mints a 60-second single-use code bound to the full tuple (client, owner, redirect URI, challenge, resource, scope). Deny redirects with `error=access_denied`. @@ -87,6 +87,7 @@ The request is persisted for 10 minutes. A signed, httpOnly, `SameSite=Lax` cook - **`authorization_code`.** The code is consumed atomically, so a replay is dead. The server re-verifies `client_id`, the exact `redirect_uri`, PKCE (`base64url(sha256(code_verifier))`) and `resource` if it is resent. It returns an access token (1 hour), a refresh token (30 days), `token_type: Bearer` and the scope. - **`refresh_token`.** The token rotates on every use. **Reuse detection:** presenting an already-consumed refresh token revokes the whole chain. If `client_id` is sent, it must be the token's own client. - Errors use RFC 6749 §5.2 JSON (`invalid_grant`, `invalid_client`, `invalid_target`, `unsupported_grant_type`). +- Every response, success or error, carries `Cache-Control: no-store` and `Pragma: no-cache` (RFC 6749 §5.1). A failed refresh logs `[oauth] refresh failed` with its status (`reused` means the chain was just revoked) and the first 8 characters of `client_id`, so a dropped connection shows in `wrangler tail`. ## §5 The `/mcp` resource server diff --git a/packages/api/src/mcp/handler.test.ts b/packages/api/src/mcp/handler.test.ts index 99532f90..c8b3fa15 100644 --- a/packages/api/src/mcp/handler.test.ts +++ b/packages/api/src/mcp/handler.test.ts @@ -220,6 +220,13 @@ describe('/mcp handler', () => { } }); + it('ping answers an empty result, anonymously too', async () => { + const pong = (await (await rpc(null, { jsonrpc: '2.0', id: 'p1', method: 'ping' })).json()) as Rpc; + expect(pong.error).toBeUndefined(); + expect(pong.result).toEqual({}); + expect(pong.id).toBe('p1'); + }); + it('unknown method → -32601; malformed JSON body → -32700', async () => { makeOwner('ow_e', 'e@example.com'); const token = await mintToken({ ownerId: 'ow_e', scope: 'registry:read' }); diff --git a/packages/api/src/mcp/handler.ts b/packages/api/src/mcp/handler.ts index 4783a5f0..32b6bacc 100644 --- a/packages/api/src/mcp/handler.ts +++ b/packages/api/src/mcp/handler.ts @@ -1256,6 +1256,9 @@ app.post('/mcp', bearerMiddleware, async (c) => { instructions: chatgptMeta.instructions, }); } + case 'ping': + // MCP basic utilities: a receiver MUST answer ping promptly with an empty result. + return rpcResult(c, id, {}); case 'tools/list': return rpcResult(c, id, { tools: TOOLS.map((t) => ({ diff --git a/packages/api/src/mcp/oauth.test.ts b/packages/api/src/mcp/oauth.test.ts index d0452a00..0573db8c 100644 --- a/packages/api/src/mcp/oauth.test.ts +++ b/packages/api/src/mcp/oauth.test.ts @@ -564,6 +564,25 @@ describe('sign-in completes in the window that started it', () => { expect(await status(appr.cookie)).toBe('done'); }); + it('a second Approve load (the link tab or a reload) keeps the popup\'s Allow form valid', async () => { + const { popupCookie, lt, req } = await startAndSend('twice@example.com', 'st-twice'); + const linkTab = await app.request(`/oauth/continue?lt=${encodeURIComponent(lt)}&req=${encodeURIComponent(req)}`, { headers: { Cookie: popupCookie } }, ENV); + const jar = cookieOf(linkTab); + // The popup polls to ready and renders its Allow form first... + const popup = await approve(jar); + // ...then the link tab's "Approve in this tab instead" loads the page again + // in the same browser, re-arming the shared cookie. + const linkTabApprove = await approve(popup.cookie); + expect(linkTabApprove.csrf).toBe(popup.csrf); + // The browser now holds the cookie from the latest load; the popup's form + // (rendered earlier) must still verify against it. + const dec = await app.request('/oauth/decision', { method: 'POST', headers: { ...FORM, Cookie: linkTabApprove.cookie }, body: new URLSearchParams({ req, csrf: popup.csrf, decision: 'allow' }).toString() }, ENV); + expect(dec.status).toBe(302); + const loc = new URL(dec.headers.get('location')!); + expect(loc.searchParams.get('code')).toBeTruthy(); + expect(loc.searchParams.get('state')).toBe('st-twice'); + }); + it('status and approve answer only for the cookie: no cookie or a forged one gets nothing', async () => { const { lt, req, popupCookie } = await startAndSend('nocookie@example.com'); await app.request(`/oauth/continue?lt=${encodeURIComponent(lt)}&req=${encodeURIComponent(req)}`, { headers: { Cookie: popupCookie } }, ENV); @@ -622,6 +641,19 @@ describe('/oauth/token', () => { expect(body.scope).toBe('registry:read board:post'); }); + it('marks every token response, success or error, as not cacheable', async () => { + const verifier = 'nostore-verifier-0000000'; + const { cid, code } = await freshCode('nostore@example.com', verifier); + const ok = await tokenExchange({ grant_type: 'authorization_code', code, client_id: cid, redirect_uri: REDIRECT, code_verifier: verifier }); + expect(ok.status).toBe(200); + const bad = await tokenExchange({ grant_type: 'refresh_token', refresh_token: 'not-a-real-refresh-token', client_id: cid }); + expect(bad.status).toBe(400); + for (const res of [ok, bad]) { + expect(res.headers.get('cache-control')).toBe('no-store'); + expect(res.headers.get('pragma')).toBe('no-cache'); + } + }); + it('rejects a wrong PKCE verifier with invalid_grant', async () => { const { cid, code } = await freshCode('t2@example.com', 'right-verifier-111111'); const res = await tokenExchange({ grant_type: 'authorization_code', code, client_id: cid, redirect_uri: REDIRECT, code_verifier: 'WRONG-verifier' }); diff --git a/packages/api/src/mcp/oauth.ts b/packages/api/src/mcp/oauth.ts index 199f0d6a..2105ce37 100644 --- a/packages/api/src/mcp/oauth.ts +++ b/packages/api/src/mcp/oauth.ts @@ -535,17 +535,28 @@ function authreqStatus(row: AuthRequestRow | null, now: string): 'pending' | 're } /** - * The Approve page, with a fresh CSRF token re-armed into the cookie. Shows + * The Approve page, re-arming the cookie with the request's existing CSRF + * token (a fresh one only when the cookie has none). Reusing it keeps every + * window of the same browser on one token: the popup and the email-link tab + * share the cookie jar, so minting a new token per load would let a second + * /oauth/approve load (the link tab's "Approve here instead", a reload) + * silently invalidate the Allow form already showing in the popup. Shows * WHAT is being authorized (defense in depth + informed consent): the * registered client name and the redirect host the code will be sent to, so * the human can recognize the app instead of blindly approving. */ -async function approvePage(c: Context, config: McpConfig, store: OAuthStore, row: AuthRequestRow): Promise { +async function approvePage( + c: Context, + config: McpConfig, + store: OAuthStore, + row: AuthRequestRow, + existingCsrf: string | undefined, +): Promise { const client = await store.getClient(row.client_id); const clientLabel = client?.client_name || 'An application'; let redirectHost = ''; try { redirectHost = new URL(row.redirect_uri).host; } catch { redirectHost = ''; } - const csrf = newCsrfToken(); + const csrf = existingCsrf || newCsrfToken(); setCookie(c, AUTHREQ_COOKIE, await signCookie(config.signingSecret, { authreq_id: row.id, csrf }), { httpOnly: true, secure: true, @@ -615,7 +626,8 @@ app.get('/oauth/approve', async (c) => { 400, ); } - return approvePage(c, config, store, row); + // The row was looked up by this cookie's authreq_id, so its csrf belongs to it. + return approvePage(c, config, store, row, cookie?.csrf); }); // ── GET /oauth/continue (magic-link landing) ── @@ -690,7 +702,8 @@ app.get('/oauth/continue', async (c) => { 'Signed in', `

You're signed in

Go back to the window where you started connecting. It continues by itself and asks you to approve there. You can close this tab.

-

Don't see that window anymore? Approve here instead.

`, +

Closed that window? Approve in this tab instead. The app may not + finish connecting from here; if it doesn't, restart the connection from the app.

`, ), ); }); @@ -757,6 +770,10 @@ app.post('/oauth/token', async (c) => { const config = cfg(c); const db = getDb(c); const store = new OAuthStore(db); + // RFC 6749 §5.1: responses carrying tokens MUST NOT be cached. Set once, so + // every success and error body from this endpoint carries it. + c.header('Cache-Control', 'no-store'); + c.header('Pragma', 'no-cache'); const form = await c.req.parseBody(); const grantType = typeof form.grant_type === 'string' ? form.grant_type : ''; const now = nowIso(); @@ -823,6 +840,9 @@ app.post('/oauth/token', async (c) => { // the whole chain and fails; unknown/expired/revoked also fail. const rot = await store.rotateRefreshToken(presented, now); if (rot.status !== 'ok') { + // A failed refresh ends that connection until the user signs in again, + // so leave a trace: 'reused' means the whole chain was just revoked. + console.warn('[oauth] refresh failed', { status: rot.status, client: clientId?.slice(0, 8) ?? 'none' }); return oauthError(c, 400, 'invalid_grant', 'refresh token invalid, expired, or reused'); } // If the client identifies itself it must be the token's own client. From 6653f3b788fd9ebcb130cc7e5a3dfa4d5560149d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:25:09 +0000 Subject: [PATCH 25/25] MCP sign-in: Deny is final; a wrong client_id no longer burns a refresh token Two findings from an adversarial review of the shared-CSRF change: - Deny now consumes the authorization request before redirecting with access_denied. With one CSRF token per request, an Allow form still open in another window could otherwise mint a code after the user denied; polling windows now see 'done'. - rotateRefreshToken takes the presented client_id and checks it before consuming the token. A mismatch returns invalid_client and leaves the token usable by its own client, instead of consuming it unreturned and turning the owner's next refresh into a 'reused' chain revocation. The refusal is logged like every other failed refresh. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5VPBCqJw8VrBa7GrLboYa --- CHANGELOG.md | 3 ++- MCP_CONNECTOR_SPEC.md | 4 ++-- packages/api/src/mcp/oauth-store.test.ts | 7 ++++++ packages/api/src/mcp/oauth-store.ts | 11 +++++++-- packages/api/src/mcp/oauth.test.ts | 30 ++++++++++++++++++++++++ packages/api/src/mcp/oauth.ts | 20 ++++++++++------ 6 files changed, 63 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d07e0cbb..a2210dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The hosted MCP's sign-in popup and the email-link tab share one cookie, and every load of the Approve page used to mint a new CSRF token into it. If the link tab's fallback link (or a reload) loaded the page again, the Allow button already showing in the popup failed with "csrf validation failed". The only Allow that still worked was the one in the email tab, which returns to ChatGPT without its pending state, so the connection never finished. - **One token per sign-in.** The Approve page reuses the CSRF token the request's cookie already carries, so every window of the browser agrees on it. +- **Deny is final.** Denying consumes the request, so an Allow button still open in another window can't connect afterwards. - **The fallback says what it costs.** The email tab's link now reads "Approve in this tab instead" and warns that the app may not finish connecting from there. - **Token responses are never cached.** `/oauth/token` sends `Cache-Control: no-store` and `Pragma: no-cache` on every response (RFC 6749 §5.1). -- **Failed refreshes are logged.** A refused refresh logs its status and a client-id prefix, so a connection that drops after its 1-hour token shows in `wrangler tail`. +- **Failed refreshes are logged, and a wrong client doesn't burn the token.** A refused refresh logs its status and a client-id prefix, so a connection that drops after its 1-hour token shows in `wrangler tail`. A refresh sent with another client's id is refused before the token is used up, so it no longer turns the owner's next refresh into a false theft alarm that revokes the connection. - **`ping` answers.** The server returns an empty result for MCP's `ping`, instead of "method not found". ### Fixed — the hire endpoints get listed even when the client echoes nothing (api) diff --git a/MCP_CONNECTOR_SPEC.md b/MCP_CONNECTOR_SPEC.md index 39fb754b..6943a2b2 100644 --- a/MCP_CONNECTOR_SPEC.md +++ b/MCP_CONNECTOR_SPEC.md @@ -78,14 +78,14 @@ The request is persisted for 10 minutes. A signed, httpOnly, `SameSite=Lax` cook - On `ready`, the page moves to `GET /oauth/approve`. That page requires the same cookie and an owner already bound, which only step 3 does. It renders consent with the request's CSRF token from the cookie, minting one only if the cookie has none. Every window of the browser shares that cookie, so a second load (the link tab's fallback, a reload) must not replace the token: that would fail the Allow form already open in the popup. - Both endpoints reveal nothing without the cookie. Neither adds a path around the same-browser binding. - A no-JavaScript fallback link, "I've clicked the link, continue", goes to the same page. -5. **Consent**, `POST /oauth/decision`. The page names the client and its redirect host. Allow consumes the request atomically and mints a 60-second single-use code bound to the full tuple (client, owner, redirect URI, challenge, resource, scope). Deny redirects with `error=access_denied`. +5. **Consent**, `POST /oauth/decision`. The page names the client and its redirect host. Allow consumes the request atomically and mints a 60-second single-use code bound to the full tuple (client, owner, redirect URI, challenge, resource, scope). Deny also consumes the request, so an Allow form still open in another window can't mint a code afterwards, then redirects with `error=access_denied`. ## §4 Tokens `POST /oauth/token` (form-encoded): - **`authorization_code`.** The code is consumed atomically, so a replay is dead. The server re-verifies `client_id`, the exact `redirect_uri`, PKCE (`base64url(sha256(code_verifier))`) and `resource` if it is resent. It returns an access token (1 hour), a refresh token (30 days), `token_type: Bearer` and the scope. -- **`refresh_token`.** The token rotates on every use. **Reuse detection:** presenting an already-consumed refresh token revokes the whole chain. If `client_id` is sent, it must be the token's own client. +- **`refresh_token`.** The token rotates on every use. **Reuse detection:** presenting an already-consumed refresh token revokes the whole chain. If `client_id` is sent, it must be the token's own client. That is checked before the token is consumed, so a mismatch (`invalid_client`) leaves the token usable by its own client. - Errors use RFC 6749 §5.2 JSON (`invalid_grant`, `invalid_client`, `invalid_target`, `unsupported_grant_type`). - Every response, success or error, carries `Cache-Control: no-store` and `Pragma: no-cache` (RFC 6749 §5.1). A failed refresh logs `[oauth] refresh failed` with its status (`reused` means the chain was just revoked) and the first 8 characters of `client_id`, so a dropped connection shows in `wrangler tail`. diff --git a/packages/api/src/mcp/oauth-store.test.ts b/packages/api/src/mcp/oauth-store.test.ts index 1f821173..1f7641bf 100644 --- a/packages/api/src/mcp/oauth-store.test.ts +++ b/packages/api/src/mcp/oauth-store.test.ts @@ -200,6 +200,13 @@ describe('oauth_refresh_tokens — rotation + reuse detection', () => { expect((await store.rotateRefreshToken(token, iso())).status).toBe('invalid'); }); + it('another client_id → client_mismatch, and the token stays usable by its own client', async () => { + seedOwner('ow_a'); + const { token } = await store.mintRefreshToken({ clientId: 'oc_1', ownerId: 'ow_a', resource: RESOURCE, scope: 'board:post' }); + expect((await store.rotateRefreshToken(token, iso(), 'oc_2')).status).toBe('client_mismatch'); + expect((await store.rotateRefreshToken(token, iso(), 'oc_1')).status).toBe('ok'); + }); + it('REUSE of a consumed token revokes the ENTIRE successor chain', async () => { seedOwner('ow_a'); const { token: r0 } = await store.mintRefreshToken({ clientId: 'oc_1', ownerId: 'ow_a', resource: RESOURCE, scope: 'board:post' }); diff --git a/packages/api/src/mcp/oauth-store.ts b/packages/api/src/mcp/oauth-store.ts index 4a2f0f34..fdfc1302 100644 --- a/packages/api/src/mcp/oauth-store.ts +++ b/packages/api/src/mcp/oauth-store.ts @@ -118,7 +118,8 @@ export interface AccessTokenRow { export type RotateResult = | { status: 'ok'; refresh_token: string; owner_id: string; client_id: string; resource: string; scope: string } | { status: 'reused' } // presented an already-consumed token → chain revoked - | { status: 'invalid' }; // unknown / revoked / expired + | { status: 'invalid' } // unknown / revoked / expired + | { status: 'client_mismatch' }; // live token presented by another client_id; left unconsumed // Narrow raw-row typing without pulling a helper: every column we read is TEXT. type Raw = Record; @@ -506,6 +507,9 @@ export class OAuthStore { * - already CONSUMED (replay = theft) → walk `rotated_to` from the presented * row forward and revoke the entire successor chain, forcing full re-auth → * {status:'reused'} + * - live, but `expectedClientId` given and not the token's own client + * → {status:'client_mismatch'}, token left + * untouched (a mismatch must not burn the legitimate holder's token) * - live + unconsumed → atomically consume it (SET consumed_at, * rotated_to=; changes===1 is the gate), then mint the successor → * {status:'ok', refresh_token, …binding} @@ -514,7 +518,7 @@ export class OAuthStore { * consume, changes!==1 and we mint no successor (no orphan valid token). The * caller mints the paired access token from the returned binding. */ - async rotateRefreshToken(token: string, nowIso: string): Promise { + async rotateRefreshToken(token: string, nowIso: string, expectedClientId?: string): Promise { const presentedHash = sha256hex(token); const row = await this.db.get( `SELECT client_id, owner_id, resource, scope, expires_at, rotated_to, @@ -533,6 +537,9 @@ export class OAuthStore { if (row.revoked_at != null || s(row.expires_at) <= nowIso) { return { status: 'invalid' }; } + if (expectedClientId !== undefined && expectedClientId !== s(row.client_id)) { + return { status: 'client_mismatch' }; + } const binding = { client_id: s(row.client_id), diff --git a/packages/api/src/mcp/oauth.test.ts b/packages/api/src/mcp/oauth.test.ts index 0573db8c..658d37e1 100644 --- a/packages/api/src/mcp/oauth.test.ts +++ b/packages/api/src/mcp/oauth.test.ts @@ -583,6 +583,22 @@ describe('sign-in completes in the window that started it', () => { expect(loc.searchParams.get('state')).toBe('st-twice'); }); + it('Deny is final: an Allow form still open in another window cannot mint a code afterwards', async () => { + const { popupCookie, lt, req } = await startAndSend('denyfinal@example.com', 'st-deny'); + const jar = cookieOf(await app.request(`/oauth/continue?lt=${encodeURIComponent(lt)}&req=${encodeURIComponent(req)}`, { headers: { Cookie: popupCookie } }, ENV)); + const popup = await approve(jar); + const linkTab = await approve(popup.cookie); // same browser, same token + const deny = await app.request('/oauth/decision', { method: 'POST', headers: { ...FORM, Cookie: linkTab.cookie }, body: new URLSearchParams({ req, csrf: linkTab.csrf, decision: 'deny' }).toString() }, ENV); + expect(deny.status).toBe(302); + expect(new URL(deny.headers.get('location')!).searchParams.get('error')).toBe('access_denied'); + expect(await status(linkTab.cookie)).toBe('done'); + + const lateAllow = await app.request('/oauth/decision', { method: 'POST', headers: { ...FORM, Cookie: linkTab.cookie }, body: new URLSearchParams({ req, csrf: popup.csrf, decision: 'allow' }).toString() }, ENV); + expect(lateAllow.status).toBe(400); + const codes = rawDb.prepare('SELECT COUNT(*) AS n FROM oauth_auth_codes').get() as { n: number }; + expect(codes.n).toBe(0); + }); + it('status and approve answer only for the cookie: no cookie or a forged one gets nothing', async () => { const { lt, req, popupCookie } = await startAndSend('nocookie@example.com'); await app.request(`/oauth/continue?lt=${encodeURIComponent(lt)}&req=${encodeURIComponent(req)}`, { headers: { Cookie: popupCookie } }, ENV); @@ -712,6 +728,20 @@ describe('/oauth/token', () => { expect(live.n).toBe(0); }); + it('a refresh from the wrong client_id is refused without burning the token', async () => { + const verifier = 'mismatch-verifier-666666'; + const { cid, code } = await freshCode('t7@example.com', verifier); + const first = (await (await tokenExchange({ grant_type: 'authorization_code', code, client_id: cid, redirect_uri: REDIRECT, code_verifier: verifier })).json()) as { refresh_token: string }; + + const wrong = await tokenExchange({ grant_type: 'refresh_token', refresh_token: first.refresh_token, client_id: 'oc_someone_else' }); + expect(wrong.status).toBe(400); + expect((await wrong.json()) as { error: string }).toMatchObject({ error: 'invalid_client' }); + + // The token's own client can still use it: nothing was consumed or revoked. + const right = await tokenExchange({ grant_type: 'refresh_token', refresh_token: first.refresh_token, client_id: cid }); + expect(right.status).toBe(200); + }); + it('rejects an unsupported grant_type', async () => { const res = await tokenExchange({ grant_type: 'password', username: 'x', password: 'y' }); expect(res.status).toBe(400); diff --git a/packages/api/src/mcp/oauth.ts b/packages/api/src/mcp/oauth.ts index 2105ce37..58597ade 100644 --- a/packages/api/src/mcp/oauth.ts +++ b/packages/api/src/mcp/oauth.ts @@ -738,6 +738,12 @@ app.post('/oauth/decision', async (c) => { // Deny → redirect with access_denied (RFC 6749 §4.1.2.1), no code minted. if (decision !== 'allow') { + // Deny is final: consume the request so an Allow form still open in + // another window of this browser (they share one CSRF token) cannot mint + // a code after the user said no, and polling windows see 'done'. + if (!(await store.consumeAuthRequest(req, now))) { + return oauthError(c, 400, 'invalid_request', 'authorization request already used or expired'); + } const url = new URL(row.redirect_uri); url.searchParams.set('error', 'access_denied'); if (row.state) url.searchParams.set('state', row.state); @@ -837,17 +843,17 @@ app.post('/oauth/token', async (c) => { return oauthError(c, 400, 'invalid_grant', 'refresh_token required'); } // Rotate with reuse-detection: a replayed (already-consumed) token revokes - // the whole chain and fails; unknown/expired/revoked also fail. - const rot = await store.rotateRefreshToken(presented, now); + // the whole chain and fails; unknown/expired/revoked also fail. If the + // client identifies itself it must be the token's own client, checked + // before the token is consumed so a mismatch doesn't burn it. + const rot = await store.rotateRefreshToken(presented, now, clientId); if (rot.status !== 'ok') { // A failed refresh ends that connection until the user signs in again, // so leave a trace: 'reused' means the whole chain was just revoked. console.warn('[oauth] refresh failed', { status: rot.status, client: clientId?.slice(0, 8) ?? 'none' }); - return oauthError(c, 400, 'invalid_grant', 'refresh token invalid, expired, or reused'); - } - // If the client identifies itself it must be the token's own client. - if (clientId !== undefined && clientId !== rot.client_id) { - return oauthError(c, 400, 'invalid_client', 'client_id does not match the refresh token'); + return rot.status === 'client_mismatch' + ? oauthError(c, 400, 'invalid_client', 'client_id does not match the refresh token') + : oauthError(c, 400, 'invalid_grant', 'refresh token invalid, expired, or reused'); } const access = await store.mintAccessToken({ clientId: rot.client_id,