Repository navigation
fix(mcp): single state model for the server detail page + stdio client on the official go-sdk - #860
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d5d9af74c8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| default: | ||
| return nil, errMCPMethodNotFound |
There was a problem hiding this comment.
Preserve the full JSON-RPC proxy surface
For stdio servers that advertise resources, prompts, completion, or other MCP capabilities, downstream clients receive those capabilities from the replayed initialize result but every corresponding request now reaches this default branch and returns -32601; the previous implementation forwarded arbitrary JSON-RPC methods to the server. This makes the advertised capabilities unusable through /mcp-stdio/:connection_id, so either retain raw passthrough or implement every capability exposed by the initialization response.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid catch — fixed in ac2222e. The dispatch table now covers the full MCP client surface the go-sdk ClientSession speaks (prompts/list, prompts/get, resources/list, resources/read, resources/templates/list, resources/subscribe, resources/unsubscribe, completion/complete, logging/setLevel), so anything the replayed initialize advertises is callable through the proxy; -32601 is reserved for genuinely unknown (experimental/custom) methods. Raw passthrough is not an option here — the SDK session's read loop owns the transport, so interleaving hand-correlated frames would race it. New dispatch subtests run prompts/* and resources/* end to end against a real in-memory SDK server.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ac2222e8c8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| try { | ||
| await putBotsByBotIdMcpById({ | ||
| path: { bot_id: props.botId, id: serverId.value } as unknown as { bot_id: string, id: string }, | ||
| body: buildBody(snap, value), |
There was a problem hiding this comment.
Preserve OAuth authentication when toggling the server
When the saved remote connection has auth_type: "oauth", this new immediate toggle sends buildBody(snap, value), which contains no auth_type. ConnectionService.Update defaults an omitted value to "none", so merely enabling or disabling the server silently removes its OAuth mode and subsequent requests stop attaching the stored token. Include the saved authentication type in this update or provide an active-only endpoint.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed at the root, backend-side (5501a8b): ConnectionService.Update now preserves the stored auth_type when the upsert omits it instead of defaulting to none. Upsert payloads never carry auth_type — that field is owned by the OAuth flow — so an omitted value now means "keep". This protects every caller (toggle, save, any other client), not just this one.
| v-if="probing" | ||
| class="size-4" | ||
| /> | ||
| <svg |
There was a problem hiding this comment.
Replace the inline SVG with the shared icon system
Replace this hand-written checkmark SVG with a lucide-vue-next icon or an existing shared icon component; the Web frontend contract explicitly prohibits introducing inline SVG icons in templates.
AGENTS.md reference: apps/web/AGENTS.md:L547-L547
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed (0677c07). The checkmark is now a shared component (apps/web/src/components/check-draw-icon) reused by the MCP detail and the provider form — no more inline SVG. Bonus catch while fixing: the detail's copy referenced a check-draw class whose keyframes only existed inside other components' scoped styles, so the draw animation could never have played here; the shared component owns its styles.
| return c.sdkCall(ctx, req, nil, func(ctx context.Context) (any, error) { | ||
| return c.session.ListTools(ctx, &sdkmcp.ListToolsParams{}) |
There was a problem hiding this comment.
Forward pagination cursors to list methods
When an MCP server paginates its tool list, callers send the returned cursor in the next tools/list request, but this dispatch path discards req.Params and always passes an empty ListToolsParams, repeatedly returning the first page. The same pattern affects prompts and resource list methods, making later pag
8000
es inaccessible through the proxy; decode each request's params before invoking the SDK.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed (5501a8b): all four list methods (tools/list, prompts/list, resources/list, resources/templates/list) now decode req.Params into the typed SDK params before invoking, so cursors are forwarded. A regression test asserts params decoding rejects a malformed cursor instead of silently dropping it.
| // whitespace, and a legacy config that stored a whole line as one token must | ||
| // re-display as that line so the next save parses it back into shape. | ||
| export function joinShellWords(command: string, args: string[]): string { | ||
| return [command.trim(), ...args.map(quoteShellWord)].filter((s) => s !== '').join(' ') |
There was a problem hiding this comment.
Quote executable paths when joining shell words
When a stored executable path contains whitespace, such as /opt/my server/bin/mcp, this joins it without quoting. Opening the detail then parses /opt/my as the command and the remainder as arguments, marks an unchanged server dirty, and corrupts the saved command if the user saves. Apply quoteShellWord to valid command tokens as well, while handling legacy whole-line commands separately.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed (0677c07): joinShellWords now quotes the command token when it needs it — a real path with whitespace like /opt/my server/bin/mcp round-trips exactly with no phantom dirty state — while a multi-word token whose first word is a bare name still renders raw, so the legacy whole-line repair flow keeps working. quoteShellWord also learned # to stay in sync with the backend escaper.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0677c07f43
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if authType == "" { | ||
| // Upsert payloads never carry auth_type — that field is owned by the | ||
| // OAuth flow. Defaulting an omitted value to "none" would silently | ||
| // strip OAuth from the connection on every save/toggle and leave the | ||
| // stored token permanently unattached, so preserve the stored value. | ||
| if existing, getErr := s.Get(ctx, botID, id); getErr == nil { | ||
| authType = strings.TrimSpace(existing.AuthType) | ||
| } |
There was a problem hiding this comment.
Clear OAuth when the remote origin changes
When an OAuth-authorized remote connection is edited to use a different origin, this blanket preservation keeps auth_type as oauth. The post-save probe and subsequent tool calls then use connectionHTTPClient, which attaches the connection's existing bearer token to the newly configured URL, potentially disclosing credentials to an unrelated host. Preserve OAuth only when the credential scope remains unchanged; changing origin or transport should clear the authorization and require re-authorization.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and fixed (e4870e9): preservation is now scoped to the credential's origin. Update keeps auth_type only when the connection stays remote on the same scheme://host[:port]; editing to a different origin (or switching to stdio) falls back to none, so a token minted for one host can never be attached to another. The OAuth flow owns re-authorization after such an edit.
| if err != nil { | ||
| return c.JSON(http.StatusOK, mcptools.JSONRPCErrorResponse(req.ID, -32603, err.Error())) | ||
| code := -32603 | ||
| if errors.Is(err, errMCPMethodNotFound) { | ||
| code = -32601 | ||
| } |
There was a problem hiding this comment.
Preserve downstream JSON-RPC error codes
When a proxied stdio server returns a JSON-RPC error such as -32602 or a server-defined -320xx code, the SDK surfaces it as an error and this branch rewrites it to -32603 unless it is the locally generated method-not-found sentinel. This prevents MCP clients from distinguishing invalid requests, missing tools, and server-specific failures; extract the SDK JSON-RPC error code and message before falling back to internal error.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed (e4870e9): when the downstream server answers with a JSON-RPC error, dispatch now surfaces the SDK's *jsonrpc.Error and the handler forwards its code verbatim (-32602, server-defined -320xx) instead of flattening to -32603. These errors also skip exit-code enrichment — a server that answered is alive by definition. Covered by a dispatch-level test asserting the wire error survives with its code intact.
…error attribution The stdio MCP client was a hand-rolled JSON-RPC session machine (init handshake state machine, pending-call map, read loop) on top of the bridge ExecStream, while the remote transports already used the official modelcontextprotocol/go-sdk. Replace it with sdkmcp.ClientSession: Connect() performs the initialize handshake, ListTools/CallTool/Ping go through the session, and the protocol version is SDK-negotiated instead of hardcoded. Failure attribution: the Exec demux goroutine now captures the EXIT frame's exit code (previously dropped), so a dead server process reports "process exited with code 127: /bin/sh: 1: foo: not found" instead of a bare EOF. Also pass timeout -1 to ExecStream — 0 was interpreted by the bridge as the 30s default and SIGKILLed slow-starting servers mid-probe. The federation gateway stdio methods now mirror the HTTP/SSE paths (convertSDKTools / wrapSDKToolResult), and the external HandleMCPStdio endpoint keeps its API surface via a bounded method-dispatch table (ping / initialize / tools/list / tools/call, -32601 otherwise). Tests: rewrite mcp_session_test.go around a real sdkmcp.Server over IOTransport pipe pairs (round-trip, dispatch table, enrichError, server death), plus buildShellCommand coverage.
The MCP settings page carried contradictory state ("Not tested" next to
"Testing..."), a stdio-only advanced config leaking into HTTP mode, a
Command field that mangled pasted launch lines, an Enabled switch that
looked instant but was a draft, and an Export button on the primary row.
State model: status/probe now always describes the SAVED server, never
the draft. The header identity card owns the persisted status badge
(semantic variants, holds the last known result while a probe runs);
the Test button owns only this-visit feedback (spinner / check / alert,
bare on every entry). Enabled auto-commits optimistically outside the
form draft; Save is disabled while clean and re-probes only when
connection fields changed; Test is disabled while the draft diverges.
Unsaved drafts are tracked against a stored-shape snapshot (legacy
broken configs surface as unsaved instead of being silently normalized)
and guarded on leave/switch/create.
Layout: provider-detail alignment — identity header card (hover-pencil
name edit, status badge, delete, switch), stacked field titles, actions
in the section footer, Advanced behind an ActionCard + focused dialog.
Create mode drops the header entirely (no entity, no switch), keeps
Name in the form, accepts the factory-default command when left empty,
and redirects explicitly onto the new server's canonical state, which
auto-probes once. Shape changes animate via AutoHeight above the
remount boundary (the OAuth device-code card pattern).
Command + Arguments merge into one launch-line field: stored split per
the official mcpServers schema, presented joined (new shell-words
util with round-trip quoting, 17 tests). Transport switching stashes
env/headers per mode instead of leaking them. Export button removed.
SettingsSection's footer divider is now an inset hairline aligned with
the content padding instead of full-bleed.
The bounded dispatch table only spoke tools/*, but the replayed initialize result advertises whatever the server supports — a downstream client that took the advertisement at face value and called resources/list, prompts/get, or completion/complete got -32601 for a capability the proxy itself had just announced. The hand-rolled client this replaced forwarded arbitrary methods, so this was a regression on the public /mcp-stdio/:connection_id surface. Extend the table to every method the go-sdk ClientSession speaks — prompts, resources (+ templates, subscribe/unsubscribe), completion, logging — via a shared sdkCall helper that decodes raw params into the typed SDK structs and enriches failures with process diagnostics. -32601 is now reserved for genuinely unknown (experimental/custom) methods. Tests: the in-memory server registers a prompt and a resource alongside the echo tool; dispatch subtests cover prompts/list, prompts/get, resources/list, and resources/read end to end.
- Preserve the stored auth_type on Update when the upsert omits it: every save/toggle was silently resetting an OAuth connection to "none", leaving the token stored but never attached (UI still showed "Authorized"). - Forward pagination cursors: tools/list, prompts/list, resources/list and resources/templates/list now decode request params instead of always returning the first page. - Quote env values separately from keys in the stdio launch line — a quoted "KEY=value" word is not an assignment to sh, so values with whitespace killed the launch with exit 127; also quote '#', which starts a comment as the first char of a bare word. - enrichError keeps the original failure text alongside exit diagnostics, so a protocol error racing a process exit is no longer swallowed (io.EOF itself stays out: it adds nothing beyond "the process died"). - Register onClose before the session's Wait goroutine can fire Close, and reap entries whose process died before registration — the registry entry used to leak forever in that window. - Drop the dead lastUsedAt field (unsynchronized write, never read). - Log dropped notifications instead of silently acking them; the SDK owns the protocol and offers no generic notify, a known fidelity gap vs the old hand-rolled client (e.g. notifications/cancelled).
- Extract the self-drawing checkmark into a shared component (it existed as
three copies, and the MCP detail's had no keyframes in scope — the draw
animation could never play there); the detail and provider form now reuse
it instead of inline SVG, which the frontend contract forbids.
- Mutual exclusion between the Enabled switch and Save: the toggle commits
the pre-edit snapshot and the save commits the toggle's optimistic active,
so letting both fly let whichever landed last silently revert the other.
- Draft comparison no longer relies on JSON.stringify: env/header records
compare key-by-key, so re-adding an identical pair in a different order
is not a phantom change.
- joinShellWords quotes an executable path containing whitespace (imported
configs like "/opt/my server/bin/mcp" used to re-parse into a bogus
command + phantom args, showing unsaved changes on an untouched draft and
corrupting the command on save); legacy whole-line commands still render
raw so the repair flow keeps working. quoteShellWord also quotes '#',
staying in sync with the backend's escapeShellArg.
- Remove the remote env-vars editor and the save-time ${VAR} substitution:
the backend never stores env for remote configs, so the editor was
unreachable dead code (inherited verbatim from the old UI) and the
substitution always ran against an empty var set.
The message goes out as a JSON-RPC result via c.JSON — JSON-encoded, never rendered as HTML. Same suppression pattern as internal/handlers/message.go.
…codes - The blanket auth_type preservation overreached: editing an OAuth remote to a different origin would keep auth_type=oauth and attach the stored bearer token to an unrelated host. Preserve only when the connection stays remote on the same origin (scheme://host[:port]); anything else falls back to "none" and the OAuth flow owns re-authorization. - A JSON-RPC error answered by the downstream server now keeps its code through the proxy (-32602, server-defined -320xx) instead of being flattened to -32603. It also skips exit-code enrichment entirely: a server that answered is alive by definition.
The authorization code is single-use, but the exchange rode the browser
request's context: closing or re-navigating the popup mid-exchange aborted
it ("context canceled") and burned the code, forcing a full restart. The
exchange and token persistence now run on a detached 30s context — the
frontend polls status and completes the flow regardless of what the
callback's HTTP response does.
- The completion poll checked `configured`, which becomes true the moment authorization STARTS (the endpoint is stored up front) — so the flow toasted "Authorization successful" seconds after clicking Authorize, before any consent, then probed a tokenless server into the Error state and consumed the `completed` flag that the real callback needed. Poll `has_token` instead: completion means the token landed. - The Authorize button spun but never disabled, so impatient re-clicks started parallel flows that re-navigated the same named popup and aborted in-flight callbacks. It now disables for the flight, with a matching reentry guard. - finishOAuth closes the popup on success — poll-based completion can win the race against the callback page's own self-close.
…provider One httptest server plays both halves of Linear (token-gated MCP endpoint plus authorization server with PRM, ASM, DCR, real PKCE verification, single-use codes, refresh rotation), so the tests fail when the client cuts a spec corner — not just when its own bookkeeping breaks: - the complete journey: discover → DCR → authorize → callback → stored token opens the resource server, with resource/PKCE/redirect_uri asserted on the provider side - the shipped regressions: callback exchange survives a client abort mid-flight, authorization codes are single-use, tampered PKCE verifiers are rejected, refresh rotates and invalidates the old pair, DCR runs once per connection - sameRemoteOrigin table test for the auth_type preservation gate
The dialog auto-opens to spotlight the missing authorization; once the flow completes its reason is gone, so success now returns the user to the detail where the connected status and discovered tools live.
Authorization is part of the main flow for servers that require sign-in, not an advanced knob, so it leaves the Advanced dialog for its own Authentication section on the detail page: one quiet row mirroring the provider OAuth section (status as the row description, a sm outline Authorize, ghost Revoke behind a confirm), growing in place via AutoHeight for manual client credentials. Advanced keeps only env vars and headers. Deliberate behavior changes: - the probe auth_required spotlight no longer opens the dialog; the row description carries the state instead - revoke is now confirm-gated - an expired token reads as its own state with re-authorize, not as Authorized
…e lines joinShellWords' legacy heuristic only checked the first word for a path separator, so a stored "my dir/srv" rendered raw, re-split into command=my + phantom args, and the next save persisted the corruption over a working config (buildShellCommand escapeShellArg-quotes the stored command, so the unsplit shape was the correct one). The heuristic now requires empty args, a bare first word, and a later word that looks like a flag or carries no separator — erring toward never corrupting a valid path over surfacing a repair.
…-page guards
Four review findings on the single-state-model detail page:
- Restore ${VAR} save-time substitution for remote url/headers (dropped in
the redesign, silently persisting literal placeholders that 401). Vars
resolve from the stashed stdio env rows — the only env source in a remote
session — and an unresolved placeholder now blocks the save with a toast
instead of baking in a broken config. The Enabled toggle deliberately
carries the snapshot verbatim without substitution.
- Leave guard now counts transport stashes: typing env, switching to remote,
then leaving no longer discards the rows without confirmation.
- Probe AbortController is actually wired to the request signal (it was
decorative), and an older overlapping probe's finally no longer blanks the
newer probe's spinner/controller.
- A blocked authorization popup fails fast with a toast instead of polling
silently for 120s.
…scope Update preserved auth_type whenever the origin matched, but the token's RFC 8707 resource includes the path (canonicalResourceURI) — so a same-origin path edit left a token whose audience no longer matched (persistent 401 while the UI showed Authorized), and a cross-origin edit reset auth_type without clearing the grant (GetStatus kept reporting has_token=true while requests went out anonymous). The preserve gate now compares canonical resource URIs (origin AND path), and an out-of-scope edit clears the stored grant (mirroring RevokeToken: the client registration survives, so re-authorization needs no re-registration). Clearing runs before the row update so a failure can never leave a credential attached to a target it was not minted for.
… the stdio proxy The go-sdk migration dropped two protocol behaviors the hand-rolled client had, because the SDK owns the transport and offers no raw passthrough: - The external client's initialize capabilities never reached the container server: the session handshook at create with memoh's fixed empty identity. The session process now starts LAZILY on the first proxied message, so an initialize drives the single handshake with the real client's capabilities/clientInfo (spec-valid; the old raw-forward of a second initialize was not). Create keeps its contract — fail fast, tool list — via a throwaway probe process. Sampling/elicitation stay advertised but unfulfillable: the SDK answers them method-not-found instead of the old silent hang. - notifications/cancelled was acknowledged and dropped, so cancellation never reached the server. Dispatched calls now register their cancel func under the external request ID; a cancelled notification resolves and cancels it, and the SDK forwards a spec-compliant cancelled carrying the internal call ID. Both paths are covered by wire-level tests over real pipes: the handshake frame carries the external capabilities, and a cancelled tools/call unblocks both the dispatch and the server handler with context.Canceled.
…ars toast
vue-i18n treats {VAR} as a named interpolation, so the literal ${VAR} in
the message was eaten and the toast read "unresolved $ placeholders".
Pass it as a param instead.
env is a stdio-only concept (process environment); remote connections have no env editor and the backend never stores env for them, so the only value source left was an undiscoverable stdio-stash trick that lived for one editing session. Removing the mechanism beats keeping it on life support: headers now store exactly what the user typed. Deliberate removal of a main-era behavior, to be disclosed in the PR body.
36a93e4 to
407a66d
Compare
@felinic/ui's ConfirmPopover requires cancelText (no default); the OAuth Revoke popover in the Authentication section passed only confirm-text, so the cancel button rendered blank (and vue-tsc flagged a new TS2345).
Summary
Two tightly related fixes for the MCP server settings page, split into two commits:
fix(mcp)— migrate the stdio client to the official go-sdkThe stdio MCP client was a hand-rolled JSON-RPC session machine (init handshake state machine, pending-call map, read loop) on top of the bridge
ExecStream, while the remote transports already used the officialmodelcontextprotocol/go-sdk. It is now a plainsdkmcp.ClientSession:Connect()performs the initialize handshake; protocol version is SDK-negotiated instead of hardcoded2025-06-18exit_code(previously dropped), so a dead server reportsprocess exited with code 127: /bin/sh: 1: foo: not foundinstead of a bareEOFExecStreamwas called with timeout0, which the bridge interprets as the 30s default — slow-starting servers were SIGKILLed mid-probe. Now-1(no timeout; the probe context bounds it)convertSDKTools/wrapSDKToolResult)HandleMCPStdiokeeps its external API surface via a bounded method-dispatch table (ping/initialize/tools/list/tools/call,-32601otherwise)fix(web)— rebuild the detail page around a single state modelThe page carried contradictory state (
Not testednext toTesting...), stdio-only advanced config leaking into HTTP mode, a Command field that mangled pasted launch lines, an Enabled switch that looked instant but was a draft, and an Export button on the primary row.AutoHeightabove the remount boundary (the OAuth device-code card pattern)mcpServersschema, presented joined (newshell-wordsutil with round-trip quoting, 17 tests). Legacy broken configs surface as unsaved changes instead of being silently normalized (snapshot is kept in stored shape)The footer band's top divider changes from full-bleed
border-tto an inset hairline (mx-4, aligned with content padding, matching row-divider logic), and the double-hairline guard moves fromnth-last-child(2)to(3). This affects all 7#footerconsumers (providers, transcription, video, memory, speech, web-search, email settings).Since #839 turned
apps/web/src/components/settings/section.vueinto a re-export shim, the change is not in this PR's diff — it is companion PR felinics/ui#8. Until ui#8 merges and the gitlink is bumped, the app keeps today's full-bleed divider (no regression vs main).Test plan
go build ./... && go test ./internal/handlers/(rewrittenmcp_session_test.go: realsdkmcp.Serverover IOTransport — round-trip, dispatch table, enrichError, server death;buildShellCommandtable)vitest run apps/web/src/utils/shell-words.test.ts(17 tests, join/split round-trips)npx -y @modelcontextprotocol/server-everythingin a workspace container → Connected + 14 discovered toolsprocess exited with code 127: ... not found(was:EOF)Review-fix round (2026-07-26, 6 commits)
A full review pass found 8 verified issues; all are fixed on this branch. Behavior changes, disclosed deliberately:
${VAR}substitution removed. main used to resolve${VAR}in remote URL/headers from env pairs at save time. env is a stdio-only concept (process environment): remote connections no longer have an env editor, the backend never stores env for remote configs, and the only remaining value source was an undiscoverable stdio→remote stash that lived for one editing session. Remote URL/headers now save verbatim — paste real values. If secret indirection for headers ever becomes a real need, it should come back as proper secret management, not session-stashed env.capabilities.rootsintoRootCapabilitieswithout touching the deprecated field), andnotifications/cancellednow cancels the in-flight call instead of being dropped.auth_typeand the stored token stay on the same resource URI — a same-origin path change keeps the token valid; an origin change clears stored tokens instead of showing "authorized" while sending anonymous requests.initialize.Protocol-surface notes (from a second blind review): on the stdio proxy, only
notifications/cancelledis forwarded to the in-container server — other client→server notifications (progress,roots/list_changed) are still logged and dropped, as before. Proxied results round-trip through SDK typed decode/encode, so non-standard extension fields in results are not preserved verbatim, and the replayedinitializeresult carries the SDK-negotiated version rather than mirroring the client's requested one. All three are fidelity narrowings vs raw forwarding; acceptable for the known consumers, flagged here for the record.Small follow-ups filed from the same review (not blocking): the default-command create path can briefly report a phantom unsaved-change in the create→redirect window; the swaggo comment block for
HandleMCPStdiosits aboveensureStdioSessioninstead of its handler.Verified:
go build ./...;go test ./internal/handlers/ ./internal/mcp/(incl. wire-level handshake + cancellation tests over real pipes); fullvitest run(web project green; the 5 desktop-project failures are pre-existing on main since fix(desktop): append /api to server URL if missing #814 — impl/test mismatch, and CI doesn't run vitest); eslint clean; UI contract guard clean.'my dir/srv'round-trips unmangled — the shell-words legacy-heuristic fix); remote Linear OAuth → authorized → discover tools.Rebased onto main (b499395, incl. #839). The footer fix moved to companion PR felinics/ui#8; this PR no longer touches
settings/section.vue. Suggested order: land ui#8 → bump the gitlink (tiny follow-up) → land this PR.