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

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

  3. 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

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

Metadata

Metadata

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions