8000 fix(mcp): single state model for the server detail page + stdio client on the official go-sdk by qqqqqf-q · Pull Request #860 · felinics/Memoh · GitHub
[go: up one dir, main page]

Skip to content

fix(mcp): single state model for the server detail page + stdio client on the official go-sdk - #860

Merged
qqqqqf-q merged 19 commits into
felinics:mainfrom
qqqqqf-q:fix/mcp-state-model
Jul 29, 2026
Merged

qqqqqf-q merged 19 commits into
felinics:mainfrom
qqqqqf-q:fix/mcp-state-model

Conversation

@qqqqqf-q
@qqqqqf-q qqqqqf-q commented Jul 25, 2026 •
Copy link
Copy Markdown
Member

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-sdk

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. It is now a plain sdkmcp.ClientSession:

  • Connect() performs the initialize handshake; protocol version is SDK-negotiated instead of hardcoded 2025-06-18
  • Error attribution: the Exec demux now captures the EXIT frame's exit_code (previously dropped), so a dead server reports process exited with code 127: /bin/sh: 1: foo: not found instead of a bare EOF
  • Pre-existing bug: ExecStream was called with timeout 0, which the bridge interprets as the 30s default — slow-starting servers were SIGKILLed mid-probe. Now -1 (no timeout; the probe context bounds it)
  • Federation gateway stdio methods now mirror the HTTP/SSE paths (convertSDKTools / wrapSDKToolResult)
  • HandleMCPStdio keeps its external API surface via a bounded method-dispatch table (ping / initialize / tools/list / tools/call, -32601 otherwise)

fix(web) — rebuild the detail page around a single state model

The page carried contradictory state (Not tested next to Testing...), 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.

  • One state, one place: status/probe always describes the saved server, never the draft. 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 (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 guarded on leave/switch/create
  • Layout: provider-detail alignment — identity header card (hover-pencil name edit, badge, delete, switch), stacked field titles, actions in the section footer, Advanced behind ActionCard + focused dialog (no duplicated copy)
  • Create flow: no header card (no entity → no switch/badge/delete), Name stays in the form, empty Command falls back to the reference server default, and create 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). Legacy broken configs surface as unsaved changes instead of being silently normalized (snapshot is kept in stored shape)
  • Transport switching stashes env/headers per mode instead of leaking them; Export button removed

⚠️ Shared-component change — now lives in @felinic/ui

The footer band's top divider changes from full-bleed border-t to an inset hairline (mx-4, aligned with content padding, matching row-divider logic), and the double-hairline guard moves from nth-last-child(2) to (3). This affects all 7 #footer consumers (providers, transcription, video, memory, speech, web-search, email settings).

Since #839 turned apps/web/src/components/settings/section.vue into 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/ (rewritten mcp_session_test.go: real sdkmcp.Server over IOTransport — round-trip, dispatch table, enrichError, server death; buildShellCommand table)
  • vitest run apps/web/src/utils/shell-words.test.ts (17 tests, join/split round-trips)
  • eslint + vue-tsc on changed files (3 pre-existing SDK path-cast errors on HEAD are unrelated)
  • Live: stdio npx -y @modelcontextprotocol/server-everything in a workspace container → Connected + 14 discovered tools
  • Live: bogus command → process exited with code 127: ... not found (was: EOF)
  • Reviewer: create → explicit redirect → auto-probe; toggle Enabled; rename-only Save (no re-probe); dirty-leave guard; zh/ja locales; one provider page for the footer divider change

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:

  • Remote ${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.
  • stdio proxy protocol fidelity restored: external client capabilities are forwarded at handshake (works around go-sdk feat(agent): support run_in_background for spawn subagent batches #607 by decoding wire capabilities.roots into RootCapabilities without touching the deprecated field), and notifications/cancelled now cancels the in-flight call instead of being dropped.
  • OAuth scope consistency: auth_type and 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.
  • Create flow spawns a throwaway probe session (validates the command, fills the tool list, closes); the real session starts lazily on the first proxied message, with capabilities taken from the client's initialize.
  • Popup-blocked OAuth toasts immediately instead of spinning 120s; probe AbortController is per-call; the leave guard also covers stashed env/headers content for new drafts (a saved server that stashes then returns to its original transport is clean by definition — stashes only exist to survive transport switches).

Protocol-surface notes (from a second blind review): on the stdio proxy, only notifications/cancelled is 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 replayed initialize result 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 HandleMCPStdio sits above ensureStdioSession instead of its handler.

Verified:

  • go build ./...; go test ./internal/handlers/ ./internal/mcp/ (incl. wire-level handshake + cancellation tests over real pipes); full vitest 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.
  • Live QA (human, isolated dev stack): stdio happy path incl. a spaced-path command ('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.

@qqqqqf-q
qqqqqf-q requested review from a team July 25, 2026 13:49
@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +154 to +155
default:
return nil, errMCPMethodNotFound
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@qqqqqf-q
Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/handlers/mcp_stdio.go Outdated
Comment on lines +135 to +136
return c.sdkCall(ctx, req, nil, func(ctx context.Context) (any, error) {
return c.session.ListTools(ctx, &sdkmcp.ListToolsParams{})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread apps/web/src/utils/shell-words.ts Outdated
// 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(' ')
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@qqqqqf-q
qqqqqf-q requested a review from a team July 25, 2026 15:23
@qqqqqf-q
Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +292 to +299
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)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 495 to +499
if err != nil {
return c.JSON(http.StatusOK, mcptools.JSONRPCErrorResponse(req.ID, -32603, err.Error()))
code := -32603
if errors.Is(err, errMCPMethodNotFound) {
code = -32601
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

qqqqqf-q added 18 commits July 26, 2026 05:41
…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.
@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).
@qqqqqf-q
qqqqqf-q merged commit 03c5d4f into felinics:main Jul 29, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0