Skip to content

Local MCP: moving the port after a bind conflict keeps the capability path #23

Closed

Description

@takashi-matsuyama

Recovering from a local-MCP bind conflict keeps the profile's capability path.
That is deliberate today — reassignLocalMcpPort says so
(src/main/profileStore.ts:376-387): only the port moves, so a configuration
the user already pasted stays valid for the profile it named, and they re-copy
one line instead of re-pasting a secret. This issue is about whether that trade
still holds in the one case where the conflict was not benign.

The path a hostile local process could take

  1. A user has a client configured with kozou-local-<profile> at
    127.0.0.1:<port>/mcp-<hex>. The path sits in that client's config file in
    plaintext, which EGRESS.md item 10 already states.
  2. The app is not running. Another local process binds <port> and answers on
    any path. The configured client connects on its own schedule and hands the
    path over. Nothing about the transport prevents this: loopback is per-machine,
    the path is a capability, and there is no server authentication for a client
    to verify.
  3. The user starts the app, sees MCP port busy, and presses move port. The
    port changes; the path does not. The squatter, which now knows the path, can
    scan for the new port and reach the tools.

What that reaches is schema metadata — structure, COMMENTs, @ai/@policy
annotations, function signatures. Never row data, and no write path: the MCP
worker has no execution tool and cannot construct one (EGRESS.md item 8).
So this is a metadata-confidentiality question, not an integrity one.

There is a smaller UI leg to the same story: the card stops offering to open the
config panel while the status is error-port-busy, but a panel already open
keeps rendering the snippet with only a "server currently stopped" hint
(src/renderer/src/App.svelte:482-505). A user reading that hint as "not
running, so nothing is listening" would be wrong in exactly this scenario.

What could change

  • Rotate the path on recovery from a bind conflict, and tell the user the old
    configuration is dead rather than letting it fail silently. Costs the current
    "re-copy one line" property.
  • Rotate only when the conflict was observed as a foreign bind, keeping the
    cheap path for an ordinary conflict (a stale instance of this app, a port the
    user reassigned themselves).
  • Close or blank an open config panel when the status turns error-port-busy,
    independently of whichever rotation policy is chosen.

Why this is filed rather than fixed

The current behaviour is documented intent, and each option above trades user
friction against a threat that already requires a local process able to read the
user's client config files. That balance is a product decision, not a bug fix.
The README no longer implies the config is withheld while a foreign process
holds the port, and it now states that moving the port keeps the path, so the
documentation is accurate under today's behaviour either way.

Activity

  1. takashi-matsuyama commented on Aug 16, 2026

    @takashi-matsuyama
    MemberAuthor

    Settled by #47 (merged): recovery from a bind conflict now rotates the capability path along with the port, keeping the locator id.

    Why the trade came out that way. The premise this issue reasoned from — that rotating "costs the current re-copy one line property" — does not hold once both config shapes are read:

    • a pasted URL names the port, so move port had already stopped it naming this app's server, and the panel copies whole snippets, so the same one line is re-copied either way;
    • a bridge entry names only the locator id, and the bridge reads port and path from the locator when it connects (src/bridge/stdioBridge.ts), so it is transparent to both changes. That entry did not exist when this was filed.

    So the path was being kept for nobody, while a client still configured with the old URL goes on offering it to whoever holds that port now.

    On the three options listed above:

    • Rotate on recovery — adopted.
    • Rotate only on an observed foreign bind — not adopted. EADDRINUSE carries no identity of the holder, and probing for one either hands the real path to whatever is listening, or, with a freshly generated path, is defeated by a squatter that answers only paths it has already harvested — which is the adversary this issue describes. The "stale instance of this app" case it would protect is also narrow: a second instance is refused by the single-instance lock, leaving an orphaned worker after a force quit.
    • Close or blank an open config panel — already on main before this was resolved. The panel is no longer offered while the status is error-port-busy, and one that was already open carries a note naming the port conflict rather than a generic "not running" hint.

    Line references in the text above have drifted (filed 2026-08-02, before the layout rework): reassignLocalMcpPort is now src/main/profileStore.ts:523, and the UI leg moved from App.svelte to src/renderer/src/McpBlock.svelte:118-130 and src/renderer/src/lib/statusCopy.ts:152.

    Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions