Skip to content

fix(mcp): serve both IPv4 and IPv6 loopback on the default HTTP host - #950

Open
yotamleo wants to merge 1 commit into
tobi:mainfrom
yotamleo:fix/mcp-http-dual-stack-loopback
Open

yotamleo wants to merge 1 commit into
tobi:mainfrom
yotamleo:fix/mcp-http-dual-stack-loopback

Conversation

@yotamleo

Copy link
Copy Markdown
Contributor

Impact

qmd mcp --http (and --daemon) listens on only ONE loopback family by default. startMcpHttpServer defaults the host to "localhost" and calls httpServer.listen(port, "localhost"). That binds only the first address localhost resolves to, which is ::1 on typical modern Linux hosts. Any client that connects to http://127.0.0.1:<port>, or resolves localhost IPv4-first, gets ECONNREFUSED while the daemon is healthy.

Repro

At upstream main 04e4dbd, on a Linux host where localhost resolves to ::1 first:

qmd mcp --http --daemon
ss -ltnp | grep 8181               # [::1]:8181 only
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8181/mcp   # 000 (refused)
curl -s -o /dev/null -w '%{http_code}\n' 'http://[::1]:8181/mcp'     # 405

Fix

When the resolved host is the default "localhost", bind 127.0.0.1 first, then ::1, with the same request handler and on the same port. When 0 is requested, the second listener reuses the actual port the first one got.

  • Missing address family: if one family is unavailable (EADDRNOTAVAIL / EAFNOSUPPORT, e.g. IPv6 disabled), the server continues on the listener that bound.
  • Other bind errors: any other error (notably EADDRINUSE on either address) closes whatever already bound and rejects, so a port collision still fails loudly.
  • Explicit host: an explicit --host / QMD_HOST (127.0.0.1, ::1, 0.0.0.0, …) keeps today's single listener.
  • Shutdown: stop(), and handle.httpServer.close(), release every listener. port stays a single number.
  • Docs: the README and the doc comment now describe the default.

The origin/Host guard already accepted all three loopback spellings. A unit test now pins that.

Tests

test/mcp-http-loopback.test.ts (new) uses ephemeral ports. The IPv6 cases skip only when the host has no IPv6 loopback. It covers:

  • The default host serves both families on one port.
  • All loopback Host/Origin headers are accepted over each family.
  • An explicit IPv4 host, via the option or QMD_HOST, does not serve ::1.
  • An IPv4 or IPv6 collision rejects and leaves nothing bound.
  • stop() and close() release both families.

Results:

  • RED: against the unchanged src/mcp/server.ts: 6 failed / 4 passed. IPv4 is refused.
  • GREEN:
    • vitest: test/mcp-http-loopback.test.ts + test/mcp-origin-guard.test.ts, 46 passed.
    • bun test on the same files: 46 pass, 0 fail.
  • Checks: test:types and lint are clean.

Found while maintaining a downstream fork.

startMcpHttpServer defaulted host to "localhost" and called
httpServer.listen(port, "localhost"), which binds only the first address
localhost resolves to. On typical Linux hosts that is ::1, so clients
using http://127.0.0.1:<port> (or resolving localhost IPv4-first) got
ECONNREFUSED while the daemon was healthy.

For the default host, bind 127.0.0.1 first and then ::1 on the same port
(including the actual port when 0 is requested). A missing address
family (EADDRNOTAVAIL/EAFNOSUPPORT) is tolerated when the other bound;
any other error, e.g. EADDRINUSE, closes whatever bound and rejects. An
explicit host (--host / QMD_HOST) keeps the single listener. The handle's
stop() and httpServer.close() release every listener.

Found while maintaining a downstream fork.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012FnLMoqMCftiGsbTeTWEDx
yotamleo added a commit to yotamleo/Himmel that referenced this pull request Sep 14, 2026
…mleo fork (#761)

## Why

HIMMEL-2882 found upstream `tobi/qmd` had merged both of the local
`yotamleo/qmd` fork's carried bugfixes (Windows index-name path
normalization, a REST JSON.parse guard) under its own SHAs
(`2d5806fb`, `4fde5662`, re-authored by the fork maintainer directly
against
upstream), collapsing the fork's delta to empty. `_qmd_fork_ref` already
pinned `04e4dbd8245c527a88f1a8f0bda547aef9ca81fb`, which **is** upstream
`tobi/qmd` main — the fork only mirrored it on branch
`himmel-main-upstream-2026-09-13`. This PR drops the fork and points the
install directly at `tobi/qmd`, at the **same unchanged pinned SHA**
(HIMMEL-3041, open upstream as tobi/qmd#950, owns any future pin move).

## What changed

- `scripts/lib/qmd-bin.sh`: `_qmd_fork_repo()` default →
  `https://github.com/tobi/qmd.git` (was `yotamleo/qmd.git`). A new
`_qmd_is_legacy_fork_origin()` helper recognizes the retired fork origin
in
its exact, no-`.git`, and trailing-slash https forms (the SSH form is a
  different string and stays refused, same as any other unowned remote).
  `qmd_install()`'s origin-mismatch guard now migrates via
  `git remote set-url` instead of refusing when it sees a legacy origin.
`QMD_FORK_REPO`/`_REF`/`_DIR` names and the on-disk `qmd-fork` dir name
are
  unchanged (renaming was out of scope). Header comments rewritten.
- `scripts/lib/test-qmd-bin.sh`: RED→GREEN for the migration path — new
  assertions cover the exact legacy URL, its no-`.git` form, a trailing
slash (all migrate), and the SSH form (still refused, dir untouched).
The
  stub harness's default origin answer repoints to `tobi/qmd`.
- `scripts/upstreams.json`: qmd's `fork` sub-object is dropped entirely
  (mirrors graphify's HIMMEL-1048 de-fork precedent); `kind`/`mode`/
  `tracked_repo`/`synced_base`/`tier` are unchanged. No `version_pin` is
  added — the pin is a full commit SHA, not a version literal, so
  `apply-drift-bump.sh` correctly keeps reporting SKIP.
- `scripts/test-check-plugin-drift.sh`: qmd's fork-field assertions
replaced
with `assert 'fork' not in qmd` (same shape as the existing
claude-obsidian
  assertion); comments/summary updated.
- `scripts/check-plugin-drift.sh`, `scripts/upstreams/resync-fork.sh`,
`.claude/commands/fork-resync.md`: stale header comments / worked
examples
corrected — qmd is no longer a live fork-block entry, so (as of this PR)
**zero** registry entries carry a `fork` block; the `/fork-resync`
mechanism
  itself is untouched and stays ready for a future fork.
- `scripts/upstreams/test-apply-drift-bump.sh`: one stale assertion
message
  ("fork SHA pin" → "SHA pin, no version_pin").
- Docs/comments only, no behavior change: `docs/tooling-catalog.md`,
  `docs/setup/new-machine.md`, `marketplace/plugins/qmd/README.md`,
`scripts/setup.ps1`, `scripts/adopt.ps1` (both `.ps1` files delegate the
  actual clone to `bash scripts/lib/qmd-bin.sh install` — no `.ps1` has
  behavior tied to the fork URL, only wording).

**Confirmed NOT touched (checked, not fork references):**
`scripts/himmel-doctor.sh:380` and `scripts/luna-upgrade-all.sh:147` /
`docs/internals/environment-gotchas.md:1104` all reference the unrelated
`himmel-main-ref` / private-RC install-path guard, not the qmd fork.

## RED → GREEN

`scripts/lib/test-qmd-bin.sh`, RED (stub default origin repointed to
`tobi/qmd` before the code changed):
```
pass=151 fail=36
```
(2 targeted new/changed assertions failing as expected, plus ~34
cascading
failures in existing owned-dir happy-path tests whose fixtures
implicitly
relied on the old `yotamleo/qmd` default — expected, since the code's
default hadn't moved yet.)

GREEN after implementing the default-repo switch + migration branch:
```
[test-qmd-bin] pass=187 fail=0
```

## Suite pass table (every suite referencing a changed file, foreground)

| Suite | Result |
|---|---|
| `scripts/lib/test-qmd-bin.sh` | 187 passed, 0 failed |
| `scripts/test-check-plugin-drift.sh` | all checks passed |
| `scripts/test-himmel-update-axis-b.sh` | 155 passed, 0 failed |
| `scripts/upstreams/test-apply-drift-bump.sh` | all checks passed |
| `scripts/upstreams/test-apply-tool-upgrade.sh` | all checks passed |
| `scripts/upstreams/test-drift-fix-cadence.sh` | all checks passed |
| `scripts/upstreams/test-resync-fork.sh` | all checks passed |
| `scripts/machine-setup/test-upgrade-rtk.sh` | 46 passed, 0 failed |

(Enumerated via `git grep -l upstreams.json` under `scripts/` — every
test
suite among its consumers, not just the two directly touched.)

## Real-install migration proof (scratch HOME, never the live install)

```
$ HOME=<scratch> git clone --depth 1 https://github.com/yotamleo/qmd.git <scratch>/.himmel/qmd-fork
origin: https://github.com/yotamleo/qmd.git
HEAD:   dbfd0b4736aeaf761d1a16ca8e424f071df8feb9   # fork's old tip

$ HOME=<scratch> bash -c 'source scripts/lib/qmd-bin.sh; qmd_install'
  ... origin is the retired himmel qmd fork ('https://github.com/yotamleo/qmd.git')
      - migrating to https://github.com/tobi/qmd.git (HIMMEL-3045).
  ... qmd fork installed and verified (qmd 2.8.3 (04e4dbd)).
INSTALL_RC=0

# after:
origin: https://github.com/tobi/qmd.git
HEAD:   04e4dbd8245c527a88f1a8f0bda547aef9ca81fb   # matches the unchanged pin
```

The live `$HOME/.himmel/qmd-fork` install and the running qmd daemon
were
never touched — this used a throwaway `HOME` env override only.

leg-burn: calls=127 avg-ctx=129.7k first-turn=73.3k compactions=2
cost-eq=2454.2k

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_012mUdiMH5TPrQh58qnY2PPn

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **Documentation**
- Updated setup, installation, and synchronization guidance to use the
upstream `qmd` project pinned to an immutable commit.
- Clarified fork auditing, drift handling, migration procedures, and
future synchronization scenarios.

- **Maintenance**
- Existing installations using the recognized legacy fork URL can
migrate to the upstream source automatically.
- Unrecognized repository origins remain protected and stop installation
rather than being modified.

- **Tests**
- Added coverage for supported legacy-origin migration and rejection of
unsupported SSH origins.
- Updated validation to reflect the current de-forked `qmd`
configuration.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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