From dc50403a81d19fc2cad8839bee6a8695b68711c0 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:16:00 +0530 Subject: [PATCH] feat(desktop): the Connectors page replaces the MCP tab (#119074) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(connectors): the backend serves a connector's tool list, cached for 24 hours The Connectors page opens one app and shows every tool it has. The backend had no way to read that list. - `tools/connectors/portal/`: a client for the portal's tool-list route and a JSON cache under the Hermes home, one file per portal origin and connector. An entry is fresh for 24 hours. After that the read revalidates with the stored ETag: 304 keeps the list, 404 deletes the entry, an upstream failure serves the stored list marked stale, and a 401 never serves the cache. - `connectors.tools {slug, refresh}`: account-level, routed by `profile`, no chat session. Errors carry a fixed `reason` from one closed set on the rail. - Every connector model that is not operation state moves into `tui_gateway/contracts/connectors.py`. Handlers that no chat session owns live in `tui_gateway/methods_connectors_account.py`. The wire model is tolerant: an unknown facet reads as unclassified and one odd tool never blanks a connector. * feat(connectors): catalog, accounts and member tool rules by RPC The Connectors page needs the app catalog, the connected account of one app, a way to disconnect it, and the member's own on/off rules. None had an RPC. - `connectors.catalog`: name, description, category and logo of each app. - `connectors.accounts`, `connectors.accounts.remove`: read the accounts at the tool gateway and remove one by id. - `connectors.policy.get`: the rule layers that apply to the member, widest first. The body is a union on `mode`, so a reader can name who turned a tool off. - `connectors.policy.set`: one change, a union on `type` (the tools of one connector, or one connector on or off), with the revision the user saw. A stale revision answers `POLICY_CONFLICT`. The backend composes the upstream write in one pure function, so no renderer learns the upstream rules. - Bundled MCP manifests can name their hosted twin with `connector:`, so the page can show one card per app. * feat(connectors): connect an app without a chat session Every connector RPC took a `session_id`, and a connect that did not come from the model's tool call minted a link with no watcher. The Connectors page has no chat session, and its card must flip to connected by itself. - `connectors.list`, `connectors.connect`, `connectors.operation.status`, `connectors.operation.wake` and `connection.respond` take `owner`, a union on `type`: `session` (today's behaviour and authorization) or `account` (routed by `profile`, authorized by the live transport like `mcp.*`). `session_id` is gone from these params; every desktop caller sends `owner`. - An account connect runs the same operation lifecycle on a background thread, under the profile's scope, so the watcher reads the account and settles the operation. A second connect for an app that is already connecting returns the open operation and mints nothing. - `connection.update` carries `owner`. An account operation has no session to address, so its updates go out on the session-less broadcast path. * feat(mcp-catalog): eighteen more bundled entries name their hosted connector A bundled MCP entry and a hosted connector for the same app are one card on the Connectors page only when the manifest names its hosted twin. Linear and Notion had the field. These entries get it too: airtable, asana, attio, calendly, dropbox, figma, railway, supabase, todoist, betterstack, canva, cloudflare, datadog, intercom, neon, sentry, stripe and vercel. Atlassian maps to two hosted connectors and Prisma Postgres is not clearly the same app, so both stay without one. * refactor(connectors): the account handlers share one gate, one params model and one write table The six account-level handlers each repeated the availability gate, the auth catch and the catch-all reply. One decorator now owns that, and each handler validates its params with its contract model instead of a ladder of isinstance checks. The five connection RPCs share one guard for the unexpected-failure reply. The four write composers for the member rules were the same function with a different list key and polarity. They are one table now. The owner union lives in contracts/common.py, so the params side and the event side stop declaring it twice and the import cycle is gone. An account operation start carries one event and a flag, so the wait for the sign-in link blocks instead of polling every 50 ms. run_operation loses its two account-only parameters; drive_operation is the second entry point. Tests: four deleted (they exercised pydantic or the mock), three merged into tables, two added (a client that still sends the old top-level session_id is refused; all six account RPCs run off the server loop). The shared reply helper and the HTTP and managed-client fakes move to one place each. Comments are one line or gone. * fix(connectors): a missing tool-list route reads as "unavailable", not "connector gone" The tool-list read treated every 404 as the portal's "this connector is not in the catalog" answer. It deleted the cache entry and answered CONNECTOR_NOT_FOUND, so a page would offer to remove an app that is connected and works. A portal that does not serve the route yet answers a bare 404 for every app. Only the portal's own {"error": "connector_not_found"} means the connector is gone. Any other 404 is now a tool-list outage: the cached list is served as stale, or the RPC answers TOOLS_UNAVAILABLE. * fix(connectors): a connect from the page returns to the app after sign-in The sign-in link carries a return target only when the session's surface is the desktop. A chat session binds that surface. An account-owned call has no chat session, so nothing bound it: the link was minted without a return target and the browser ended on the portal's done page instead of coming back to Hermes. Every account-owned call now runs with the process's own surface bound, next to its profile scope. The operation thread copies that context, so the first link and every reissued link carry the return target and the operation id. * test(connectors): defer the new connector RPC coverage The tests for the new account RPCs, the portal client, the tool-list cache and the rule composer leave this PR and come back in one later change, after the API is settled. The same was done for #111008. Kept: the edits that existing tests need because the five connection RPCs now take `owner` instead of `session_id`, and the rename of the managed client seam. Removed: six new test files, their two fakes and the gateway conftest, and the new cases in test_mcp_catalog.py, test_connectors_gateway_client.py, gateway-rpc.test.ts and notifications.test.ts. Reverting this commit restores all of them. * fix(cli): the connection panel hands the tool thread back at once The classic CLI's connection callback waited on a queue for the user's first decision. The operation's watcher starts only after the callback returns, and the watcher is what polls a hosted account, runs the 300-second deadline and sees Ctrl+C. For a hosted connector the panel opens on the sign-in link, where the only key that filled the queue was Cancel. The account was never polled: the user signed in, the panel never changed, and Esc reported the app as skipped. Ctrl+C set the interrupt flag but left the thread parked on the queue, so the turn never ended. The callback now opens the panel and returns, as the gateway's callback does for the desktop and the Ink TUI. The panel's actions already reach the operation through apply_answer on the UI thread, so the queue is removed. An install with a form still waits for Connect, because the backend starts no work for a pending row. Ctrl+C now settles the operation as `interrupt`, and open rows become `not_connected`. Checked on the e2e rig with the fake tool gateway: hosted connect completes on the third status read; Ctrl+C ends the turn and the polling stops; an MCP install with a plain and a secret field still saves config and both values. * fix(connectors): "run it again" lives in the library, so the classic CLI can use it Making a new sign-in link for a failed or expired hosted connector was implemented only in the JSON-RPC layer (`_reissue`). The classic CLI does not go through JSON-RPC: its Connect button on a failed row called apply_answer, which does nothing for a hosted operation because it has no MCP runner. The panel showed "Waiting…" until the deadline. `tools.connectors.run.reissue(operation, names)` now holds the checks and the per-kind action, and returns a refusal reason or None. The gateway maps each reason to the same JSON-RPC error as before. The CLI calls it for a hosted row; a refusal is shown on the row. MCP rows keep their path, because Connect on a failed MCP row re-sends the form values. Checked on the e2e rig: a scripted failed sign-in, then Connect: a second mint with `reinitiate: true`, a new link with a new connection id, then connected. * feat(connectors): the account list and disconnect go through the portal `connectors.accounts` and `connectors.accounts.remove` called the tool gateway. They now call the portal's account-management routes (`GET /api/v1/connectors/accounts`, `DELETE /api/v1/connectors/accounts/{id}`), which apply the organisation membership checks and write the disconnect audit row. There is no fallback to the gateway when the portal is unavailable, and a removal is never retried. The read of ONE account stays on the gateway (`GET v1/connectors/accounts/{id}`): the portal has no such route, and the operation watcher polls it once per second. `ConnectorClient.list_accounts` and `delete_account` are removed. The removed account's reply model carries `connector`, which both services send. * fix(connectors): the account RPCs answer what the portal really sends Checked against the portal source and against the staging and production services. - Errors are read from the upstream error code, not the HTTP status. A rule write answered 409 for a stale revision and for a user with no organisation; both read as "the policy changed". `org_required` is now `ORG_REQUIRED` and 403 `no_access` is `ORG_ACCESS_DENIED` on every account RPC; only a rejected sign-in is `NEEDS_NOUS_AUTH`. `connectors.list` and `connectors.connect` with the account owner map these too. - `connectors.policy.get` and `connectors.policy.set` carry `effective`: the portal's own result for this user, with its stamp and without provider or subject ids. Nothing is recomputed locally. - A rule write needs the revision the user saw: `expected_revision` is required and must be a revision string; a bad one is refused before any HTTP call. - A tool row carries `no_auth`; a list without the upstream flag is an invalid answer, not `false`. - `connectors.accounts.remove` returns the app of the removed account. An invalid id is `INVALID_PARAMS`. - The tool-list cache is per signed-in member (a hash of the token's `sub`), so two Nous accounts on one profile do not share entries. - A malformed slug is a local error, not a 404 from a server nobody called. Live, staging: no revision and a malformed revision refused locally; a good revision wrote one disabled Gmail tool and returned it in `effective`; the same revision again answered `POLICY_CONFLICT`; the list row showed the tool; the restore brought the member rules back to the start. Live, staging and production, read-only: all 60 tool lists (5483 tools) parse. * fix(connectors): the operation RPCs match their contract; a settled card cannot start a new link Found by two adversarial reviews of the RPC layer and its types. - `connectors.connect` from a chat session with no open operation is refused (`UNKNOWN_OPERATION`). It used to call `manage_connections` through the tool registry with no card: it made a link nobody watched, returned a reply without the required `settled` field, and named an operation that was never registered. There is one way into an operation: the agent's call, or the account owner's `connectors.connect`. "Run it again" inside an open operation is unchanged. - `connection.update` for a session is routed by session key AND profile; two profiles with the same key no longer cross-deliver a sign-in link. The event payload gets the same redaction as the RPC replies. - `connection.respond` runs on the long-handler pool: an approval can start MCP OAuth discovery, which blocked every RPC of the gateway while it ran. - `connectors.list` rows are a closed snake_case model: `connector`, `enabled`, `connected`, `connection_status`, `status_reason`, `gateway_disabled_tools`. The last one is display data: the gateway enforces the rules, the backend only passes the list on. The phantom `name` and `description` are gone, and the desktop uses the generated types instead of hand-written copies. - `tools_listing` (model-only data) no longer rides on `connectors.operation.status`. - `unavailable` is removed from the target states and settle reasons: nothing produces it. The contract generator now fails when a contract enum and its domain enum differ. - `ConnectorErrorReason` is part of the generated TypeScript and OpenRPC. - The desktop sends `connection.respond` on the socket that holds the session, as wake and reissue already did. - Contract violations are logged every time, at error level. - An account connect whose prepare step is slow returns the live operation instead of an error while the operation keeps running. - The MCP-manifest `connector` field leaves this PR (it moves to a later one on top of the catalog-reader change). `hermes_cli/mcp_catalog.py` and `optional-mcps/` are untouched by this PR again. anti-slop: no net-new findings (15 touched files). * fix(connectors): the model gets no sign-in link wherever a card exists; side agents cannot connect The flag that tells the model "a connection card exists" was the session platform (`== "desktop"`). The Ink TUI and the classic CLI also draw a card, so there a connector call on an unconnected app handed the model the raw `connect_url` and told it to pass the link to the user. - The agent turn now declares how a link can reach the user (`tools/connectors/turn.py`): CARD when the agent was built with a connection callback, SIDE for a subagent or a background turn, LINK for a headless run (`-q`, cron, ACP, api_server, messaging). It is set once per tool batch in the agent loop and read by the connector dispatch path, which never sees the agent. The session platform decides return-to-app only. - CARD: the result carries `connect_card_available` and our hint, never the link and never the gateway's own hint. - SIDE: subagents (`delegate_tool`), gateway background turns and the classic CLI `/bg` are built with `side_agent=True`. They hold no `manage_connections` tool on any path that derives the tool list, and a connector call on an unconnected app gets no link, only "report this to the main agent". - LINK is unchanged. - The hosted path with no card builds a detached operation, as the MCP path does, so no `connection.update` is emitted for an operation no client asked for. Names and docstrings that said "off desktop" now say "no card". - A settled card is dead on the desktop: `reissueConnectionTarget` and `respondToConnectionRequest` share one guard and send nothing for a settled or unknown operation. - The model-facing settled result no longer carries `connection_id`; the model repeated it to the user. Shown on the real clients with a real model (rig, fake tool gateway): Ink TUI and classic CLI get `connect_card_available` and no link, the model opens the card, the account connects, the retried call succeeds; `-q` still gets the link; a subagent and a background turn have no `manage_connections` and get the no-link hint; on the desktop a card settled with Continue has no enabled control and sends no RPC. * feat(tools): every call made through tool_search + tool_call shows a real label on all three clients A bridged call showed as a generic `tool_call` row in the Ink TUI and as `⚡ tool_call` in the classic CLI, because the display looked the name up in the tool registry and bridged names are made at run time. The desktop labelled only batches that were all hosted connector calls, by parsing names itself. - `tools/tool_labels.py` is the one place that turns a bridged call into a label: kind, app, action, emoji and text. Hosted: `connectors__gmail__GMAIL_SEND_EMAIL` → "Gmail · send email". MCP: "Linear · list issues". A local deferred tool keeps its own emoji, verb and primary-argument preview. A batch gets exactly one label per entry, always; an entry with no name gets a generic label. - Classic CLI: one row per inner call; the duration on the last row; the failure text on the row of the call that failed. With friendly labels off it prints what it printed before. - Gateway: tool start, progress and complete events and stored transcript rows carry a typed `labels` field. It does not depend on the classic CLI's display setting. Clients no longer parse tool names. - Ink TUI: rows from the labels; the verbose trail keeps Args and Result. - Desktop: `ConnectorExecution` renders hosted, MCP and mixed turns from the labels, one row per call. The labels reach the row under a key no tool argument can use. The connect card it drew under a failed tool result is gone: after `CONNECTION_REQUIRED` the one way in is the agent's own `manage_connections` call. - `tool_search` and `tool_describe` rows read "Searching tools · " and "Reading tool details · N tools". Shown on the real desktop (video and screenshots), the Ink TUI and the classic CLI with the rig: hosted rows, MCP rows, a two-entry batch, a failed entry, a `CONNECTION_REQUIRED` row with no card under it, labels after a reload, and the desktop rows with the classic CLI setting off. * fix(connectors): the model can tell "hosted tools unavailable" from "no such tool"; manage_connections routes MCP names correctly - A failed hosted search or describe used to return nothing, by design, so the model saw only local tools and told the user that a connected app was missing. The local results are unchanged; when the hosted leg failed, the `tool_search` and `tool_describe` results carry `connectors: {status: "unavailable", reason: "unreachable" | "sign_in_expired"}` and one hint line. A rejected token is `sign_in_expired`; an entitlement refusal or a shut gate adds nothing. `tool_describe` no longer lists those names under `not_found` next to "search again". - NS-932. The description now says which side a name belongs to: a bare name is a hosted connector account; `mcp: true` only when the user asks for an MCP server, a local server or an install, or when the name exists only in the catalog; connect and reconnect are hosted verbs, install, enable and authorize are MCP verbs. It names the three clients that draw a card. - A misrouted target is refused with the call that works. Only when the gateway does not know the connector (confirmed on that failure path) and the name is a catalog entry does the target fail with "X is a local MCP server. Call manage_connections with action install ...". It is a per-target outcome: other targets of the same call keep their links and their card. A vendor failure on a name both sides know stays an ordinary failed row. The MCP side mirrors it, and never for an entry that is only not installed. - "Do not re-ask after a skip or a timeout" no longer stops the model when the USER asks for that app again; the description and the settled-result notes say so. A builder saw the model refuse a direct user request. Shown on the Ink TUI and the classic CLI with a real model: a dead gateway and a 401; "connect fxmail" goes hosted; "install the fx-noauth MCP server" goes MCP; "connect fx-noauth" reaches the MCP install card in one corrective round with no hosted mint; a two-target call where one is misrouted still connects the other with exactly one mint. * fix(tui): the connection card answers every key, shows what is happening, and is dead once settled Reproduced on the real Ink TUI with the rig, then fixed: - The keyboard was dead during the sign-in wait: the card kept a `submitting` flag that the normal OAuth path never cleared, and Esc went through the same guard. The in-flight state now belongs to the answered row and clears when that row moves, when any later frame of the operation arrives, or after five seconds. Esc skips the row in every phase; Ctrl+C interrupts the turn (the input handler had no branch for this overlay); Shift+arrows scroll the transcript and the card ignores them; arrow keys no longer move the text cursor and the field focus at once. - The card was lost at turn idle: the overlay flag was cleared while the operation stayed in the store, and a resume dropped the pending card. The flag survives idle, a resume shows the pending card again, a session switch clears it. - States with no branch: `not_connected` and a row with no link fell into the credential form; `expired` vanished with no note. The title and the row text now name the action (connect, reconnect, install, enable, authorize); a failed or expired row with no fields offers Try again / Skip; a failed row WITH fields reopens the form over the typed draft, with the failure above it. - A settled card is dead: at settle the overlay closes and one transcript line per app states the outcome. A settled or dismissed operation id is remembered, so no replay or resume can reopen its card. Esc in the last "Finishing…" moment hides the card and still writes the outcome lines. - A failed `connection.respond` and a browser that did not open are shown on the card in one sentence. Also: `tui_gateway/connector_payload.py` redacted the BOOLEAN `secret` flag of a credential field to the string "[REDACTED]". On the desktop every credential field therefore rendered as a password and lost its prefilled default. A boolean is no longer redacted. * chore(connectors): remove the comments and docstrings this branch added Deletions only. Kept: tool directives (`# noqa`, `// eslint-disable`, ...), `// SAFETY:` lines, and the docstrings of the contract models under `tui_gateway/contracts/`, which become the descriptions in the generated OpenRPC and TypeScript. Checked that no code changed: every Python file has the same AST as before once docstrings and `pass` are ignored (62 files), and every TypeScript file prints the same with comments stripped by the TypeScript printer (32 files). The generated contract files are unchanged. * fix(connectors): a card restored after a reload answers again; every account RPC names auth and org failures Found by the end-to-end runs on the pushed head. - Desktop: after a window reload, Continue on the restored card sent nothing. The answer looked up the backend that holds the session with the runtime session id, the lookup wants the stored id, and a failed lookup returned silently. When the lookup gives no owner the answer now goes out on the window's active socket, which is what main does. - `connectors.policy.get` answered `POLICY_UNAVAILABLE` for a rejected sign-in, a refused scope, a non-member and a missing organisation alike: the handler runs with the gateway's globals and did not import the reason enum, so its own error mapping raised. `connectors.accounts.remove` caught auth failures in its generic branch. `org_required` was mapped on `policy.set` only. All six account RPCs now answer `NEEDS_NOUS_AUTH`, `FORBIDDEN_SCOPE`, `ORG_ACCESS_DENIED` and `ORG_REQUIRED` for those four upstream answers. * wip(desktop): port the Connectors tab files and wiring onto the #115191 head * wip(desktop): Connectors tab on the #115191 contract, catalog arm removed, audit defects fixed * wip(desktop): Connectors tab passes the anti-slop ratchet; dormant two-ways code and the Available collapse removed * wip(mcp): every server row says whether config or a plugin provides it; writes refuse plugin rows * wip(desktop): Connectors tab, the owner's first live round (custom MCP form, kind words, compact dialog) * wip(desktop): the connector dialog fits its content * wip(desktop): catalog MCPs show on the Connectors tab until the catalog dies; connector_slug pairs a manifest with its managed app; the closed-gate state * wip(desktop): connectors cache v3, the seed shape gained connector_slug * wip(desktop): the owner's answers on the connectors page A plugin-provided server now shows its tool list: the dialog probes it through the existing read-only test endpoint, shows the tools without switches (the plugin owns them), and shows the probe's error with a Retry when the server cannot start. Its card is named after the server key in the plugin's mcp.json, not the namespaced runtime key. The paste box no longer parses `--header` on a `hermes mcp add` line; the CLI has no such flag. The rule write sends the member layer's revision only. The portal always returns a member layer (baseline revision when no row exists) and compares the write against that row, so the effective revision was never the right guess. Verified live on staging: two writes in a row, both accepted, policy restored. The page cache keeps every read for signed-in accounts too and only clears itself when the account is signed out. The storage version moves to v4 so old blobs are ignored. * chore(desktop): strip the prose comments the connectors page branch added Comments and docstrings this branch added relative to main are gone; tool directives, SAFETY lines and the contract docstrings that feed the generated OpenRPC stay. Guards: Python AST and TypeScript printer output are identical before and after; ruff, tsc, eslint, the ratchet and the generated contracts are unchanged. --- .gitignore | 3 + .../capabilities/connectors/add-dialog.tsx | 124 ++++ .../connectors/add-server-draft.ts | 151 +++++ .../connectors/add-server-form.tsx | 326 +++++++++++ .../capabilities/connectors/catalog-mark.tsx | 21 + .../connectors/category-picker.tsx | 74 +++ .../connectors/connect-element.tsx | 49 ++ .../connectors/connector-dialog.tsx | 372 ++++++++++++ .../capabilities/connectors/connector-kind.ts | 19 + .../connectors/connector-row-card.tsx | 198 +++++++ .../connectors/connectors-directory.tsx | 225 ++++++++ .../connectors/connectors-tab.tsx | 388 +++++++++++++ .../connectors/data/account-operations.ts | 167 ++++++ .../capabilities/connectors/data/deep-link.ts | 29 + .../app/capabilities/connectors/data/join.ts | 309 ++++++++++ .../app/capabilities/connectors/data/keys.ts | 60 ++ .../capabilities/connectors/data/mutations.ts | 243 ++++++++ .../capabilities/connectors/data/persist.ts | 283 +++++++++ .../capabilities/connectors/data/portal.ts | 29 + .../capabilities/connectors/data/prefetch.ts | 44 ++ .../capabilities/connectors/data/queries.ts | 227 ++++++++ .../app/capabilities/connectors/data/rpc.ts | 152 +++++ .../capabilities/connectors/derive-page.ts | 94 +++ .../capabilities/connectors/derive-tools.ts | 312 ++++++++++ .../src/app/capabilities/connectors/derive.ts | 415 +++++++++++++ .../capabilities/connectors/dialog-menu.tsx | 60 ++ .../connectors/hint-vocabulary.ts | 109 ++++ .../capabilities/connectors/hosted-dialog.tsx | 155 +++++ .../capabilities/connectors/local-dialog.tsx | 120 ++++ .../connectors/local-server-control.tsx | 98 ++++ .../capabilities/connectors/local-slots.tsx | 71 +++ .../connectors/plugin-tools-panel.tsx | 108 ++++ .../app/capabilities/connectors/tool-row.tsx | 146 +++++ .../connectors/tools-filter-bar.tsx | 140 +++++ .../capabilities/connectors/tools-list.tsx | 543 ++++++++++++++++++ .../capabilities/connectors/tools-panel.tsx | 157 +++++ .../capabilities/connectors/tools-status.tsx | 120 ++++ .../capabilities/connectors/tools-summary.tsx | 127 ++++ .../src/app/capabilities/connectors/types.ts | 219 +++++++ .../connectors/use-tools-editor.ts | 176 ++++++ .../capabilities/connectors/ways-section.tsx | 148 +++++ apps/desktop/src/app/capabilities/index.tsx | 31 +- .../capabilities/mcp/install-catalog-entry.ts | 36 ++ .../src/app/capabilities/mcp/mcp-avatar.tsx | 25 + .../src/app/capabilities/mcp/mcp-doc.ts | 199 +++++++ .../src/app/capabilities/mcp/mcp-editor.tsx | 98 ++++ .../src/app/capabilities/mcp/mcp-logs.tsx | 65 +++ .../src/app/capabilities/mcp/mcp-status.ts | 160 ++++++ .../src/app/capabilities/mcp/use-mcp-draft.ts | 195 +++++++ .../app/capabilities/mcp/use-mcp-probes.ts | 162 ++++++ .../app/capabilities/mcp/use-mcp-servers.ts | 429 ++++++++++++++ .../desktop/src/app/command-palette/index.tsx | 10 +- .../contrib/hooks/use-desktop-integrations.ts | 13 +- .../contrib/mcp-install-deeplink-dialog.tsx | 2 +- apps/desktop/src/app/routes.ts | 6 +- .../src/app/routes.workspace-reveal.test.ts | 10 +- .../gateway-event/input-requests.ts | 7 + .../src/app/settings/moved-tabs.test.ts | 2 +- apps/desktop/src/app/settings/moved-tabs.ts | 21 +- .../src/components/ui/connector-card.tsx | 2 +- apps/desktop/src/components/ui/switch.tsx | 2 +- apps/desktop/src/i18n/en.ts | 240 ++++++++ apps/desktop/src/i18n/types.ts | 219 +++++++ apps/desktop/src/lib/connector-tools.ts | 15 +- apps/desktop/src/lib/mcp-import.ts | 119 +++- apps/desktop/src/lib/mcp-servers.ts | 2 + apps/desktop/src/lib/mcp-tool-filter.test.ts | 26 +- apps/desktop/src/lib/mcp-tool-filter.ts | 30 + apps/desktop/src/store/connection-request.ts | 6 +- apps/desktop/src/store/mcp-health.ts | 6 +- apps/desktop/src/types/hermes.ts | 1 + apps/shared/src/gateway-contract.generated.ts | 6 + apps/shared/src/gateway-contract.openrpc.json | 56 +- hermes_cli/mcp_catalog.py | 12 +- hermes_cli/plugins.py | 4 + hermes_cli/plugins_ledger.py | 2 +- hermes_cli/plugins_loader.py | 1 + hermes_cli/web_routers/mcp.py | 65 ++- hermes_cli/web_server_mcp.py | 4 +- optional-mcps/airtable/manifest.yaml | 1 + optional-mcps/asana/manifest.yaml | 1 + optional-mcps/atlassian/manifest.yaml | 1 + optional-mcps/attio/manifest.yaml | 1 + optional-mcps/betterstack/manifest.yaml | 1 + optional-mcps/calendly/manifest.yaml | 1 + optional-mcps/canva/manifest.yaml | 1 + optional-mcps/cloudflare/manifest.yaml | 1 + optional-mcps/datadog/manifest.yaml | 1 + optional-mcps/dropbox/manifest.yaml | 1 + optional-mcps/figma/manifest.yaml | 1 + optional-mcps/intercom/manifest.yaml | 1 + optional-mcps/linear/manifest.yaml | 1 + optional-mcps/neon/manifest.yaml | 1 + optional-mcps/notion/manifest.yaml | 1 + optional-mcps/prisma-postgres/manifest.yaml | 1 + optional-mcps/railway/manifest.yaml | 1 + optional-mcps/sentry/manifest.yaml | 1 + optional-mcps/stripe/manifest.yaml | 1 + optional-mcps/supabase/manifest.yaml | 1 + optional-mcps/todoist/manifest.yaml | 1 + optional-mcps/vercel/manifest.yaml | 1 + tests/hermes_cli/test_mcp_catalog.py | 21 + tests/tui_gateway/test_mcp_profile_rpcs.py | 2 + tui_gateway/contracts/tools_mcp_plugins.py | 10 + tui_gateway/mcp_rpc_helpers.py | 31 +- tui_gateway/methods_tools.py | 52 +- 106 files changed, 9149 insertions(+), 89 deletions(-) create mode 100644 apps/desktop/src/app/capabilities/connectors/add-dialog.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/add-server-draft.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/add-server-form.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/catalog-mark.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/category-picker.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/connect-element.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/connector-dialog.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/connector-kind.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/connector-row-card.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/connectors-directory.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/connectors-tab.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/data/account-operations.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/data/deep-link.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/data/join.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/data/keys.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/data/mutations.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/data/persist.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/data/portal.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/data/prefetch.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/data/queries.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/data/rpc.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/derive-page.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/derive-tools.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/derive.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/dialog-menu.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/hint-vocabulary.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/hosted-dialog.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/local-dialog.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/local-server-control.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/local-slots.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/plugin-tools-panel.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/tool-row.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/tools-filter-bar.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/tools-list.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/tools-panel.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/tools-status.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/tools-summary.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/types.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/use-tools-editor.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/ways-section.tsx create mode 100644 apps/desktop/src/app/capabilities/mcp/install-catalog-entry.ts create mode 100644 apps/desktop/src/app/capabilities/mcp/mcp-avatar.tsx create mode 100644 apps/desktop/src/app/capabilities/mcp/mcp-doc.ts create mode 100644 apps/desktop/src/app/capabilities/mcp/mcp-editor.tsx create mode 100644 apps/desktop/src/app/capabilities/mcp/mcp-logs.tsx create mode 100644 apps/desktop/src/app/capabilities/mcp/mcp-status.ts create mode 100644 apps/desktop/src/app/capabilities/mcp/use-mcp-draft.ts create mode 100644 apps/desktop/src/app/capabilities/mcp/use-mcp-probes.ts create mode 100644 apps/desktop/src/app/capabilities/mcp/use-mcp-servers.ts diff --git a/.gitignore b/.gitignore index 63c2583b76..227ac9ac97 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ data/ # Bundled community plugin index seed (shipped as package data) — the bare # `data/` pattern above would otherwise swallow it. !hermes_cli/data/ +# The Connectors page's wire layer (apps/desktop/src/app/capabilities/connectors/data/) +# — same reason: the bare `data/` pattern above would otherwise swallow it. +!apps/desktop/src/app/capabilities/connectors/data/ .pytest_cache/ test_durations.json .pytest-cache/ diff --git a/apps/desktop/src/app/capabilities/connectors/add-dialog.tsx b/apps/desktop/src/app/capabilities/connectors/add-dialog.tsx new file mode 100644 index 0000000000..f9ad6342b4 --- /dev/null +++ b/apps/desktop/src/app/capabilities/connectors/add-dialog.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' +import type { ProfileScope } from '@/hermes' +import { useI18n } from '@/i18n' +import { notifyError } from '@/store/notifications' + +import { McpJsonEditor } from '../mcp/mcp-editor' +import type { McpServersController } from '../mcp/use-mcp-servers' + +import { type AddServerDraft, EMPTY_ADD_DRAFT, entryOfDraft, isDraftComplete } from './add-server-draft' +import { AddServerForm } from './add-server-form' +import { setMcpBearerToken } from './data/rpc' + +export interface AddServerDialogProps { + controller: McpServersController + onOpenChange: (open: boolean) => void + open: boolean + profile: ProfileScope +} + +export function AddServerDialog({ controller, onOpenChange, open, profile }: AddServerDialogProps) { + const { t } = useI18n() + const copy = t.connectorsPage.add + const [draft, setDraft] = useState(EMPTY_ADD_DRAFT) + const [raw, setRaw] = useState(false) + const [saving, setSaving] = useState(false) + + const name = draft.name.trim() + const nameTaken = name !== '' && name in controller.servers + + const discardRawDraft = () => { + if (controller.dirty) { + controller.resetDraft(controller.servers) + } + } + + const close = () => { + discardRawDraft() + setDraft(EMPTY_ADD_DRAFT) + setRaw(false) + onOpenChange(false) + } + + const openRaw = () => { + controller.addServer() + setRaw(true) + } + + const leaveRaw = () => { + discardRawDraft() + setRaw(false) + } + + const save = async () => { + setSaving(true) + + try { + if (!(await controller.addServerEntry(name, entryOfDraft(draft)))) { + return + } + + if (draft.transport === 'http' && draft.auth === 'bearer' && draft.bearer.trim() !== '') { + await setMcpBearerToken(profile, name, draft.bearer.trim()) + controller.refetchConfig() + } + + close() + } catch (err) { + notifyError(err, copy.saveFailed) + } finally { + setSaving(false) + } + } + + return ( + (next ? onOpenChange(true) : close())} open={open}> + +
+ {copy.title} +
+ {copy.hint} + + {raw ? ( +
+ +
+ ) : ( +
+ +
+ )} + +
+ {raw ? ( + + ) : ( + <> + + + + )} +
+
+
+ ) +} diff --git a/apps/desktop/src/app/capabilities/connectors/add-server-draft.ts b/apps/desktop/src/app/capabilities/connectors/add-server-draft.ts new file mode 100644 index 0000000000..e2d069036e --- /dev/null +++ b/apps/desktop/src/app/capabilities/connectors/add-server-draft.ts @@ -0,0 +1,151 @@ +import { type McpServerEntry, normalizeEntry } from '@/lib/mcp-servers' + +export type AddServerAuth = 'bearer' | 'none' | 'oauth' + +export type AddServerTransport = 'http' | 'stdio' + +export interface DraftValue { + id: number + value: string +} + +export interface DraftPair { + id: number + key: string + value: string +} + +export interface AddServerDraft { + args: DraftValue[] + auth: AddServerAuth + bearer: string + command: string + cwd: string + env: DraftPair[] + headers: DraftPair[] + name: string + passthrough: DraftValue[] + transport: AddServerTransport + url: string +} + +let rowCounter = 0 + +export const nextRowId = (): number => ++rowCounter + +export const emptyValue = (value = ''): DraftValue => ({ id: nextRowId(), value }) + +export const emptyPair = (key = '', value = ''): DraftPair => ({ id: nextRowId(), key, value }) + +export const EMPTY_ADD_DRAFT: AddServerDraft = { + args: [], + auth: 'none', + bearer: '', + command: '', + cwd: '', + env: [], + headers: [], + name: '', + passthrough: [], + transport: 'stdio', + url: '' +} + +export const isDraftComplete = (draft: AddServerDraft): boolean => + draft.name.trim() !== '' && (draft.transport === 'stdio' ? draft.command.trim() !== '' : draft.url.trim() !== '') + +const filledPairs = (rows: readonly DraftPair[]): Record => + Object.fromEntries(rows.filter(row => row.key.trim() !== '').map(row => [row.key.trim(), row.value])) + +const filledValues = (rows: readonly DraftValue[]): string[] => + rows.map(row => row.value.trim()).filter(value => value !== '') + +const envReference = (name: string): string => `\${${name}}` + +function httpEntry(draft: AddServerDraft): McpServerEntry { + const headers = filledPairs(draft.headers) + const entry: McpServerEntry = { url: draft.url.trim() } + + if (Object.keys(headers).length > 0) { + entry.headers = headers + } + + if (draft.auth === 'oauth') { + entry.auth = 'oauth' + } + + return entry +} + +function stdioEntry(draft: AddServerDraft): McpServerEntry { + const env = { ...filledPairs(draft.env) } + + for (const name of filledValues(draft.passthrough)) { + env[name] = envReference(name) + } + + const args = filledValues(draft.args) + const entry: McpServerEntry = { command: draft.command.trim() } + + if (args.length > 0) { + entry.args = args + } + + if (Object.keys(env).length > 0) { + entry.env = env + } + + if (draft.cwd.trim() !== '') { + entry.cwd = draft.cwd.trim() + } + + return entry +} + +export const entryOfDraft = (draft: AddServerDraft): McpServerEntry => + draft.transport === 'http' ? httpEntry(draft) : stdioEntry(draft) + +type EntryValue = McpServerEntry[string] + +const isString = (value: EntryValue): value is string => Object.prototype.toString.call(value) === '[object String]' + +const asString = (value: EntryValue): string => (isString(value) ? value : '') + +const asRecord = (value: EntryValue): McpServerEntry => + value instanceof Object && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {} + +const asValues = (value: EntryValue): DraftValue[] => + Array.isArray(value) ? value.map((entry: EntryValue) => emptyValue(asString(entry))) : [] + +const forwarded = (key: string, value: EntryValue): boolean => value === envReference(key) + +export function draftFromEntry(name: string, raw: McpServerEntry, previous: AddServerDraft): AddServerDraft { + const entry = normalizeEntry(raw) + const url = asString(entry.url) + const named = name || previous.name + + if (url !== '') { + return { + ...EMPTY_ADD_DRAFT, + auth: asString(entry.auth) === 'oauth' ? 'oauth' : 'none', + bearer: previous.bearer, + headers: Object.entries(asRecord(entry.headers)).map(([key, value]) => emptyPair(key, asString(value))), + name: named, + transport: 'http', + url + } + } + + const env = Object.entries(asRecord(entry.env)) + + return { + ...EMPTY_ADD_DRAFT, + args: asValues(entry.args), + command: asString(entry.command), + cwd: asString(entry.cwd), + env: env.filter(([key, value]) => !forwarded(key, value)).map(([key, value]) => emptyPair(key, asString(value))), + name: named, + passthrough: env.filter(([key, value]) => forwarded(key, value)).map(([key]) => emptyValue(key)), + transport: 'stdio' + } +} diff --git a/apps/desktop/src/app/capabilities/connectors/add-server-form.tsx b/apps/desktop/src/app/capabilities/connectors/add-server-form.tsx new file mode 100644 index 0000000000..e10630e7d3 --- /dev/null +++ b/apps/desktop/src/app/capabilities/connectors/add-server-form.tsx @@ -0,0 +1,326 @@ +import { type ReactNode, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Input } from '@/components/ui/input' +import { SegmentedControl } from '@/components/ui/segmented-control' +import { Textarea } from '@/components/ui/textarea' +import { useI18n } from '@/i18n' +import type { Translations } from '@/i18n/types' +import { parseMcpImport } from '@/lib/mcp-import' + +import { + type AddServerAuth, + type AddServerDraft, + type AddServerTransport, + draftFromEntry, + type DraftPair, + type DraftValue, + emptyPair, + emptyValue +} from './add-server-draft' + +type AddCopy = Translations['connectorsPage']['add'] + +type SetDraft = (patch: Partial) => void + +export interface AddServerFormProps { + draft: AddServerDraft + nameTaken: boolean + onChange: (next: AddServerDraft) => void +} + +export function AddServerForm({ draft, nameTaken, onChange }: AddServerFormProps) { + const { t } = useI18n() + const copy = t.connectorsPage.add + const set: SetDraft = patch => onChange({ ...draft, ...patch }) + + return ( +
+ + + + set({ name: event.currentTarget.value })} size="sm" value={draft.name} /> + {nameTaken ?

{copy.nameTaken}

: null} +
+ +
+ set({ transport: next })} + options={[ + { id: 'stdio', label: copy.typeStdio }, + { id: 'http', label: copy.typeHttp } + ]} + value={draft.transport} + /> +
+ + {draft.transport === 'stdio' ? ( + + ) : ( + + )} +
+ ) +} + +function StdioFields({ copy, draft, set }: { copy: AddCopy; draft: AddServerDraft; set: SetDraft }) { + return ( + <> + + set({ command: event.currentTarget.value })} size="sm" value={draft.command} /> + + + set({ args })} + removeLabel={copy.removeRow} + rows={draft.args} + /> + + set({ env })} + rows={draft.env} + /> + + set({ passthrough })} + placeholder={copy.keyPlaceholder} + removeLabel={copy.removeRow} + rows={draft.passthrough} + /> + + + set({ cwd: event.currentTarget.value })} size="sm" value={draft.cwd} /> + + + ) +} + +const AUTH_OPTIONS: readonly AddServerAuth[] = ['none', 'oauth', 'bearer'] + +function HttpFields({ copy, draft, set }: { copy: AddCopy; draft: AddServerDraft; set: SetDraft }) { + const authLabel = { bearer: copy.authBearer, none: copy.authNone, oauth: copy.authOauth } + + return ( + <> + + set({ url: event.currentTarget.value })} size="sm" value={draft.url} /> + + + set({ headers })} + rows={draft.headers} + /> + +
+ set({ auth: next })} + options={AUTH_OPTIONS.map(id => ({ id, label: authLabel[id] }))} + value={draft.auth} + /> +
+ + {draft.auth === 'bearer' ? ( + + set({ bearer: event.currentTarget.value })} + size="sm" + type="password" + value={draft.bearer} + /> + + ) : null} + + ) +} + +function PasteBox({ + copy, + onFill, + previous +}: { + copy: AddCopy + onFill: (next: AddServerDraft) => void + previous: AddServerDraft +}) { + const [text, setText] = useState('') + const [failed, setFailed] = useState(false) + + const read = (value: string) => { + setText(value) + + if (value.trim() === '') { + setFailed(false) + + return + } + + const entries = parseMcpImport(value) + + setFailed(entries === null) + + if (entries !== null) { + onFill(draftFromEntry(entries[0].name, entries[0].config, previous)) + } + } + + return ( +
+