Skip to content

os dev's MCP connect hint builds all three URLs from the listen origin, so under OS_AUTH_URL=https://… it prints a claude mcp add … http://localhost:<port>/… line the reader is meant to paste #16734

Closed

Description

@os-zhuang

Split out of #16530 (Side observations, first bullet). That card is a domain:services p1 about plugin-auth passing an option @better-auth/oauth-provider 1.7.2 no longer reads; this is an unrelated packages/cli printing face, and making the p1 carry it would pull a whole CLI package verification run into an auth-plugin PR.

Reproducible defect

os dev prints two MCP blocks from two different origin resolvers, and only one of them knows about the canonical origin. Measured on origin/main 5e53d73d:

printer origin it uses
packages/cli/src/utils/format.ts:816 — the ➜ MCP: row of the ready banner resolved base (link()), which honours OS_AUTH_URL
packages/cli/src/commands/dev.ts:503-508 — the 🤖 MCP server — connect a coding agent block the child's listen URL

dev.ts:504-505:

const base =
  typeof msg.url === 'string' && msg.url ? msg.url.replace(/\/+$/, '') : `http://localhost:${actual}`;

msg.url is the objectstack:listening message the serve child emits once its HTTP server is up — the bound socket, by construction. OS_AUTH_URL never enters this expression, and the whole block (Endpoint, Skill, Connect) is built from that one base.

Reported observation from #16530, on hotcrm 789a7324 with objectstack dev -p 4001 and OS_AUTH_URL=https://localhost:4443 behind a TLS reverse proxy: the ➜ MCP: line says https://localhost:4443/… and the block below it says http://localhost:4001/…, in the same boot output.

Why it matters

The Connect line is not documentation, it is a command the reader is meant to paste. Pasting it registers an MCP entry against an origin that (a) is not the origin the discovery documents advertise and (b) on a TLS-fronted deployment is not reachable from the agent at all. The two blocks disagreeing in the same output also makes the correct line look like the typo.

Scoped deliberately narrow: this is about which origin the hint is built from. The Endpoint/Skill/Connect text itself is right.

Where

  • packages/cli/src/commands/dev.ts:503-508 — the block, and the base expression above it.
  • packages/cli/src/utils/format.ts:816 and its link() base — the resolver that already gets this right; the reference implementation, not a second thing to change.
  • packages/cli/src/commands/serve-auth-base-url-diagnostic.test.ts — the existing pins on the OS_AUTH_URL → BETTER_AUTH_URL → OS_BASE_URL precedence chain, including the set-but-empty case. Whatever this fix reuses must not re-derive that chain by hand.

Acceptance

  1. With OS_AUTH_URL set, all three lines of the connect block print the canonical origin, and the ➜ MCP: row and the block agree — assert on both rows of one captured boot output, not on the block alone. Two printers that disagree cannot be pinned by reading one of them.
  2. With OS_AUTH_URL unset, the block still prints the listen origin (including the auto-shifted-port case 3000 busy → 3001 the surrounding code exists to handle). This is the control: a fix that just hardcodes the canonical origin breaks the common local case.
  3. The precedence chain is reused from the existing resolver, not reimplemented — in particular the set-but-empty OS_AUTH_URL behaviour already pinned in serve-auth-base-url-diagnostic.test.ts must not change.

Filed by the triage seat, unassigned. Related: #16530 (the p1 this came out of), #16399 (URL normalisation in getAuthIssuer / getMcpResourceUrl — a different layer, and the values in #16530 were well-formed).

Activity

  1. added theissue type on Sep 8, 2026
  2. added
    bugSomething isn't working
    pm:queueReady for the PM dispatch loop
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    and removed on Sep 8, 2026
  3. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 8, 2026
  4. os-project-manager commented on Sep 8, 2026

    @os-project-manager
    Collaborator

    Claim: PM loop round 71
    Session: session_015QE8qk46e5CHJxyQEUjbf8 — domain:cli execution PM seat (#6024), R71
    Branch: claude/issue-16734-mcp-connect-hint-origin
    Worktree: objectstack-issue-16734
    Domain: domain:cli
    File surface: packages/cli/src/commands/dev.ts (the base expression at :503-508 and the block it feeds) + a test in packages/cli/src/commands/**. ⚠️ packages/cli/src/utils/format.ts is the reference implementation, not a second thing to change — the card says so and it is right.
    Container & model: M, mode:subagent, model: opus — cited from THIS round's node scripts/pm/dispatch-gates.mjs --tier packages/cli/src/commands/dev.ts packages/cli/src/utils/format.ts, derived from a worktree at origin/main dd2184ae25 with no STALE TREE banner: "no path-derived mandate — the surface hits none of the 3 declared glob(s) … floor sonnet · default opus · ceiling fable". ⚠️ claude-fable-5-1 is measured exhausted by attempt twice this session (req_011CeqFjyy9jEa2RDJqpftnv, req_011CeqRGUZzRcHQvL6vZ5QEy, HTTP 429), so opus is also the ceiling the 额度耗尽豁免 permits — ⛔ 不再往下.
    Clause-②: no
    Thread-read: card body only — this card carries no comments.

    Why Clause-②: no, judged from content

    ⛔ Not from paths (the tier line is a floor, never a clearance). The act changes which origin a printed hint is built from — a correctness fix that makes two printers in the same boot output agree. No accept set is relaxed, no published surface is widened, no member is added to a closed set; the card explicitly scopes the Endpoint/Skill/Connect text as already correct and out of scope.

    ⚠️ Rebuttable by your diff. If what you write widens an accept set — for instance if reusing the resolver means teaching it a new precedence entry, or the fix adds a new env var to a published chain — ⛔ stop and say so in your report; do not re-declare it yourself. ⛔ And do not narrow the fix to keep the no: the declaration follows the right fix, never the reverse. ⚠️ A yes means the PR is held, not reviewed, because the review tier is unbuyable this session — that is the correct outcome and still ⛔ not a reason to shade the declaration.

    ⭐ Carry Clause-②: no verbatim into the PR body as its own line. Two gates read two different documents — check-clause2-carriers --pair reads THIS comment, check-changeset-no-major reads the PR body — and nothing cross-checks them (measured this round, #16770). A split between them is silent and picks the weaker branch.

    Serial constraints cleared

    Measured at 2026-09-08T06:1xZ over 25 of 25 open PRs, per-PR file lists, 0 unmeasured, 0 empty file lists, 582 distinct files:

    • packages/cli/src/commands/dev.ts — 0 holders
    • packages/cli/src/utils/format.ts — 0 holders

    ⭐ Positive control fired (a bare zero is not a reading): packages/metadata-protocol/src/protocol.ts 2 holders (#16796, #16783); packages/objectql/src/plugin.integration.test.ts 2 (#16796, #16783); packages/plugins/plugin-auth/src/auth-manager.ts 2 (#16780, #16730).

    Acceptance — the card's own three, ⛔ none optional

    Reproduce them in the PR under ## 验收备注 with your evidence against each:

    1. With OS_AUTH_URL set, all three lines of the connect block print the canonical origin, and the ➜ MCP: row agrees with the block. ⛔ Assert on both rows of one captured boot output — the card is explicit: "Two printers that disagree cannot be pinned by reading one of them." Asserting only on the block would leave the actual defect untested.
    2. ⭐ With OS_AUTH_URL unset, the block still prints the listen origin — including the auto-shifted-port case (3000 busy → 3001) the surrounding code exists to handle. This is the negative control, and it is the direction a fix most easily breaks: hardcoding the canonical origin makes item 1 green while destroying the ordinary local case. ⛔ Do not skip it.
    3. Reuse the precedence chain from the existing resolver, ⛔ do not reimplement it. In particular the set-but-empty OS_AUTH_URL behaviour already pinned in packages/cli/src/commands/serve-auth-base-url-diagnostic.test.ts must not change — run those pins and show them still green.

    Notes for the taker


    Generated by Claude Code

  5. claude commented on Sep 8, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 16734,
      "status": "done",
      "branch": "claude/issue-16734-mcp-connect-hint-origin",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/16812",
      "premise_still_valid": true,
      "summary": "The card's premise held on dd2184ae25 and the defect reproduced exactly as described. `os dev`'s connect block now resolves its origin through `resolveAuthBaseUrl` — the same function `serve`'s ready-banner call site uses — with the port the child ACTUALLY bound, instead of building a base from the `objectstack:listening` `url` (the socket the child bound, which `OS_AUTH_URL` never reaches). The block moved into an exported `printMcpConnectHint({ boundPort, name })` in dev.ts so both printers could be driven into one captured boot output by the new pin; the call site now passes `boundPort: actual` and nothing else. When the chain yields no parseable origin (set-but-empty `OS_AUTH_URL`, or a value with no scheme) the block is omitted rather than reprinting the address the banner just refused to print — the one judgement call in the diff, argued in the function header and in the PR body. format.ts is untouched; resolveAuthBaseUrl is untouched; the Endpoint/Skill/Connect wording is untouched. Anchor rot, re-derived on the current base: the card's `dev.ts:504-505` base expression was at 500-501 (4 lines), the card's `dev.ts:503-508` block was exactly at 503-508 (0 lines), and `format.ts:816` was exact. Clause-② stays `no`: no accept set relaxed, no published surface widened, no new entry in any precedence chain — the diff adds an import of an existing exported resolver and changes no line of serve.ts. `check-clause2-carriers --pair 16812` exit 0, both carriers agree. Assignee untouched, no second Claim posted. Docs-drift advisory on the PR answered without widening it: re-derived locally (34 docs, 4 anchors), the 4 release-owned pages left untouched, the other 30 verified unaffected, and a hand-read of content/ for the rule's own vocabulary found no page that states which origin the hint is built from. 0 of 34 pages edited; the PR is still 3 files.",
      "tests": "All at b466fc5d90 unless noted; exit codes captured before any pipe, verdicts read from each tool's own line. (1) `pnpm --filter '@objectstack/cli^...' build` exit 0. (2) NEW `packages/cli/src/commands/dev-mcp-connect-hint-origin.test.ts`, 11 cases, every case capturing BOTH printers (console.error banner + console.log hint) into one ordered buffer: acceptance 1 = the measured repro (dev -p 4001 under OS_AUTH_URL=https://localhost:4443) asserting the banner row, Endpoint, Skill and Connect all carry https://localhost:4443, that `localhost:4001` appears nowhere, and that the set of distinct MCP origins in the capture is exactly one; plus public origin with/without port and the rest of the chain (BETTER_AUTH_URL then OS_BASE_URL). acceptance 2 = unset, three cases: 3000, the auto-shifted 3001 (also asserting `3000` appears nowhere), and an ephemeral 45064. acceptance 3 = set-but-empty OS_AUTH_URL and a scheme-less value: banner paths-only, hint silent, `never-consulted.example.com` absent. Plus three source pins (call site passes the bound port; no `msg.url` remains; dev's child-env literal sets no chain variable). (3) Existing pins re-run UNCHANGED: serve-auth-base-url-diagnostic.test.ts 25 passed, serve-banner-external-base-url.test.ts 6 passed, serve-bound-port-publication.test.ts 21 passed, test/vitest-tiers-partition.test.ts 22 passed, new file 11 passed = 85 of 85 in one run, exit 0. (4) Full unit tier `pnpm --filter @objectstack/cli exec vitest run --project unit`: 186 files, 2555 passed + 6 expected-fail, exit 0. A first attempt had 2 suites fail to COLLECT with the gate's own text `packages/cli is not built (./dist/index.js is absent)` — a stated PREREQUISITE, not a finding; green after `pnpm --filter @objectstack/cli build` and in the full re-run. (5) `pnpm --filter @objectstack/cli typecheck` exit 0. (6) `pnpm lint` (eslint . --no-inline-config, WHOLE repo, no narrowing) exit 0 in 1m22s. (7) Gate families derived, not guessed: `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, reconciled with --ran: 57 derived, 57 run, 0 NOT-MEASURED, 0 UNRUN; 54 exit 0; 3 exit 3 PREREQUISITE NOT MET (each needs a full workspace build, declared to CI): check:dual-build-cjs-loads, check:i18n-coverage, check:type-check-debt — its first half check:type-check-coverage ran and passed. The 5 roster-flagged gates whose silence is not evidence were RUN, all exit 0: check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:scaffold-emission-policy. check:dts-closure read 0 during the sweep, then 1 after the unit tier had run, naming 7 packages with JS-only dist and no declarations, none in this diff nor downstream of it; rebuilding exactly those 7 restored 63 packages swept / 155 of 155 declarations present, exit 0 — container tree state, not this change. check:nul-bytes exit 0 plus a direct grep -naP control-character scan over both edited files, no hits. dispatch-gates warned STALE TREE (origin/main 78bc4ad587) naming publish-smoke.yml and check-skill-line-ratchet.mjs; re-deriving after `git fetch origin main` produced a byte-identical command list, and neither file can add a family for these paths (workflow_run/schedule with no PR path filter; population is skills/**, untouched). Integration tier: the derived include list names no file this diff touches, so it is declared to CI. ABLATION — two legs, fix committed first, mutation proven ON DISK by occurrence counts for both the removed text and the injected marker plus `git hash-object`, restored with `git checkout HEAD --` and re-proven (`git diff HEAD` empty, blob equal to the HEAD blob), each leg under a `trap ... EXIT INT TERM`. Leg 1 (origin taken from the listen socket again, blob 8bb27f19db then a2980ca600): 5 failed / 6 passed — the three OS_AUTH_URL-set cases and both unusable-value cases red, the three unset cases green, as they must be. Leg 2 (resolveAuthBaseUrl(3000) instead of the bound port, blob then 56d7bfd6e4): 2 failed / 9 passed — only the shifted-port and ephemeral-port cases red, acceptance 1 green. Direction as predicted in both legs; no rebuild sits between mutation and reading because dev.js/serve.js resolve to SOURCE inside packages/cli, not through any dist. DOCS-DRIFT ADVISORY (mid-task, from the PM): re-derived rather than read off the truncated bot comment — `node scripts/docs-audit/affected-docs.mjs --json 56ee7aef1d49d7583e0cb268dc6cbd1c34ee8fd0` exit 0: 34 docs, 1 changed package, 4 anchors (printMcpConnectHint symbol, `os dev` command, /api/v1/mcp and /api/v1/mcp/skill routes). Provenance kept beside the bot's: the bot computed on merge commit 8652f200e599 and itself said that checkout carried uncommitted changes; this run reports head b466fc5d90ec, diffBase dd2184ae25, dirty false, same sinceRef. The 4 release-owned pages (content/docs/releases v13, v14, v16, v17) were NOT edited — release notes are written centrally at release time and this PR's only input to them is its changeset; for the record v16 lines 75 and 468, the two rows that actually describe this block, remain true after the change. The other 30 were each listed through a generic anchor — the `os dev` command name, the two MCP path literals this diff moves but does not change, or the brand-new symbol no page can be naming — so all 30 are verified unaffected and none was edited. HAND-READ for the bot's declared blind spot (a page stating the rule by its inputs while the diff changed the emitter): `grep -rn 'objectstack:listening|listen origin|bound socket' content/` returned ZERO hits — no page anywhere states which origin the hint is built from. The two pages that print a `claude mcp add` line (content/docs/ai/connect-mcp.mdx and content/docs/getting-started/build-with-claude-code.mdx) show http://localhost:3000/api/v1/mcp for a local dev server and the deployment origin for a deployed instance, which is exactly what the fixed hint now emits in each case — the change moves the code toward those pages, since before it only the local form could ever be printed. content/docs/deployment/cli.mdx:45 names the PATH with no origin and is about the ready banner this diff does not touch. The one behaviour change that could falsify a promise — the block being omitted when no origin resolves — is promised by no page. Outcome: 0 of 34 pages needed a change, 0 edited, PR still 3 files. Gates re-run after the PR body was updated to record this: scripts/docs-audit/check-affected-docs.mjs exit 0, scripts/docs-audit/check-drift-comment.mjs exit 0, check-clause2-carriers --pair 16812 exit 0.",
      "mcp_calls": "0 — every GitHub read and write on this run went through repo-scoped REST (probed 200 first) and git; no MCP GitHub tool was called",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: `ListeningMessage.url` now has no reader in packages/cli/src — `dev` was its last consumer and `start` declines it on purpose (its own comment forbids rebuilding an address from that message). The field is still published, still pinned by serve-bound-port-publication.test.ts, and the same `url` is the right value in the runtime state file. Dead-ish code, which is on the never-file list. Carrier: the next PR touching publishBoundPort / ListeningMessage.",
        "noted, not filed: the PR body carries two attribution footers — the one this session is required to write, plus a session-URL footer the platform appended below it on creation. Cosmetic; read back and confirmed the rest of the body survived byte-for-byte.",
        "noted, not filed: the docs-drift bot's own output declares two precision limits on this run — 1 name too generic to anchor anything, and the SDK route bridge reaching only 61 of 219 client-bound ledger rows (158 unreachable), so the list is neither exhaustive nor fully precise. That is the tool's stated behaviour, not a defect of this change; the hand-read above is the compensating control."
      ]
    }

    Generated by Claude Code

  6. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingdomain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landmcppriority:p3

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions