From 0b5ad07281608b55aee7ff1b7dee06d54598f8df Mon Sep 17 00:00:00 2001 From: Kristian Yendrek Date: Mon, 14 Sep 2026 17:38:24 +0300 Subject: [PATCH 1/9] Correct seven built-in catalog entries the OAuth audit found wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data-only fixes from running fleet's own discovery against every official entry (#1006 audit, 2026-09-13/14), each verified live against the vendor: - expensify: URL is /mcp/ — the vendor 308s the slash-less form and fleet refuses redirects, so Add failed. - cartesia: the MCP endpoint is /mcp; the bare origin discovers (via the legacy origin fallback) but 404s every tool call (measured live). - octagon: the server's own metadata names mcp.octagonai.co as the resource; the entry's mcp.octagonagents.com host serves the same server but a token must be bound to the audience the vendor declares. - globalping: no longer open — initialize answers 401 with protected-resource metadata and a self-registering, public-client authorization server. - zerodha-kite: no OAuth at the transport (initialize answers 200, no metadata anywhere); users sign in through the server's `login` tool → open, with a hint saying so. - sage-intacct: no dynamic registration and no `none` auth method → client_registration: manual + client_secret: required, with a setup hint; pinned in TestBuiltinRemoteCatalogClientSecretRequired. - openrouter: docs_url 404 → https://openrouter.ai/docs/mcp. Not changed: square and smartlead document SSE-only endpoints, which fleet's hosted-connector transport (streamable HTTP) cannot speak — a product decision (hide, or keep with a warning) and possibly a transport gap, kept out of a data-only change. Co-Authored-By: Claude Fable 5.1 --- docs/CONNECTOR-ONBOARDING.md | 4 ++- .../clientconfig/builtin_remote_catalog.yaml | 35 ++++++++++++++----- internal/clientconfig/remote_catalog_test.go | 2 +- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/docs/CONNECTOR-ONBOARDING.md b/docs/CONNECTOR-ONBOARDING.md index 196191a5..c7f335a2 100644 --- a/docs/CONNECTOR-ONBOARDING.md +++ b/docs/CONNECTOR-ONBOARDING.md @@ -74,7 +74,9 @@ is the validation. whose live metadata said so on 2026-09-10 — GitHub (measured first: a secretless exchange answers `incorrect_client_credentials`, #1006), Slack, HubSpot, Zoom, Box, Asana, Docusign, Front, Wrike, Xero, AlloyDB and the - eight Google Workspace servers. Left off `amazon-ads` and `doordash`, whose + eight Google Workspace servers — and Sage Intacct, whose metadata the + 2026-09-14 catalog audit read the same way (no registration endpoint, no + `none`). Left off `amazon-ads` and `doordash`, whose metadata lists `none`, and off five of the six `tenant` entries, whose URL cannot be probed without a tenant. Azure DevOps is the sixth: its protected-resource metadata is organization-agnostic and names Microsoft diff --git a/internal/clientconfig/builtin_remote_catalog.yaml b/internal/clientconfig/builtin_remote_catalog.yaml index d70a6513..7ce664a1 100644 --- a/internal/clientconfig/builtin_remote_catalog.yaml +++ b/internal/clientconfig/builtin_remote_catalog.yaml @@ -2959,7 +2959,7 @@ servers: find missing receipts, and analyze trends. Read-only — cannot approve reports, edit expenses, reimburse, or change workspace settings. OAuth 2.1 with PKCE. - url: "https://www.expensify.com/mcp" + url: "https://www.expensify.com/mcp/" vendor: Expensify, Inc. docs_url: "https://help.expensify.com/articles/new-expensify/connections/connect-ai-assistants/Connect-Claude-to-Expensify-Using-MCP" category: finance @@ -3056,7 +3056,7 @@ servers: tools over SEC filings, earnings-call transcripts, financial metrics, private/public market data, deep research, and prediction-market research. OAuth recommended; a non-OAuth variant embeds the API key in the path. - url: "https://mcp.octagonagents.com/mcp" + url: "https://mcp.octagonai.co/mcp" vendor: Octagon AI, Inc. docs_url: "https://octagonai.co/docs/guide/mcp-server" repo_url: "https://github.com/OctagonAI/octagon-mcp-server" @@ -3105,6 +3105,17 @@ servers: tags: [accounting, erp, general-ledger, read-only] provenance: official auth: oauth + client_registration: manual + client_secret: required + setup_hint: >- + Sage Intacct's authorization server registers no clients itself and + accepts no public clients, so an administrator creates an OAuth client + for fleet in Sage Intacct (an AI-Enabled Web Services Developer License + is required — see the setup guide), registers the callback URL shown in + this form as its redirect URI, and pastes the client ID and client + secret here. Sign in afterwards with the Intacct user the connector + should act as. + setup_url: "https://developer.sage.com/intacct/mcps/intacct-mcp/latest/intacct-mcp-server/setup-and-connection-guide" - name: thirdweb display_name: thirdweb description: >- @@ -3182,7 +3193,9 @@ servers: Official hosted MCP for Zerodha brokerage accounts: market quotes, historical data, instrument search, holdings, positions, margins and read- only order/GTT data. The hosted instance deliberately excludes - place/modify/cancel order tools; SSE also available at /sse. + place/modify/cancel order tools. No OAuth at the transport: the server + answers unauthenticated and the user signs in through its `login` tool, + which returns a Kite login link to open in the browser. url: "https://mcp.kite.trade/mcp" vendor: Zerodha Broking Ltd. docs_url: "https://zerodha.com/products/mcp/" @@ -3190,7 +3203,11 @@ servers: category: finance tags: [trading, brokerage, portfolio, market-data, india] provenance: official - auth: oauth + auth: open + setup_hint: >- + Nothing to enter: add the server, then ask the assistant to call the + Kite `login` tool and open the link it returns to sign in with your + Zerodha credentials. The session lives on Kite's side. # ── design-media ──────────────────────────────────────────────── - name: adobe-creativity @@ -3539,7 +3556,9 @@ servers: display_name: Globalping description: >- Run real network measurements (ping, traceroute, DNS, MTR, HTTP) from - thousands of probes worldwide. Works unauthenticated at lower rate limits. + thousands of probes worldwide. OAuth sign-in with a Globalping account + (the server registers fleet as a client itself; higher rate limits than + the former anonymous access). url: "https://mcp.globalping.dev/mcp" vendor: Volentio JSD Limited (jsDelivr) docs_url: "https://github.com/jsdelivr/globalping-mcp-server" @@ -3547,7 +3566,7 @@ servers: category: observability tags: [network, ping, traceroute, dns, latency] provenance: official - auth: open + auth: oauth - name: grafana-cloud display_name: Grafana Cloud description: >- @@ -3934,7 +3953,7 @@ servers: cloning/localization, pronunciation dictionaries, and credit usage. OAuth sign-in via play.cartesia.ai; the org must have at least one API key provisioned for the hosted server to work. - url: "https://mcp.cartesia.ai" + url: "https://mcp.cartesia.ai/mcp" vendor: Cartesia AI, Inc. docs_url: "https://docs.cartesia.ai/integrations/mcp" category: ai-ml @@ -4075,7 +4094,7 @@ servers: rankings, benchmarks, docs search, and billable test messages. url: "https://mcp.openrouter.ai/mcp" vendor: OpenRouter, Inc. - docs_url: "https://openrouter.ai/docs/mcp-server" + docs_url: "https://openrouter.ai/docs/mcp" category: ai-ml tags: [models, llm, routing, pricing] provenance: official diff --git a/internal/clientconfig/remote_catalog_test.go b/internal/clientconfig/remote_catalog_test.go index e2be9134..7ea6e1ed 100644 --- a/internal/clientconfig/remote_catalog_test.go +++ b/internal/clientconfig/remote_catalog_test.go @@ -436,7 +436,7 @@ func TestBuiltinRemoteCatalogClientSecretRequired(t *testing.T) { secretRequired := []string{ "alloydb", "asana", "azure-devops", "box", "docusign", "front", "github", "google-calendar", "google-chat", "google-docs", "google-drive", "google-gemini-agent-platform", "google-gmail", "google-people", - "google-sheets", "google-slides", "hubspot", "slack", "wrike", "xero", "zoom", + "google-sheets", "google-slides", "hubspot", "sage-intacct", "slack", "wrike", "xero", "zoom", } for _, name := range secretRequired { e, ok := byName[name] From e1724873e74e38bf270a7a51b7539660883bd623 Mon Sep 17 00:00:00 2001 From: Kristian Yendrek Date: Mon, 14 Sep 2026 21:29:26 +0300 Subject: [PATCH 2/9] Record the #1006 OAuth pack: connector status table, catalog audit appendix, and an operator runbook for hosted connectors docs/MCP-CATALOG-STATUS.md is the record #986 asked for: the live-run table for the twelve hosted connectors exercised against real vendor accounts (add, consent, tools, tool calls from chat and scheduled tasks, refresh, sign-out, seats and sharing, with the fleet PR each one needed), the fleet changes the pack produced, the audit findings with their disposition, and a per-entry appendix from re-probing all 231 official OAuth/tenant/open entries with fleet's own discovery on the current code and catalog. docs/HOSTED-CONNECTORS-RUNBOOK.md is what an operator needs before and after Connect: how the callback URL is derived and which vendors refuse plain http, what the encryption key protects, the three shapes of Connect, the measured per-vendor prerequisites (GitHub, Google Workspace, Slack, Notion, Linear, Azure DevOps, Stripe, Grafana Cloud, Uptime Robot, Plaid, Intercom, Square, Smartlead), how chat and scheduled runs mount connections, seats and sharing, and how to read fleet.log and the Add/sign-in errors. Both pages are indexed in docs/README.md. Live results are separated from discovery-only probes throughout, and the vendors that were deliberately skipped (Square, Smartlead, the Plaid/Intercom fallback) are named as such. Co-Authored-By: Claude Fable 5.1 --- docs/HOSTED-CONNECTORS-RUNBOOK.md | 156 ++++++++++++++ docs/MCP-CATALOG-STATUS.md | 336 ++++++++++++++++++++++++++++++ docs/README.md | 7 + 3 files changed, 499 insertions(+) create mode 100644 docs/HOSTED-CONNECTORS-RUNBOOK.md create mode 100644 docs/MCP-CATALOG-STATUS.md diff --git a/docs/HOSTED-CONNECTORS-RUNBOOK.md b/docs/HOSTED-CONNECTORS-RUNBOOK.md new file mode 100644 index 00000000..de874214 --- /dev/null +++ b/docs/HOSTED-CONNECTORS-RUNBOOK.md @@ -0,0 +1,156 @@ +# Hosted MCP connectors — operator runbook + +How to connect a hosted (official, per-user OAuth) MCP server to fleet, what +each vendor needs before the Connect button works, how to tell a fleet +problem from a vendor problem, and what to do when a connection stops. Every +statement here was measured against the vendor in the #1006 OAuth pack; the +per-vendor results are in [`MCP-CATALOG-STATUS.md`](MCP-CATALOG-STATUS.md). +The design behind the mechanics is in [ADR-0009](adr/0009-per-user-remote-mcp-oauth.md) +(per-user OAuth), [ADR-0050](adr/0050-remote-mcp-seats.md) (seats), +[`REMOTE-MCP-MULTI-LOGIN.md`](REMOTE-MCP-MULTI-LOGIN.md) and +[`CONNECTION-SHARING.md`](CONNECTION-SHARING.md); the directory itself in +[`MCP-CATALOG.md`](MCP-CATALOG.md) and [`CONNECTOR-ONBOARDING.md`](CONNECTOR-ONBOARDING.md). + +## Before the first connection + +- **The callback URL is derived, not configured.** fleet advertises + `FLEET_PUBLIC_BASE_URL` + `/api/oauth/mcp/callback` and logs it at boot + (`remote MCP OAuth: ENABLED … redirect …`). `FLEET_PUBLIC_BASE_URL` must be + the browser's origin for the **web tier**, not the chat-server port. Every + vendor that asks for a redirect URI gets exactly that string. +- **Some vendors refuse plain-http callbacks.** Slack requires HTTPS. Microsoft + Entra allows `http://` only for `localhost` (not `127.0.0.1`) and ignores the + port there. Production deployments sit behind Caddy with TLS and never see + this; a local rig needs a tunnel for Slack. +- **`FLEET_MCP_OAUTH_ENCRYPTION_KEY` is the connection.** Tokens, client + secrets and registration tokens are sealed with it, bound to (owner, URL). + Lose or rotate it and every hosted connection must be reconnected by its + owner. Keep it where the database backups are. +- **Three shapes of Connect.** *One-click* — the vendor registers fleet as a + client itself (Notion, Linear, Stripe, Grafana Cloud, Uptime Robot, Plaid, + Cartesia, Globalping, …): the user clicks Connect and signs in. *Bring your + own client* — the vendor has no self-registration: an admin creates an OAuth + app at the vendor with fleet's callback URL and users paste the client ID + and, where the directory says **required**, the secret (GitHub, Slack, the + Google Workspace servers, Azure DevOps, Sage Intacct, …). *Tenant* — the + URL carries the customer's org or host; the form asks for it, then one of + the two flows above follows. +- **Why both a client secret and a login.** The secret identifies fleet, the + application, to the vendor; the login identifies the user whose data the + tools will act on. Vendors without self-registration need the first done by + hand; the second is always per user. + +## Per-vendor notes (measured) + +- **GitHub** — OAuth App (not a GitHub App) with fleet's callback as the + Authorization callback URL; the secret is mandatory — without it GitHub + answers `incorrect_client_credentials` with HTTP 200. Tokens live 8 hours + with a refresh token; `bad_refresh_token` means the user must reconnect. + "Revoke all user tokens" on the app is noticed at the next mount (401 → + *Reconnect needed*). No revocation endpoint: sign-out clears fleet's copy. +- **Google Workspace (Drive, Gmail, …)** — a Google Cloud OAuth client (Web + application) with fleet's callback; secret required. Enable the Workspace + MCP APIs on the project. fleet asks for `access_type=offline` and + `prompt=consent`, without which Google issues a one-hour token and no + refresh token. **The Workspace MCP servers are a Developer Preview**: a + personal Gmail account connects but every tool call answers + `The caller does not have permission`. Test with a Workspace account that is + enrolled. +- **Slack** — create the app from a manifest or by hand; under *Features → + Agents & AI Apps* turn on **Model Context Protocol** (without it the server + answers `App is not enabled for Slack MCP server access`); redirect URL must + be HTTPS; secret required. The user token has no expiry and no refresh + token; no revocation endpoint. The vendor's metadata names its bare origin + as the resource while serving MCP at `/mcp` — fleet keeps the typed URL. +- **Notion, Linear** — one click. Linear publishes a revocation endpoint, so + sign-out revokes at the vendor; Notion does not. +- **Azure DevOps** — the org must be connected to a Microsoft Entra tenant + (a standalone personal-account org is refused by Microsoft). In that + tenant: confirm the *Azure DevOps MCP* enterprise application exists (or + `az ad sp create --id 2a72489c-aab2-4b65-b93a-a91edccf33b8`), create an app + registration on the **Web** platform with fleet's callback, add a client + secret, add the *Azure DevOps MCP* delegated permission and **grant admin + consent**. Sign in with a **work account native to the tenant**: a personal + Microsoft account, even one made a tenant Member during Azure sign-up, loops + at Microsoft's account picker on the `organizations` endpoint fleet is + directed to. That user must have opened `dev.azure.com/` once, or every + tool call answers `Identity … has not been materialized`. Tokens live about + 70 minutes with a rotating refresh token; no revocation endpoint. +- **Stripe** — one click. Every API tool needs `stripe_context` (the account + id) and `livemode`; the model gets them from `list_available_accounts_or_orgs` + first. Calls made without them answer HTTP 422. +- **Grafana Cloud, Uptime Robot** — one click. Uptime Robot's server returns a + client secret at registration although fleet asked to be a public client; + fleet stores and uses it. +- **Plaid, Intercom** — no protected-resource metadata; connect worked on the + 2026-09-14 build. `main` currently refuses them at Add (their MCP path + answers 401 under every sub-path, which the discovery code reads as a + server failure). Known and deliberately not fixed at the time of writing. +- **Square, Smartlead** — SSE-only endpoints. fleet's hosted-connector + transport is streamable HTTP; these cannot connect today. + +## Running with connections + +- **Chat.** A connection mounts only when it is enabled in the conversation's + Tools picker. The web UI pre-enables the default set; an API client must + send `enabled_optional` naming the connection, or nothing hosted mounts and + the model reports "no MCP tools". +- **Scheduled tasks** mount every connected connection of the task's owner on + its default seat; `mcp_selection` pins a seat. A pin to a seat that is not + connected skips that connector and tells the model; a pin to a server the + owner never connected dead-letters the task without a model call. +- **Expired tokens refresh headlessly** in both chat and scheduled runs, + under a row lock. A connection that cannot refresh is skipped and marked + *Reconnect needed*; the run completes without it. +- **More than 128 tools** switches the run to deferred mode: the model finds + tools with `tool_search`, reads their schema with `tool_describe` (which + lists the required arguments) and calls them with `tool_call`, which refuses + a call missing a required argument and names it. +- **Seats and sharing.** One connection name can hold several logins; each is + a seat with one owner-chosen default. Sharing is per seat, the grantee never + sees the token, tool calls act as the owner at the vendor, and a revoked + share takes effect on the grantee's next turn. Share only what you would + hand over: a grantee gets the seat's full tool surface. + +## When something fails + +Read `fleet.log` first. The lines that matter: + +- `remote-mcp: skipping server "" for — token unavailable` — + refresh failed; the row is or will be *Reconnect needed*. Have the owner + click Connect. +- `remote-mcp: skipping server "" … — failed to connect: … HTTP 404` — + the vendor rejected the tools handshake; usually a wrong URL (check the + vendor's documented MCP path), not a credential problem, although the + model is currently told the connector "needs re-authorization" either way. +- `mcpbroker: tool call failed (masked to the caller): … HTTP 422 …` — the + vendor refused the tool arguments. The model sees only + `credential-owner call failed`; the real text is on this line. +- `scheduled task : wired N remote MCP server(s) for ` / + `skipped remote MCP server(s) needing re-auth or a missing pinned seat` — + what a scheduled run mounted and what it could not. +- `Fantasy tools registered: … N MCP tools DEFERRED` — the run is in deferred + mode. + +At **Add** time, the error toast names every metadata location fleet tried. +`fetch protected-resource metadata … 404` on every location means the vendor +publishes none; `authorization-server issuer mismatch` means the vendor's +document names another issuer and neither that issuer nor the vendor's host +vouched for its endpoints; `client secret required` means the vendor accepts +no public clients and the form needs the secret. + +At **sign-in**, errors starting with `AADSTS` come from Microsoft: +`50011` a redirect URI that does not match the app registration, `65001` +admin consent not granted, `90100` a stale sign-in page (start again in a +private window, or complete the new user's first sign-in at +myaccount.microsoft.com first). + +## Adding a vendor to the directory + +The catalog rules are in [`MCP-CATALOG.md`](MCP-CATALOG.md). Before listing a +hosted server, run discovery against it (the Connect button does exactly +that) and record: does it publish protected-resource metadata, does its +authorization server allow public clients (`none` in +`token_endpoint_auth_methods_supported`), does it self-register, and does the +URL you list answer the MCP `initialize` POST with 401 rather than 404. A +URL that discovers but 404s tool calls is the most common catalog error. diff --git a/docs/MCP-CATALOG-STATUS.md b/docs/MCP-CATALOG-STATUS.md new file mode 100644 index 00000000..1e075b7b --- /dev/null +++ b/docs/MCP-CATALOG-STATUS.md @@ -0,0 +1,336 @@ +# Hosted MCP connector status — the #1006 OAuth pack and the catalog audit + +The record for #986 ("test official MCPs and improve the catalog") and its +child #1006 ("test the OAuth flow with official MCPs"). Two kinds of evidence +are kept apart here: **live runs** — a real account, the browser consent +screen, a real tool call from a chat and from a scheduled task, a forced +token refresh, a sign-out — and **discovery probes**, which run fleet's own +`mcpoauth.Discover` and add-time guards against a vendor's published metadata +without logging in. A probe proves a server can be *added*; only a live run +proves it *works*. Dates are when the row was last verified; a row is not +re-verified by later releases. + +Everything below was run against a local rig: one fleet process with the +Postgres pair, the web tier on `http://localhost:3200`, real OpenRouter +models, real vendor accounts. Vendors that need an HTTPS callback (Slack) +were reached through a temporary Cloudflare quick tunnel. + +## Live runs (the manual OAuth pack) + +Legend — ✓ passed · ✗ failed · — not exercised · n/a the vendor has no such +thing · **fixed by** names the fleet PR that had to land first. + +| connector | auth shape | add | consent + token | tools list | tool call (chat) | scheduled task | refresh | sign-out | seats / sharing | verdict | last verified | +|---|---|---|---|---|---|---|---|---|---|---|---| +| GitHub | manual client, **secret required** | ✓ | ✓ | ✓ | ✓ `get_me` | ✓ | ✓ natural + forced (8 h token; dead refresh → `needs_reauth`) | local only (no revocation endpoint); vendor "Revoke all" → `needs_reauth` on the next 401 | 2 seats ✓ | **PASS** — fixed by #1405 #1446 #1449 #1454 #1464 #1465 #1467 | 2026-09-10 | +| Notion | dynamic registration, public client | ✓ | ✓ | ✓ | ✓ | ✓ pinned to the `test` seat | ✓ natural (headless in the scheduled run) | — | 2 seats ✓; share to a second user ✓, revoke ✓ | **PASS** | 2026-09-10 / sharing 2026-09-14 | +| Linear | dynamic registration, public client | ✓ | ✓ | ✓ | ✓ direct and deferred | ✓ | ✓ natural | ✓ real revocation endpoint; reconnect keeps the client id | — | **PASS** — fixed by #1467 (prompt roster) | 2026-09-09 | +| Slack | manual client, **secret required**, HTTPS callback | ✓ | ✓ | ✓ | ✓ deferred | ✓ `search_channels` | n/a — token has no expiry and no refresh token (rotation off) | local only (no revocation endpoint) | — | **PASS** with caveats — fixed by #1471 #1472; app needs the *Model Context Protocol* toggle | 2026-09-10 | +| Google Drive | manual client (GCP), **secret required** | ✓ | ✓ | ✓ | ✗ `The caller does not have permission` | mount + refresh ✓, call ✗ | ✓ forced (1 h token; needs `access_type=offline` + `prompt=consent`) | — | — | **BLOCKED by vendor** — Workspace MCP is a Developer Preview; a personal Gmail account is not eligible. fleet side fixed by #1465 | 2026-09-10 | +| Azure DevOps | tenant URL, manual Entra app, **secret required** | ✓ | ✓ tenant-native user | ✓ | ✓ `core_list_projects`, `repo_repository` | ✓ | ✓ forced (71 min token, rotated refresh token) | local only (no revocation endpoint) | — | **PASS** — fixed by #1481 | 2026-09-13 | +| Stripe | dynamic registration, public client | ✓ | ✓ | ✓ | ✓ four `stripe_api_read` calls in deferred mode | — | — | — | — | **PASS** (connect + tools) — fixed by #1482 (metadata location) and #1483 (required arguments in deferred mode) | 2026-09-14 | +| Grafana Cloud | dynamic registration, public client | ✓ | ✓ | ✓ | ✓ `list_users_by_org`, `list_datasources` | — | — | — | — | **PASS** (connect + tools) — fixed by #1482 | 2026-09-14 | +| Uptime Robot | dynamic registration (vendor returned a secret) | ✓ | ✓ | ✓ | ✓ `list-monitors`, `get-monitor-stats` | — | — | — | — | **PASS** (connect + tools) — fixed by #1485 (pointer only on POST) | 2026-09-14 | +| Plaid | no protected-resource metadata (origin is the AS) | ✓ | ✓ | ✓ | ✓ `list_teams` | — | — | — | — | **PASS on the 2026-09-14 rig build**; on `main` after #1485's final form discovery is refused (the vendor answers 401 under its MCP path). Not fixed — skipped by decision | 2026-09-14 | +| Cartesia | dynamic registration | ✓ (wrong URL) | ✓ | ✗ 404 | — | — | — | — | — | **catalog URL wrong**, corrected in #1495 (`/mcp`); not re-run since | 2026-09-14 | +| Intercom | no protected-resource metadata | probe ✓ | — | — | — | — | — | — | — | discovery only; same `main` refusal as Plaid | 2026-09-14 | + +Cross-cutting checks, all live: + +- **Seats** (#988): two GitHub accounts and two Notion accounts on one user; a + scheduled task pinned to a non-default seat mounts that seat and leaves the + default untouched; a pin to a seat that does not exist skips the connector + with a notice; a pin to a server the owner never connected dead-letters the + task before any model spend. +- **Sharing**: a second rig user given one Notion seat sees only that seat, can + call its tools from chat and from a scheduled task, cannot manage it (every + owner action answers 404), and loses it the moment the owner revokes; the + owner's token row is never touched by the grantee's use. +- **Headless refresh**: expired GitHub, Google Drive, Notion and Azure DevOps + tokens were refreshed by scheduled runs with no browser. +- **Deferred mode** (more than 128 tools): tool calls through + `tool_search`/`tool_describe`/`tool_call` for GitHub, Notion, Slack, Linear, + Stripe, Grafana Cloud, Uptime Robot. + +## Fleet changes the pack produced + +| PR | what it fixed | found by | +|---|---|---| +| #1405 | manual client without a secret sailed into a doomed exchange; `client_secret: required` on GitHub | GitHub | +| #1446 | system prompt claimed "no MCP tools connected" while hosted tools were mounted | GitHub | +| #1449 | connect failures were skipped silently; reason now logged (redacted) | GitHub | +| #1454 / #1464 | a vendor 401 at mount now marks the row `needs_reauth`; non-2xx bodies surfaced | GitHub revoke-all | +| #1465 | Google offline access; GitHub's 200-status OAuth errors; expired token without a refresh token marked | Google, GitHub | +| #1467 | live tool registry in the prompt built from the real roster | Linear | +| #1471 | Slack's resource indicator is its origin while the endpoint is `/mcp` | Slack | +| #1472 | `client_secret: required` on every manual entry whose metadata allows no public client | catalog sweep | +| #1481 | Entra's templated issuer; `offline_access` for Entra | Azure DevOps | +| #1482 | authorization-server metadata at RFC 8414's path-inserted location | audit (13 vendors) | +| #1483 | `tool_describe` shows required arguments; `tool_call` refuses a call missing one | Stripe | +| #1485 | servers with no protected-resource metadata; pointer only on POST | audit (Plaid, Uptime Robot, …) | +| #1488 | proxied authorization-server documents; registration retry as a confidential client; Auth0 `offline_access` | audit (DocuSign, ZoomInfo, Sprout Social, OVHcloud, Chargebee, Checkly) | +| #1495 | seven catalog entries with a wrong URL, auth type or docs link | audit | + +## The catalog audit (2026-09-13, re-run 2026-09-14) + +All 231 official entries with `auth: oauth`, `tenant` or `open` were probed +with fleet's own discovery. The per-entry appendix below is the re-run on the +code in `main` plus #1488 and #1495. Findings and their disposition: + +| id | finding | disposition | +|---|---|---| +| F1 | metadata only at RFC 8414's path-inserted location (13 vendors) | fixed, #1482 merged | +| F2 | no protected-resource metadata; origin is the AS | fixed, #1485 merged; `main`'s final form refuses a vendor that answers 401 under its MCP path (Plaid, Intercom) — **skipped by decision** | +| F3 | metadata pointer only on the POST reply | fixed, #1485 merged | +| F4 | document names another issuer (copy / proxy / hybrid) | fixed, #1488 open | +| F5 | registration always asks for a public client | two vendors accepted it anyway; retry on refusal in #1488 | +| F6 | `offline_access` not requested where the vendor needs it | Entra (#1481) and Auth0 (#1488); IdentityServer, Keycloak, Ory unverified | +| F7 | `tool_describe` hid the required-argument list | fixed, #1483 merged | +| F8 | the broker masks a vendor's 4xx argument error as "credential-owner call failed" | open, not started | +| F9 | `tool_search` ranks other connectors above the one named in the query | observation | +| F10 | a connect failure is announced to the model as "needs re-authorization" | open, not started | +| F11 | fleet has no legacy HTTP+SSE transport; Square and Smartlead document SSE-only endpoints | **skipped by decision**; entries left as they are | +| C1–C9 | seven catalog data errors (Expensify, Cartesia, Octagon, Globalping, Zerodha Kite, Sage Intacct, OpenRouter); Square, Smartlead untouched | #1495 open | +| F12 | Bugsnag's 401 points at its metadata over plain `http://`; the vendor redirects to https, fleet's client refuses redirects, and since #1485 a failed advertised pointer is fatal (it fell through to the well-known locations before) | open, not started — a same-host `http`→`https` upgrade of the pointer would cover it | +| V1 | 25 entries publish no scopes anywhere | live add needed per vendor | +| V2 | GoCardless, Adobe, Square, Wrike answer 403 to every unauthenticated request from the audit network | re-probe from another network before calling them broken | +| V3 | 22 tenant entries have a placeholder in the hostname and cannot be probed | expected | + + +## Appendix — every official OAuth, tenant and open entry, probed 2026-09-14 + +Probe = fleet's `mcpoauth.Discover` plus the add-time guards, run against the catalog as of #1495 with the code in `main` plus #1488. "discovery ✓" means Connect would reach the vendor's consent screen; it says nothing about tool calls. A tenant entry whose hostname carries a `{placeholder}` cannot be probed without a customer's value. Counts — discovery ✓: 177 · tenant: 31 · open ✓: 12 · discovery ✗: 7 · open: 3 · not probeable from the audit box: 1. + +| entry | auth | probe verdict | client shape | issuer | notes | +|---|---|---|---|---|---| +| adobe-creativity | oauth | discovery ✗ — 403 to every unauthenticated request from the audit network (V2) | — | | | +| ahrefs | oauth | discovery ✓ | self-registering, secret | https://api.ahrefs.com/ | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| airbyte | oauth | discovery ✓ | self-registering, secret | https://mcp.airbyte.ai/ | AS lists no `none`; fleet asks `none`, retries confidential (#1488); no scopes published (V1) | +| airtable | oauth | discovery ✓ | self-registering, public client ok | https://airtable.com/oauth2/v1 | | +| airwallex | oauth | discovery ✓ | self-registering, secret | https://mcp.airwallex.com/mcp | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| aiven | oauth | discovery ✓ | self-registering, public client ok | https://api.aiven.io | | +| alchemy | oauth | discovery ✓ | self-registering, secret | https://auth.alchemy.com | AS lists no `none`; fleet asks `none`, retries confidential (#1488); no scopes published (V1) | +| algolia | oauth | discovery ✓ | self-registering, public client ok | https://dashboard.algolia.com | | +| alloydb | oauth | discovery ✓ | manual client, secret | https://accounts.google.com | | +| alpha-vantage | oauth | discovery ✓ | self-registering, public client ok | https://mcp.alphavantage.co | | +| amazon-ads | oauth | discovery ✓ | manual client, public client ok | https://lwa.amazon.com | | +| amplitude | oauth | discovery ✓ | self-registering, public client ok | https://mcp.amplitude.com | advertises `offline_access`; refresh unverified | +| apify | oauth | discovery ✓ | self-registering, public client ok | https://console-backend.apify.com | | +| apollo-io | oauth | discovery ✓ | self-registering, public client ok | https://mcp.apollo.io | | +| asana | oauth | discovery ✓ | manual client, secret | https://app.asana.com | | +| atlassian | oauth | discovery ✓ | self-registering, public client ok | https://auth.atlassian.com/VCeDsk8ZHncYF1g234fKtc4lNipbBhu3 | | +| attio | oauth | discovery ✓ | self-registering, public client ok | https://app.attio.com | | +| audioscrape | oauth | discovery ✓ | self-registering, public client ok | https://mcp.audioscrape.com | | +| avalara-avatax | oauth | discovery ✓ | self-registering, secret | https://identity.avalara.com | AS lists no `none`; fleet asks `none`, retries confidential (#1488); advertises `offline_access`; refresh unverified | +| aws-knowledge | open | open ✓ (initialize 200) | n/a | | | +| aws-mcp | oauth | discovery ✓ | self-registering, public client ok | https://us-east-1.oauth.signin.aws | no scopes published (V1) | +| axiom | oauth | discovery ✓ | self-registering, public client ok | https://authorization.axiom.co | | +| azure-devops | tenant | discovery ✓ | manual client, secret | https://login.microsoftonline.com/organizations/v2.0 | | +| better-stack | oauth | discovery ✓ | self-registering, public client ok | https://betterstack.com | | +| black-forest-labs | oauth | discovery ✓ | self-registering, public client ok | https://uhjidycotobjggwyjdww.supabase.co/auth/v1 | advertises `offline_access`; refresh unverified | +| box | oauth | discovery ✓ | manual client, secret | https://api.box.com | no scopes published (V1) | +| brex | oauth | discovery ✓ | self-registering, public client ok | https://api.brex.com | | +| bright-data | tenant | discovery ✓ | self-registering, public client ok | https://brightdata.com | | +| browserstack | oauth | discovery ✓ | self-registering, public client ok | https://mcp.browserstack.com/ | | +| bugsnag | oauth | discovery ✗ — fetch protected-resource metadata the server advertised at http://bugsnag.mcp.smartbear.co | — | | | +| buildkite | oauth | discovery ✓ | self-registering, public client ok | https://mcp.buildkite.com | | +| cal-com | oauth | discovery ✓ | self-registering, public client ok | https://mcp.cal.com | no scopes published (V1) | +| calendly | oauth | discovery ✓ | self-registering, public client ok | https://calendly.com/ | | +| canva | oauth | discovery ✓ | self-registering, public client ok | https://mcp.canva.com | | +| cartesia | oauth | discovery ✓ | self-registering, secret | https://mcp.cartesia.ai/ | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| chargebee | tenant | discovery ✓ | manual client, public client ok | https://app.chargebee.com | | +| checkly | oauth | discovery ✓ | self-registering, public client ok | https://auth.checklyhq.com/ | | +| checkout-com | oauth | discovery ✓ | self-registering, secret | https://access.mcp.checkout.com/payment-operations | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| chromatic | tenant | tenant — not probeable without a real tenant value | — | | | +| chronosphere | tenant | tenant — not probeable without a real tenant value | — | | | +| clickhouse | oauth | discovery ✓ | self-registering, public client ok | https://mcp.clickhouse.cloud | | +| clickup | oauth | discovery ✓ | self-registering, public client ok | https://mcp.clickup.com | | +| close | oauth | discovery ✓ | self-registering, public client ok | https://api.close.com | | +| cloudflare | oauth | discovery ✓ | self-registering, public client ok | https://mcp.cloudflare.com | no scopes published (V1) | +| cloudflare-docs | open | open ✓ (initialize 200) | n/a | | | +| cloudinary | oauth | discovery ✓ | self-registering, public client ok | https://asset-management.mcp.cloudinary.com | | +| cockroachdb | oauth | discovery ✓ | self-registering, public client ok | https://cockroachlabs.cloud/mcp | | +| coingecko | open | open ✓ (initialize 200) | n/a | | | +| contentful | oauth | discovery ✓ | self-registering, public client ok | https://mcp.contentful.com | no scopes published (V1) | +| context7 | open | open ✓ (initialize 200) | n/a | | | +| coralogix | tenant | tenant — not probeable without a real tenant value | — | | | +| craft | oauth | discovery ✓ | self-registering, public client ok | https://mcp.craft.do/my/auth | no scopes published (V1) | +| cube | tenant | tenant — not probeable without a real tenant value | — | | | +| customer-io | oauth | discovery ✓ | self-registering, public client ok | https://mcp.customer.io | | +| databricks | tenant | tenant — not probeable without a real tenant value | — | | | +| datadog | oauth | discovery ✓ | self-registering, public client ok | https://mcp.datadoghq.com/v1/mcp | | +| dbt | tenant | tenant — not probeable without a real tenant value | — | | | +| deepwiki | open | open ✓ (initialize 200) | n/a | | | +| digitalocean | oauth | discovery ✓ | self-registering, public client ok | https://cloud.digitalocean.com | no scopes published (V1) | +| docusign | oauth | discovery ✓ | manual client, secret | https://account.docusign.com | | +| doordash | oauth | discovery ✓ | manual client, public client ok | https://identity.doordash.com | | +| dropbox | oauth | discovery ✓ | self-registering, public client ok | https://www.dropbox.com | | +| dune | oauth | discovery ✓ | self-registering, public client ok | https://dune.com/oauth/mcp | | +| dynatrace | tenant | tenant — not probeable without a real tenant value | — | | | +| egnyte | oauth | discovery ✓ | self-registering, secret | https://mcp-oauth.egnyte.com/egnyte-connect | AS lists no `none`; fleet asks `none`, retries confidential (#1488); no scopes published (V1) | +| elastic-agent-builder | tenant | tenant — not probeable without a real tenant value | — | | | +| expensify | oauth | discovery ✓ | self-registering, public client ok | https://www.expensify.com | | +| expo | oauth | discovery ✓ | self-registering, public client ok | https://mcp.expo.dev | | +| fellow | oauth | discovery ✓ | self-registering, public client ok | https://fellow.app | | +| fibery | oauth | discovery ✓ | self-registering, public client ok | https://mcp.fibery.io/ | | +| figma | oauth | discovery ✓ | self-registering, secret | https://api.figma.com | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| financial-datasets | oauth | discovery ✓ | self-registering, public client ok | https://mcp.financialdatasets.ai | | +| freshdesk | tenant | tenant — not probeable without a real tenant value | — | | | +| front | oauth | discovery ✓ | manual client, secret | https://app.frontapp.com | | +| gamma | oauth | discovery ✓ | self-registering, public client ok | https://auth.gamma.app | | +| github | oauth | discovery ✓ | manual client, secret | https://github.com/login/oauth | | +| gitlab | oauth | discovery ✓ | self-registering, secret | https://gitlab.com | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| globalping | oauth | discovery ✓ | self-registering, public client ok | https://mcp.globalping.dev | | +| gocardless | oauth | discovery ✗ — 403 to every unauthenticated request from the audit network (V2) | — | | | +| google-calendar | oauth | discovery ✓ | manual client, secret | https://accounts.google.com | | +| google-chat | oauth | discovery ✓ | manual client, secret | https://accounts.google.com | | +| google-cloud | tenant | tenant — not probeable without a real tenant value | — | | | +| google-docs | oauth | discovery ✓ | manual client, secret | https://accounts.google.com | | +| google-drive | oauth | discovery ✓ | manual client, secret | https://accounts.google.com | | +| google-gemini-agent-platform | oauth | discovery ✓ | manual client, secret | https://accounts.google.com | | +| google-gmail | oauth | discovery ✓ | manual client, secret | https://accounts.google.com | | +| google-people | oauth | discovery ✓ | manual client, secret | https://accounts.google.com | | +| google-sheets | oauth | discovery ✓ | manual client, secret | https://accounts.google.com | | +| google-slides | oauth | discovery ✓ | manual client, secret | https://accounts.google.com | | +| gorgias | oauth | discovery ✓ | self-registering, public client ok | https://mcp.gorgias.com/ | | +| grafana-cloud | oauth | discovery ✓ | self-registering, public client ok | https://mcp.grafana.com/mcp | | +| grain | oauth | discovery ✓ | self-registering, public client ok | https://api.grain.com | no scopes published (V1) | +| gram | tenant | tenant — not probeable without a real tenant value | — | | | +| granola | oauth | discovery ✓ | self-registering, public client ok | https://mcp-auth.granola.ai | advertises `offline_access`; refresh unverified | +| guru | oauth | discovery ✓ | self-registering, public client ok | https://mcp.api.getguru.com | | +| harness | oauth | discovery ✓ | self-registering, secret | https://id.harness.io/idp/realms/HarnessIDP | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| heroku | oauth | discovery ✓ | self-registering, secret | https://mcp.heroku.com | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| hex | oauth | discovery ✓ | self-registering, public client ok | https://auth.app.hex.tech | | +| heygen | oauth | discovery ✓ | self-registering, public client ok | https://api2.heygen.com | | +| honeycomb | oauth | discovery ✓ | self-registering, public client ok | https://ui.honeycomb.io | | +| hootsuite | oauth | discovery ✓ | self-registering, public client ok | https://platform.hootsuite.com | | +| hootsuite-lumen | oauth | discovery ✓ | self-registering, public client ok | https://app.talkwalker.com/app/ | | +| hootsuite-nest | oauth | discovery ✓ | self-registering, public client ok | https://platform.hootsuite.com | | +| hubspot | oauth | discovery ✓ | manual client, secret | https://mcp.hubspot.com | no scopes published (V1) | +| hugging-face | oauth | discovery ✓ | self-registering, secret | https://huggingface.co | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| ideogram | oauth | discovery ✓ | self-registering, secret | https://mcp.ideogram.ai/ | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| incident-io | oauth | discovery ✓ | self-registering, public client ok | https://mcp.incident.io/mcp | no scopes published (V1) | +| intercom | oauth | discovery ✗ — 401 at a metadata location; skipped by decision | — | | | +| jetbrains-youtrack | tenant | tenant — not probeable without a real tenant value | — | | | +| jotform | oauth | discovery ✓ | self-registering, public client ok | https://oauth2.jotform.com | | +| kiwi-flights | open | open ✓ (initialize 200) | n/a | | | +| klaviyo | oauth | discovery ✓ | self-registering, public client ok | https://mcp.klaviyo.com | no scopes published (V1) | +| lambdatest | oauth | discovery ✓ | self-registering, public client ok | https://auth.lambdatest.com | no scopes published (V1) | +| langsmith | oauth | discovery ✓ | self-registering, public client ok | https://api.smith.langchain.com | no scopes published (V1) | +| lemlist | oauth | discovery ✓ | self-registering, public client ok | https://app.lemlist.com | | +| linear | oauth | discovery ✓ | self-registering, public client ok | https://mcp.linear.app | | +| looker | tenant | tenant — not probeable without a real tenant value | — | | | +| lusha | oauth | discovery ✓ | self-registering, secret | https://auth.lusha.com | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| magnific-freepik | oauth | discovery ✓ | self-registering, public client ok | https://auth.magnific.com/realms/mcp | advertises `offline_access`; refresh unverified | +| mailerlite | oauth | discovery ✓ | self-registering, public client ok | https://mcp.mailerlite.com | no scopes published (V1) | +| mapbox | oauth | discovery ✓ | self-registering, public client ok | https://mcp.mapbox.com | | +| mercado-libre | oauth | discovery ✓ | self-registering, public client ok | https://mcp.mercadolibre.com/mcp | | +| mercado-pago | oauth | discovery ✓ | self-registering, public client ok | https://mcp.mercadopago.com/mcp | | +| mercury | oauth | discovery ✓ | self-registering, public client ok | https://mcp.mercury.com/ | | +| messari | oauth | discovery ✓ | self-registering, public client ok | https://mcp.messari.io | no scopes published (V1) | +| meta-ads | oauth | discovery ✓ | self-registering, public client ok | https://www.facebook.com/ads | | +| metabase | tenant | tenant — not probeable without a real tenant value | — | | | +| microsoft-dataverse | tenant | tenant — not probeable without a real tenant value | — | | | +| microsoft-learn | open | open ✓ (initialize 200) | n/a | | | +| microsoft-workiq-calendar | tenant | tenant — not probeable without a real tenant value | — | | | +| microsoft-workiq-mail | tenant | tenant — not probeable without a real tenant value | — | | | +| microsoft-workiq-sharepoint | tenant | tenant — not probeable without a real tenant value | — | | | +| microsoft-workiq-teams | tenant | tenant — not probeable without a real tenant value | — | | | +| miro | oauth | discovery ✓ | self-registering, secret | https://mcp.miro.com/ | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| mixpanel | oauth | discovery ✓ | self-registering, public client ok | https://mcp.mixpanel.com/mcp | | +| monday | oauth | discovery ✓ | self-registering, secret | https://auth.monday.com/mcp | AS lists no `none`; fleet asks `none`, retries confidential (#1488); no scopes published (V1) | +| motherduck | oauth | discovery ✓ | self-registering, public client ok | https://mcp-auth.motherduck.com | | +| mux | oauth | discovery ✓ | self-registering, public client ok | https://auth.mux.com | | +| neon | oauth | discovery ✓ | self-registering, public client ok | https://mcp.neon.tech | | +| netlify | oauth | discovery ✓ | self-registering, public client ok | https://netlify-mcp.netlify.app/ | | +| netsuite | tenant | tenant — not probeable without a real tenant value | — | | | +| new-relic | oauth | discovery ✓ | self-registering, public client ok | https://oauth2.service.newrelic.com | | +| notion | oauth | discovery ✓ | self-registering, public client ok | https://mcp.notion.com | | +| octagon | oauth | discovery ✓ | self-registering, public client ok | https://login.octagonai.co | | +| omni | tenant | tenant — not probeable without a real tenant value | — | | | +| openai-developer-docs | open | open ✓ (initialize 200) | n/a | | | +| openrouter | oauth | discovery ✓ | self-registering, public client ok | https://mcp.openrouter.ai | no scopes published (V1) | +| oracle-autonomous-db | tenant | tenant — not probeable without a real tenant value | — | | | +| orca-security | oauth | discovery ✓ | self-registering, public client ok | https://auth.orcasecurity.io | no scopes published (V1) | +| otter-ai | oauth | discovery ✓ | self-registering, public client ok | https://otter.ai | | +| outreach | oauth | discovery ✓ | self-registering, secret | https://api.outreach.io | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| ovhcloud | oauth | discovery ✓ | self-registering, secret | https://mcp.eu.ovhcloud.com/oauth-proxy | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| parallel-search | open | open ✓ (initialize 200) | n/a | | | +| paypal | oauth | discovery ✓ | self-registering, public client ok | https://mcp.paypal.com | | +| pika | oauth | discovery ✓ | self-registering, public client ok | https://ecyvlzfbufloietjsmtj.supabase.co/auth/v1 | | +| pinecone-assistant | tenant | tenant — not probeable without a real tenant value | — | | | +| pipedrive | oauth | discovery ✓ | self-registering, secret | https://oauth.pipedrive.com | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| plaid | oauth | discovery ✗ — 401 at a metadata location; skipped by decision | — | | | +| plain | oauth | discovery ✓ | self-registering, public client ok | https://signin.auth.plain.com | | +| plane | oauth | discovery ✓ | self-registering, secret | https://mcp.plane.so/http | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| planetscale | oauth | discovery ✓ | self-registering, secret | https://api.planetscale.com | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| polar | oauth | discovery ✓ | self-registering, public client ok | https://api.polar.sh | | +| posthog | oauth | discovery ✓ | self-registering, public client ok | https://oauth.posthog.com | | +| postman | oauth | discovery ✓ | self-registering, public client ok | https://mcp.postman.com | no scopes published (V1) | +| preset | oauth | discovery ✓ | self-registering, public client ok | https://api.superset.sh | | +| prisma | oauth | discovery ✓ | self-registering, public client ok | https://auth.prisma.io | | +| pulumi | oauth | discovery ✓ | self-registering, public client ok | https://mcp.ai.pulumi.com | | +| pydantic-logfire | oauth | discovery ✓ | self-registering, public client ok | https://logfire-us.pydantic.dev | | +| pylon | oauth | discovery ✓ | self-registering, public client ok | https://o.auth.usepylon.com | | +| railway | oauth | discovery ✓ | self-registering, public client ok | https://backboard.railway.com | | +| ramp | oauth | discovery ✓ | self-registering, public client ok | https://mcp.ramp.com | | +| read-ai | oauth | discovery ✓ | self-registering, public client ok | https://authn.read.ai/ | | +| recraft | oauth | discovery ✓ | self-registering, public client ok | https://mcp.recraft.ai | | +| render | oauth | not probeable from the audit box (its resolver maps the host to 127.0.0.1; public DNS is fine) | — | | | +| replicate | oauth | discovery ✓ | self-registering, public client ok | https://mcp.replicate.com | no scopes published (V1) | +| replit | oauth | discovery ✓ | self-registering, public client ok | https://replit.com/oidc | | +| rootly | oauth | discovery ✓ | self-registering, public client ok | https://rootly.com | | +| runpod | oauth | discovery ✓ | self-registering, public client ok | https://mcp.getrunpod.io | no scopes published (V1) | +| runway | oauth | discovery ✓ | self-registering, public client ok | https://mcp.runwayml.com | | +| sage-intacct | oauth | discovery ✓ | manual client, secret | https://mcp.intacct.com | | +| salesforce | tenant | discovery ✓ | self-registering, secret | https://login.salesforce.com | AS lists no `none`; fleet asks `none`, retries confidential (#1488); advertises `offline_access`; refresh unverified | +| sanity | oauth | discovery ✓ | self-registering, public client ok | https://mcp.sanity.io | | +| scrapfly | open | open — key in the URL; probe used a bogus key (answered 401) | n/a | | | +| scrapingbee | tenant | tenant — not probeable without a real tenant value | — | | | +| semaphore | oauth | discovery ✓ | self-registering, public client ok | https://mcp.semaphoreci.com/mcp/oauth | | +| semgrep | oauth | discovery ✓ | self-registering, public client ok | https://login.semgrep.dev | | +| semrush | oauth | discovery ✓ | self-registering, public client ok | https://oauth.semrush.com | advertises `offline_access`; refresh unverified | +| sentry | oauth | discovery ✓ | self-registering, public client ok | https://mcp.sentry.dev | | +| servicenow | tenant | tenant — not probeable without a real tenant value | — | | | +| shopify-storefront | tenant | tenant — not probeable without a real tenant value | — | | | +| shortcut | oauth | discovery ✓ | self-registering, public client ok | https://api.app.shortcut.com | | +| signoz | tenant | tenant — not probeable without a real tenant value | — | | | +| slack | oauth | discovery ✓ | manual client, secret | https://mcp.slack.com | | +| slite | oauth | discovery ✓ | self-registering, public client ok | https://slite.com/api/mcp/oauth | | +| smartlead | open | open — key in the URL; probe used a bogus key (answered 404) | n/a | | | +| snowflake | tenant | tenant — not probeable without a real tenant value | — | | | +| socket | oauth | discovery ✓ | self-registering, public client ok | https://api.socket.dev | | +| sourcegraph | tenant | tenant — not probeable without a real tenant value | — | | | +| spacelift | tenant | discovery ✓ | self-registering, public client ok | https://fleet-probe.app.spacelift.io | | +| sprout-social | oauth | discovery ✓ | self-registering, secret | https://identity.sproutsocial.com/oauth2/84e39c75-d770-45d9- | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| square | oauth | discovery ✗ — 403 to every unauthenticated request from the audit network (V2) | — | | | +| stainless | tenant | tenant — not probeable without a real tenant value | — | | | +| stripe | oauth | discovery ✓ | self-registering, public client ok | https://access.stripe.com/mcp | | +| supabase | oauth | discovery ✓ | self-registering, secret | https://api.supabase.com | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| superhuman-mail | oauth | discovery ✓ | self-registering, public client ok | https://mcp.auth.mail.superhuman.com | | +| surveymonkey | oauth | discovery ✓ | self-registering, secret | https://mcp.surveymonkey.com | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| teamwork | oauth | discovery ✓ | self-registering, public client ok | https://teamwork.com | | +| thirdweb | open | open — key in the URL; probe used a bogus key (answered 401) | n/a | | | +| ticktick | oauth | discovery ✓ | self-registering, public client ok | https://ticktick.com | | +| tigris | oauth | discovery ✓ | self-registering, public client ok | https://mcp.storage.dev | | +| tinybird | tenant | tenant — not probeable without a real tenant value | — | | | +| todoist | oauth | discovery ✓ | self-registering, public client ok | https://todoist.com | | +| trello | oauth | discovery ✓ | self-registering, public client ok | https://auth.atlassian.com/VCeDsk8ZHncYF1g234fKtc4lNipbBhu3 | | +| twelve-data | oauth | discovery ✓ | self-registering, secret | https://mcp.twelvedata.com/ | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| twilio | open | open ✓ (initialize 200) | n/a | | | +| typeform | oauth | discovery ✓ | self-registering, public client ok | https://api.typeform.com | advertises `offline_access`; refresh unverified | +| upstox | oauth | discovery ✓ | self-registering, public client ok | https://mcp.upstox.com | no scopes published (V1) | +| uptime-robot | oauth | discovery ✓ | self-registering, secret | https://mcp.uptimerobot.com | AS lists no `none`; fleet asks `none`, retries confidential (#1488) | +| val-town | oauth | discovery ✓ | self-registering, public client ok | https://www.val.town/oauth | | +| vercel | oauth | discovery ✓ | self-registering, secret | https://vercel.com | AS lists no `none`; fleet asks `none`, retries confidential (#1488); advertises `offline_access`; refresh unverified | +| vimeo | oauth | discovery ✓ | self-registering, public client ok | https://mcp.vimeo.com/ | | +| webflow | oauth | discovery ✓ | self-registering, public client ok | https://mcp.webflow.com | no scopes published (V1) | +| wix | oauth | discovery ✓ | self-registering, public client ok | https://mcp.wix.com | | +| wiz | oauth | discovery ✓ | self-registering, public client ok | https://mcp.app.wiz.io | | +| wordpress-com | oauth | discovery ✓ | self-registering, public client ok | https://public-api.wordpress.com | | +| wrike | oauth | discovery ✗ — 403 to every unauthenticated request from the audit network (V2) | — | | | +| x-docs | open | open ✓ (initialize 200) | n/a | | | +| xero | oauth | discovery ✓ | manual client, secret | https://identity.xero.com | | +| zerodha-kite | open | open ✓ (initialize 200) | n/a | | | +| zoom | oauth | discovery ✓ | manual client, secret | https://zoom.us | | +| zoominfo | oauth | discovery ✓ | self-registering, secret | https://okta-login.zoominfo.com/oauth2/default | AS lists no `none`; fleet asks `none`, retries confidential (#1488); docs_url 404 | diff --git a/docs/README.md b/docs/README.md index a1a8f3f1..a4178aa8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -120,6 +120,11 @@ there) and [`../.agents/skills/steward/SKILL.md`](../.agents/skills/steward/SKIL guided tenant/API-key/BYO-client add forms, the per-user api_key auth mode): [`docs/MCP-CATALOG.md`](MCP-CATALOG.md) + [`docs/CONNECTOR-ONBOARDING.md`](CONNECTOR-ONBOARDING.md) +- **Connecting hosted (official, per-user OAuth) MCP servers** — what each + vendor needs before Connect works, how to read a failure, and which vendors + were verified live: [`docs/HOSTED-CONNECTORS-RUNBOOK.md`](HOSTED-CONNECTORS-RUNBOOK.md) + + [`docs/MCP-CATALOG-STATUS.md`](MCP-CATALOG-STATUS.md) (the #1006 status + table and the per-entry catalog audit) - **Bundle-managed SES/S3 email-report infrastructure:** use the external canonical [new-client email-report runbook](https://github.com/ElcanoTek/ses-s3-setup/blob/main/docs/NEW-CLIENT-EMAIL-SETUP.md); keep client-specific inventory in the external client bundle. @@ -208,6 +213,7 @@ above fails otherwise. - [`generating-demo-gif.md`](generating-demo-gif.md) — Generating the demo GIFs (TUI + web) - [`GUARDRAILS.md`](GUARDRAILS.md) — Prompt-injection guardrails (#702) - [`HOOKS.md`](HOOKS.md) — Governed lifecycle hooks (#788) +- [`HOSTED-CONNECTORS-RUNBOOK.md`](HOSTED-CONNECTORS-RUNBOOK.md) — Hosted MCP connectors — operator runbook - [`implementation-plans-enhancements.md`](implementation-plans-enhancements.md) — Implementation plan: #984 — Fleet ↔ Buzz bridge - [`INPUT-QUEUE.md`](INPUT-QUEUE.md) — Input queue & mid-turn steering (#785) - [`keyboard-shortcuts.md`](keyboard-shortcuts.md) — Keyboard shortcuts @@ -219,6 +225,7 @@ above fails otherwise. - [`MAINTENANCE.md`](MAINTENANCE.md) — Reclamation, disk backpressure, and stuck-task backstops - [`MCP-BROKER-SCOPES.md`](MCP-BROKER-SCOPES.md) — MCP broker scoped sessions - [`MCP-BUNDLE-ENV.md`](MCP-BUNDLE-ENV.md) — MCP bundle env contract: `${FLEET_WORKSPACE}`, `MCP_VARIANT_CLIENT`, `identity_env`, interactive critical-tool staging +- [`MCP-CATALOG-STATUS.md`](MCP-CATALOG-STATUS.md) — Hosted MCP connector status — the #1006 OAuth pack and the catalog audit - [`MCP-CATALOG.md`](MCP-CATALOG.md) — The MCP connector directory — trust classes, built-in catalog, provenance - [`MCP-RELOAD.md`](MCP-RELOAD.md) — MCP server hot-reload (#218) - [`MCP-TESTING.md`](MCP-TESTING.md) — Testing MCP servers From 8ac8828319ddaa757168b5ff08a96f697444ad30 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 20:04:52 +0000 Subject: [PATCH 3/9] Show tags on the board and filter by them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tags were write-only. The create form accepted them, the API stored them, the server could already filter on them (`?tag=a&tag=b`, ANDed) — and no surface in the web app ever showed one again. The one thing a tag is for, finding the rest of its group, could not be done from the UI at all. Now: - A task's tags render as chips on its row and on its phone card, coloured by the same hashed palette as the chat label chips so a tag reads the same everywhere. Each chip is a control: clicking one adds that tag to the board's filter, clicking a selected one removes it. - The filter bar gains a **Tags** group — a select that ADDS a tag (it never holds a value, because the board is filtered by every chip beside it, not by the last one chosen) plus a removable chip per selected tag. - Options come from `GET /tasks/tags`, the existing catalogue endpoint, fetched once per activation rather than on the 30s refresh: it is a GROUP BY over every task's tag array and it changes only when someone retags something. The offered set is that catalogue unioned with the tags on the listed tasks, so a tag created after the catalogue loaded is still selectable as soon as a task carrying it appears. - Tags count as an active filter, so Clear filters appears and clears them. Two things had to change underneath. `passThroughQuery` read only the first value of each parameter, which is right for every single-valued filter and wrong for `tag`: dropping the second of `?tag=a&tag=b` widens the result instead of narrowing it, the one direction a filter must never fail in. It now forwards every value, unchanged for single-valued parameters. And `/api/orchestrator/tasks/tags` did not exist; the static segment wins over the sibling `[taskId]` route, so it does not shadow GET /tasks/{id}. The Operations Center guide said to treat tags as a label for your own grouping rather than a control on this screen. That was true and is not any more. Verified: `make ci-web` (153 files / 1608 tests, audit clean, build clean), `tsc --noEmit`, and the mocked Playwright suite (98 passed). The three tests that guard real defects — every tag reaching the query, a chip not opening the log viewer, tags counting as an active filter — were each confirmed to fail against the bug they describe. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018qQJHUvRV2FM7Rj2GfUNqg --- .../fleet-guide/operations-center.md | 13 +- .../app/api/orchestrator/_lib/proxy.test.ts | 60 +++++++ web/src/app/api/orchestrator/_lib/proxy.ts | 10 +- web/src/app/api/orchestrator/tasks/route.ts | 2 + .../app/api/orchestrator/tasks/tags/route.ts | 15 ++ web/src/app/globals.css | 64 ++++++++ web/src/app/help/guides/operations-center.md | 13 +- web/src/app/orchestrator/TasksTable.test.tsx | 116 +++++++++++++ web/src/app/orchestrator/TasksTable.tsx | 155 +++++++++++++++++- .../app/orchestrator/orchestrator-client.tsx | 1 + .../app/shared/hooks/useDashboardData.test.ts | 77 +++++++++ web/src/app/shared/hooks/useDashboardData.ts | 46 +++++- web/src/app/shared/lib/orchestratorApi.ts | 12 ++ 13 files changed, 578 insertions(+), 6 deletions(-) create mode 100644 web/src/app/api/orchestrator/_lib/proxy.test.ts create mode 100644 web/src/app/api/orchestrator/tasks/tags/route.ts diff --git a/internal/clientconfig/builtin_skills/fleet-guide/operations-center.md b/internal/clientconfig/builtin_skills/fleet-guide/operations-center.md index 27f7a369..2e965a27 100644 --- a/internal/clientconfig/builtin_skills/fleet-guide/operations-center.md +++ b/internal/clientconfig/builtin_skills/fleet-guide/operations-center.md @@ -93,6 +93,17 @@ the wake, not a second copy started alongside it. Stop is offered on both. The board filters by **Status** and **Created by**, narrows to **Scheduled only**, and searches across title, prompt, and ID. +It also filters by **tag**. A task's tags appear as small coloured chips on its +row; clicking one narrows the board to that tag, and the **Tags** dropdown in +the filter bar picks from every tag in use across the deployment — including +ones no task on the current page carries. Tags stack: each one you add narrows +the board further, to tasks carrying *all* of them. Remove a tag by clicking +its chip in the filter bar, or drop everything at once with **Clear filters**. + +Like the counters above, the dropdown lists tags from the whole deployment +while the board shows only your own tasks, so a tag a colleague uses can filter +down to nothing. + ### Whose tasks you see The board shows the tasks **you created**. Workspace admins see everyone's, which @@ -120,7 +131,7 @@ with it. | **Schedule** | Three modes: **Run now**, **Run once** at a date and time, or **Repeat**. Repeat offers a plain-language builder for daily, weekday, and weekly patterns, and an advanced field for anything else. A repeat can also end on its own, under **End repeat**: never, on a date, or after a set number of runs. The form always previews the computed next run; read it before launching. | | **Recipients** | The email addresses that receive each run's result. You set them here rather than in the library prompt, so the same prompt can serve different audiences, and the form keeps them across an edit — including when you re-insert a different prompt from the library. (They are delivered as an instruction the form writes into the task's prompt for you. That only matters if you drive the API directly: a client that replaces a task's prompt wholesale replaces that instruction too.) | | **Tools & files** | What the task may reach: mailboxes, connectors, files. Some connections are always on for every run; the rest are selected per task, so new tasks start with your deployment's recommended set and an existing task never silently gains new connections. Files can also be attached directly to the task, for work that runs against a fixed reference like a template or a lookup table. | -| **Context** | Notes that travel with the task for the people who operate it: why it exists, who owns it, what to do if it fails. These are shown to operators and never enter the assistant's instructions. Alongside them sit **tags** and the task's **persona**, which is left blank for the workspace default unless the task genuinely needs a different one. Tags are stored with the task and can be filtered on through the API; the board itself filters by status, creator and text, so treat tags as a label for your own grouping rather than a control on this screen. | +| **Context** | Notes that travel with the task for the people who operate it: why it exists, who owns it, what to do if it fails. These are shown to operators and never enter the assistant's instructions. Alongside them sit **tags** and the task's **persona**, which is left blank for the workspace default unless the task genuinely needs a different one. Tags are how you group related tasks: they show as chips on the board and it filters by them (see [Finding things](#finding-things)), so a tag you give a task here is a way back to the whole group later. | | **Advanced** | Further settings, including the model the task runs on and an option for a recurring task to carry a short summary of its previous run into the next one. The model in particular is worth choosing deliberately: match it to the demands of the job rather than leaving it to chance. | **Estimate Cost**, beneath the form, produces a **cost forecast** on demand: the diff --git a/web/src/app/api/orchestrator/_lib/proxy.test.ts b/web/src/app/api/orchestrator/_lib/proxy.test.ts new file mode 100644 index 00000000..1931d87b --- /dev/null +++ b/web/src/app/api/orchestrator/_lib/proxy.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { NextRequest } from "next/server"; + +import { passThroughQuery } from "./proxy"; + +const ORIGIN = "https://chat.example.com"; + +function request(query: string): NextRequest { + return new NextRequest(`${ORIGIN}/api/orchestrator/tasks?${query}`); +} + +// passThroughQuery is the allow-list between the browser and the orchestrator. +// It read only the FIRST value of each param, which is correct for every +// single-valued filter and wrong for `tag`: ?tag=a&tag=b means "carrying BOTH" +// (the server ANDs them), so dropping b WIDENED the result instead of +// narrowing it — the one direction a filter must never fail in. +describe("passThroughQuery", () => { + it("forwards every value of a repeated param", () => { + const qs = passThroughQuery(request("tag=ops&tag=urgent"), ["tag"]); + expect(new URLSearchParams(qs.slice(1)).getAll("tag")).toEqual(["ops", "urgent"]); + }); + + it("passes a single-valued param through unchanged", () => { + expect(passThroughQuery(request("status=running"), ["status"])).toBe("?status=running"); + }); + + it("drops params outside the allow-list", () => { + expect(passThroughQuery(request("status=running&secret=x"), ["status"])).toBe( + "?status=running", + ); + expect(passThroughQuery(request("tag=ops"), ["status"])).toBe(""); + }); + + it("drops empty values rather than forwarding a blank filter", () => { + expect(passThroughQuery(request("status=&q=hello"), ["status", "q"])).toBe("?q=hello"); + // An empty value among repeated ones drops only itself. + const qs = passThroughQuery(request("tag=ops&tag=&tag=urgent"), ["tag"]); + expect(new URLSearchParams(qs.slice(1)).getAll("tag")).toEqual(["ops", "urgent"]); + }); + + it("returns an empty string, not a bare '?', when nothing passes", () => { + expect(passThroughQuery(request("nope=1"), ["status"])).toBe(""); + }); + + it("emits params in allow-list order, not the caller's", () => { + expect(passThroughQuery(request("q=hi&status=running"), ["status", "q"])).toBe( + "?status=running&q=hi", + ); + }); + + it("keeps a comma-separated value intact for completed_status", () => { + // The Failed Today card sends two statuses in one value; splitting or + // truncating it here would silently halve the filter. + const qs = passThroughQuery( + request("completed_status=error%2Cdead_lettered"), + ["completed_status"], + ); + expect(new URLSearchParams(qs.slice(1)).get("completed_status")).toBe("error,dead_lettered"); + }); +}); diff --git a/web/src/app/api/orchestrator/_lib/proxy.ts b/web/src/app/api/orchestrator/_lib/proxy.ts index e820818c..0c9de867 100644 --- a/web/src/app/api/orchestrator/_lib/proxy.ts +++ b/web/src/app/api/orchestrator/_lib/proxy.ts @@ -84,12 +84,18 @@ export async function proxyToOrchestrator( /** * Builds the upstream query string from the incoming request's search params, * passing through only the named allow-list of params. + * + * Every value of a repeated param is forwarded, not just the first: the task + * list's `tag` filter is repeatable (`?tag=a&tag=b` means "carrying BOTH"), and + * keeping only the first silently widened that to "carrying a". Single-valued + * params are unaffected — one value in, one value out, in allow-list order. */ export function passThroughQuery(request: NextRequest, allowed: string[]): string { const out = new URLSearchParams(); for (const key of allowed) { - const v = request.nextUrl.searchParams.get(key); - if (v !== null && v !== "") out.set(key, v); + for (const v of request.nextUrl.searchParams.getAll(key)) { + if (v !== "") out.append(key, v); + } } const qs = out.toString(); return qs ? `?${qs}` : ""; diff --git a/web/src/app/api/orchestrator/tasks/route.ts b/web/src/app/api/orchestrator/tasks/route.ts index 43108ea5..aa314a51 100644 --- a/web/src/app/api/orchestrator/tasks/route.ts +++ b/web/src/app/api/orchestrator/tasks/route.ts @@ -14,6 +14,8 @@ export async function GET(request: NextRequest) { "completed_today", "completed_status", "created_by", + // Repeatable: ?tag=a&tag=b narrows to tasks carrying BOTH (#212). + "tag", ]); return proxyToOrchestrator(request, `/tasks${qs}`); } diff --git a/web/src/app/api/orchestrator/tasks/tags/route.ts b/web/src/app/api/orchestrator/tasks/tags/route.ts new file mode 100644 index 00000000..bf3e4ab1 --- /dev/null +++ b/web/src/app/api/orchestrator/tasks/tags/route.ts @@ -0,0 +1,15 @@ +import { NextRequest } from "next/server"; +import { proxyToOrchestrator } from "../../_lib/proxy"; + +export const runtime = "nodejs"; + +// GET /api/orchestrator/tasks/tags → orchestrator GET /tasks/tags (#212): the +// distinct tags in use, busiest first. Feeds the board's tag filter, which +// needs the tags that exist rather than only those on the page in front of you. +// +// The static `tags` segment wins over the sibling `[taskId]` route, so this +// does not shadow GET /tasks/{id} — the same ordering the Go router spells out +// explicitly in cmd/fleet/main.go. +export async function GET(request: NextRequest) { + return proxyToOrchestrator(request, "/tasks/tags"); +} diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 6bc2b483..4fb3ebee 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -3057,6 +3057,70 @@ tr.sla-row-fail { flex: 1 1 100%; } } +/* ── Task tags (#212) ───────────────────────────────────────────────────── + Chips share the conversation-label recipe and its hashed --chip colour, so + the same tag reads the same everywhere. Every chip is a button: on a row it + filters the board to its tag, in the filter bar it removes that tag. The + active state is the filled one — a tag currently narrowing the board should + not look identical to one merely present on a task. */ +.task-tag-row { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.3rem; +} +.task-tag-chip { + display: inline-flex; + align-items: center; + gap: 0.2rem; + max-width: 12rem; + overflow: hidden; + font-size: 0.65rem; + font-weight: 500; + line-height: 1; + white-space: nowrap; + text-overflow: ellipsis; + padding: 0.18rem 0.45rem; + border: 1px solid color-mix(in srgb, var(--chip) 38%, transparent); + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--chip) 12%, transparent); + color: color-mix(in srgb, var(--chip) 72%, white); + cursor: pointer; +} +.task-tag-chip:hover { + background: color-mix(in srgb, var(--chip) 24%, transparent); +} +.task-tag-chip-active { + background: color-mix(in srgb, var(--chip) 30%, transparent); + border-color: color-mix(in srgb, var(--chip) 62%, transparent); +} +.task-tag-chip:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} +:root[data-theme="light"] .task-tag-chip { + border-color: color-mix(in srgb, var(--chip) 48%, white); + background: color-mix(in srgb, var(--chip) 14%, white); + color: color-mix(in srgb, var(--chip) 42%, #15110e); +} +:root[data-theme="light"] .task-tag-chip-active { + background: color-mix(in srgb, var(--chip) 30%, white); +} +/* The select and the chips it fills sit on one line until the chips need + more room, at which point they wrap under it rather than squeezing the + select below its min-width. */ +.tag-filter-control { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; +} +.tag-filter-chips { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + max-width: 18rem; +} .tasks-pagination { display: flex; align-items: center; diff --git a/web/src/app/help/guides/operations-center.md b/web/src/app/help/guides/operations-center.md index 27f7a369..2e965a27 100644 --- a/web/src/app/help/guides/operations-center.md +++ b/web/src/app/help/guides/operations-center.md @@ -93,6 +93,17 @@ the wake, not a second copy started alongside it. Stop is offered on both. The board filters by **Status** and **Created by**, narrows to **Scheduled only**, and searches across title, prompt, and ID. +It also filters by **tag**. A task's tags appear as small coloured chips on its +row; clicking one narrows the board to that tag, and the **Tags** dropdown in +the filter bar picks from every tag in use across the deployment — including +ones no task on the current page carries. Tags stack: each one you add narrows +the board further, to tasks carrying *all* of them. Remove a tag by clicking +its chip in the filter bar, or drop everything at once with **Clear filters**. + +Like the counters above, the dropdown lists tags from the whole deployment +while the board shows only your own tasks, so a tag a colleague uses can filter +down to nothing. + ### Whose tasks you see The board shows the tasks **you created**. Workspace admins see everyone's, which @@ -120,7 +131,7 @@ with it. | **Schedule** | Three modes: **Run now**, **Run once** at a date and time, or **Repeat**. Repeat offers a plain-language builder for daily, weekday, and weekly patterns, and an advanced field for anything else. A repeat can also end on its own, under **End repeat**: never, on a date, or after a set number of runs. The form always previews the computed next run; read it before launching. | | **Recipients** | The email addresses that receive each run's result. You set them here rather than in the library prompt, so the same prompt can serve different audiences, and the form keeps them across an edit — including when you re-insert a different prompt from the library. (They are delivered as an instruction the form writes into the task's prompt for you. That only matters if you drive the API directly: a client that replaces a task's prompt wholesale replaces that instruction too.) | | **Tools & files** | What the task may reach: mailboxes, connectors, files. Some connections are always on for every run; the rest are selected per task, so new tasks start with your deployment's recommended set and an existing task never silently gains new connections. Files can also be attached directly to the task, for work that runs against a fixed reference like a template or a lookup table. | -| **Context** | Notes that travel with the task for the people who operate it: why it exists, who owns it, what to do if it fails. These are shown to operators and never enter the assistant's instructions. Alongside them sit **tags** and the task's **persona**, which is left blank for the workspace default unless the task genuinely needs a different one. Tags are stored with the task and can be filtered on through the API; the board itself filters by status, creator and text, so treat tags as a label for your own grouping rather than a control on this screen. | +| **Context** | Notes that travel with the task for the people who operate it: why it exists, who owns it, what to do if it fails. These are shown to operators and never enter the assistant's instructions. Alongside them sit **tags** and the task's **persona**, which is left blank for the workspace default unless the task genuinely needs a different one. Tags are how you group related tasks: they show as chips on the board and it filters by them (see [Finding things](#finding-things)), so a tag you give a task here is a way back to the whole group later. | | **Advanced** | Further settings, including the model the task runs on and an option for a recurring task to carry a short summary of its previous run into the next one. The model in particular is worth choosing deliberately: match it to the demands of the job rather than leaving it to chance. | **Estimate Cost**, beneath the form, produces a **cost forecast** on demand: the diff --git a/web/src/app/orchestrator/TasksTable.test.tsx b/web/src/app/orchestrator/TasksTable.test.tsx index 2dd1d560..32ca823e 100644 --- a/web/src/app/orchestrator/TasksTable.test.tsx +++ b/web/src/app/orchestrator/TasksTable.test.tsx @@ -14,6 +14,7 @@ const FILTERS: TaskFilters = { completedToday: false, completedStatus: "", createdBy: "", + tags: [], }; function renderTable(onFilters: TasksTableProps["onFilters"]) { @@ -489,3 +490,118 @@ describe("TasksTable Clear filters", () => { expect(screen.queryByTestId("tasks-clear-filters")).toBeNull(); }); }); + +// ── Tag filter and tag chips (#212) ────────────────────────────────────── +// Tags were write-only: the create form accepted them, the API stored them, +// and no surface on the board ever showed one again — so the thing a tag is +// for, finding the rest of its group, could not be done from the UI at all. +describe("TasksTable tags", () => { + const tagged: Task = { + id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + prompt: "nightly reconciliation", + status: "success", + tags: ["ops", "billing"], + }; + + function renderTags( + overrides: Partial & { filters?: TaskFilters } = {}, + ) { + const onFilters = vi.fn(); + render( + {}} + onPageSize={() => {}} + onOpenLogs={() => {}} + {...overrides} + />, + ); + return onFilters; + } + + it("shows a task's tags on the board", () => { + renderTags(); + // Table row and phone card both render them (CSS picks one). + expect(screen.getAllByLabelText("Filter by tag ops").length).toBe(2); + expect(screen.getAllByLabelText("Filter by tag billing").length).toBe(2); + }); + + it("filters by a tag when its chip is clicked", () => { + const onFilters = renderTags(); + fireEvent.click(screen.getAllByLabelText("Filter by tag ops")[0]); + expect(onFilters).toHaveBeenCalledWith({ tags: ["ops"] }); + }); + + it("does not open the log viewer when a chip is clicked", () => { + // The row is itself a button; without stopPropagation, filtering by a tag + // would also throw the log modal open over the board you just narrowed. + const onOpenLogs = vi.fn(); + renderTags({ onOpenLogs }); + fireEvent.click(screen.getAllByLabelText("Filter by tag ops")[0]); + expect(onOpenLogs).not.toHaveBeenCalled(); + }); + + it("adds to the selection rather than replacing it — tags AND together", () => { + const onFilters = renderTags({ filters: { ...FILTERS, tags: ["billing"] } }); + fireEvent.click(screen.getAllByLabelText("Filter by tag ops")[0]); + expect(onFilters).toHaveBeenCalledWith({ tags: ["billing", "ops"] }); + }); + + it("removes a tag when its selected chip is clicked again", () => { + const onFilters = renderTags({ filters: { ...FILTERS, tags: ["ops", "billing"] } }); + fireEvent.click(screen.getAllByLabelText("Stop filtering by tag ops")[0]); + expect(onFilters).toHaveBeenCalledWith({ tags: ["billing"] }); + }); + + it("adds a tag chosen from the select", () => { + const onFilters = renderTags(); + fireEvent.change(screen.getByLabelText("Filter by tag"), { target: { value: "urgent" } }); + expect(onFilters).toHaveBeenCalledWith({ tags: ["urgent"] }); + }); + + it("keeps the select on its placeholder — it adds, it does not hold a value", () => { + // Holding the last-added tag would claim the board is filtered by that one + // tag when it is filtered by every chip beside it. + renderTags({ filters: { ...FILTERS, tags: ["ops"] } }); + const select = screen.getByLabelText("Filter by tag") as HTMLSelectElement; + expect(select.value).toBe(""); + // An already-selected tag is not offered twice. + const offered = [...select.options].map((o) => o.value); + expect(offered).not.toContain("ops"); + expect(offered).toContain("urgent"); + }); + + it("hides the control entirely when nothing is tagged", () => { + renderTags({ tasks: [], tagOptions: [] }); + expect(screen.queryByLabelText("Filter by tag")).toBeNull(); + }); + + it("still shows the control when a tag is selected but the catalogue is empty", () => { + // A failed catalogue fetch must not strand an applied filter with no way + // to remove it. + renderTags({ tasks: [], tagOptions: [], filters: { ...FILTERS, tags: ["ops"] } }); + expect(screen.getByLabelText("Filter by tag")).toBeTruthy(); + expect(screen.getByLabelText("Stop filtering by tag ops")).toBeTruthy(); + }); + + it("counts a tag selection as an active filter, so Clear filters appears", () => { + // Without this, the only way back to the full board was a page reload. + renderTags({ + tasks: [], + filters: { ...FILTERS, tags: ["ops"] }, + onClearFilters: () => {}, + }); + expect(screen.getByTestId("tasks-clear-filters")).toBeTruthy(); + }); + + it("renders no chip row for an untagged task", () => { + renderTags({ tasks: [{ ...tagged, tags: undefined }] }); + expect(screen.queryByLabelText(/^Filter by tag /)).toBeNull(); + }); +}); diff --git a/web/src/app/orchestrator/TasksTable.tsx b/web/src/app/orchestrator/TasksTable.tsx index 8e0a0137..408172f5 100644 --- a/web/src/app/orchestrator/TasksTable.tsx +++ b/web/src/app/orchestrator/TasksTable.tsx @@ -5,6 +5,7 @@ import type { Task } from "@/app/shared/lib/orchestratorApi"; import type { TaskFilters } from "@/app/shared/hooks/useDashboardData"; import { formatTimeFirst, truncate } from "@/app/shared/lib/format"; import { Icon } from "@/app/shared/ui/Icon"; +import { labelChipStyle } from "@/app/shared/lib/labelColors"; import { createdByLabel, scheduleLabel, slaBadge, TaskSlaBadge } from "./taskDisplay"; // Statuses whose tasks can be edited: pending/scheduled edit in place; @@ -59,6 +60,10 @@ export type TasksTableProps = { page: number; pageSize: number; filters: TaskFilters; + // Tags the filter offers (useDashboardData.tagOptions). Empty is a valid + // state — a deployment where nobody tags anything — and hides the control + // rather than showing an empty dropdown. + tagOptions?: string[]; onFilters: (next: Partial) => void; // Reset every filter at once (useDashboardData.clearFilters). The button // only renders while some filter is set, and only when the parent wires it. @@ -104,6 +109,7 @@ export function TasksTable({ page, pageSize, filters, + tagOptions = [], onFilters, onClearFilters, onPage, @@ -150,7 +156,20 @@ export function TasksTable({ filters.query !== "" || filters.scheduledOnly || filters.completedToday || - filters.createdBy !== ""; + filters.createdBy !== "" || + filters.tags.length > 0; + + // Tags AND together server-side, so adding one always narrows and removing + // one always widens. Toggling is the whole interaction: the select adds, + // the chips (in the bar and on the rows) remove or add. + const toggleTag = (tag: string) => { + onFilters({ + tags: filters.tags.includes(tag) + ? filters.tags.filter((t) => t !== tag) + : [...filters.tags, tag], + }); + }; + const unselectedTags = tagOptions.filter((t) => !filters.tags.includes(t)); const totalPages = Math.max(1, Math.ceil(total / pageSize)); const start = total > 0 ? Math.min((page - 1) * pageSize + 1, total) : 0; @@ -200,6 +219,54 @@ export function TasksTable({ + {tagOptions.length > 0 || filters.tags.length > 0 ? ( +
+ +
+
+ +
+ {filters.tags.length > 0 ? ( + + {filters.tags.map((tag) => ( + + ))} + + ) : null} +
+
+ ) : null}