Skip to content

fix(cli): keep dotted tool names in a call selector - #333

Open
Yigtwxx wants to merge 3 commits into
openclaw:mainfrom
Yigtwxx:fix/cli-selector-parts
Open

fix(cli): keep dotted tool names in a call selector#333
Yigtwxx wants to merge 3 commits into
openclaw:mainfrom
Yigtwxx:fix/cli-selector-parts

Conversation

@Yigtwxx

@Yigtwxx Yigtwxx commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Summary

mcporter call <server>.<tool> truncates the tool name at the second dot, so a tool whose name contains a dot is never reachable through the configured-server path. Tool names like browser.navigate are common.

Root cause

resolveCallTarget splits the selector with a limit of two:

// src/cli/call-command.ts:311
if (selector && !server && selector.includes('.')) {
  const [left, right] = selector.split('.', 2);
  server = left;
  tool = right;
}

'proof.browser.navigate'.split('.', 2) is ['proof', 'browser'], so the trailing segment is dropped.

The module already resolves the same string correctly three other ways, and the correct helper sits nineteen lines below the defect in the same file:

// src/cli/call-command.ts:330 - used only by the ad-hoc HTTP branch at :138
function splitServerToolSelector(selector: string): { server: string; tool: string } | undefined {
  const dotIndex = selector.indexOf('.');
  if (dotIndex <= 0 || dotIndex === selector.length - 1) {
    return undefined;
  }
  return { server: selector.slice(0, dotIndex), tool: selector.slice(dotIndex + 1) };
}

src/cli/call-expression-parser.ts:95 rejoins the remainder, and src/cli/list-command.ts:552 matches the longest configured server name first, so mcporter list resolves the selector that mcporter call cannot.

Fix

Route the configured-server branch through the existing helper. A selector that starts or ends with a dot still produces the same Missing server name. / Missing tool name. errors as before, since the helper returns undefined for both and the fallback keeps the current split point.

Adjacent finding: the HTTP tool selector drops the query

Second and third commits, drop them if you would rather not take them - four lines in http-utils.ts plus four test cases.

splitHttpToolSelector rebuilds the server URL by concatenating url.origin with a hand-built path, so everything after the path is discarded:

// src/cli/http-utils.ts:63
const basePath = `${pathname.slice(0, Math.max(0, lastSlash + 1))}${baseSegment}`;
const normalizedPath = basePath.startsWith('/') ? basePath : `/${basePath}`;
const baseUrl = `${url.origin}${normalizedPath}`;

normalizeHttpUrl, eight lines below in the same module, round-trips through URL and keeps the query - that was the change in #325. This function was not part of it, so the two now answer differently for one input:

splitHttpToolSelector('https://example.com/a/mcp?tenant=b')   ->  baseUrl 'https://example.com/a/mcp'
normalizeHttpUrl('https://example.com/a/mcp?tenant=b')        ->  'https://example.com/a/mcp?tenant=b'

The consequence is not cosmetic: the connection is opened against a different endpoint than the user typed, and because findServerByHttpUrl compares through normalizeHttpUrl, a configured server whose URL carries a query no longer matches the stripped target, so the CLI silently falls through to an ad-hoc server.

Behavior proof

Dotted tool name. A stdio fixture advertising one tool literally named browser.navigate:

before  $ mcporter call proof.browser.navigate --url "https://example.com"
        [mcporter] Unable to validate flag '--url' because proof.browser did not provide
        usable tool metadata with an input schema.

after   $ mcporter call proof.browser.navigate --url "https://example.com"
        called browser.navigate with {"url":"https://example.com"}

HTTP selector query. A local HTTP server that logs the request line and answers 404, so the only thing being measured is where the request went. Same port, same command, both revisions:

$ mcporter call "http://127.0.0.1:8932/mcp.navigate?tenant=b" url=x --allow-http

before (main @ ae3d900)     after (this branch)
[server] POST /mcp          [server] POST /mcp?tenant=b
[server] POST /mcp          [server] POST /mcp?tenant=b
[server] GET  /mcp          [server] GET  /mcp?tenant=b

Tests

Three new cases, all red against unpatched main:

tests/cli-call-execution.test.ts
  x keeps every dot after the server name in a tool selector
tests/http-utils.test.ts
  x keeps the query on the server URL and drops the fragment
  x lets a configured server with a query still match the selector

Tests  3 failed | 24 passed (27)

Two further cases pass on both revisions and are there to pin behaviour rather than change it: keeps the port and the encoded path segments, and lets a configured server without a fragment still match a fragment selector.

The fragment half of the third commit came out of review: keeping url.hash would have been a new way to miss a configured server, since findServerByHttpUrl normalizes both sides and a fragment is never sent on the wire. Measured before changing it:

splitHttpToolSelector('https://example.com/mcp.tool#frag').baseUrl
  -> 'https://example.com/mcp#frag'
normalizeHttpUrl(that)                       -> 'https://example.com/mcp#frag'
normalizeHttpUrl('https://example.com/mcp')  -> 'https://example.com/mcp'
match -> false

Gates

Local runtime is Node 22.20.0 while the repo asks for >=24, so three suites fail before this branch as well: tests/chrome-devtools-relay-handoff.test.ts, tests/runtime-chrome-relay-handoff.test.ts and tests/oauth-refresh-process.integration.test.ts.

Running those three on their own and diffing the failing test names between main and this branch leaves a single difference, redeems a rotating refresh token exactly once across concurrent processes (provider path), which is flaky here: it failed on main in an earlier run of the same three files and passed on main in the run used for the diff. Every other failing name is identical on both revisions.

pnpm test                     3 files fail on both revisions (8-9 tests, varies by run)
pnpm lint:oxlint              clean
pnpm typecheck                clean for the changed files
                              (tests/oauth-session.test.ts reports 2 errors on main too)
oxfmt --check <changed>       clean
pnpm exec vitest run tests/http-utils.test.ts tests/cli-call-execution.test.ts
                              27 passed

Also ran the nine call-related suites plus every consumer of splitHttpToolSelector / extractHttpServerTarget (command-inference, generate/definition, generate/flags, emit-ts-command, list-command): 104 and 45 passed respectively.

Production delta is +10/-6 across two files.

resolveCallTarget split the selector with `split('.', 2)`, so everything after
the second dot was discarded and `mcporter call <server>.<a>.<b>` dispatched to
the tool named `<a>`. Tool names containing dots are common, and the module
already resolves them correctly three other ways: the ad-hoc HTTP branch uses
splitServerToolSelector, the call-expression parser rejoins the remainder, and
list matches the configured server name first.

Route the configured-server branch through the existing helper so the four
surfaces agree, keeping the current errors for a selector that starts or ends
with a dot.
…ector

splitHttpToolSelector rebuilt the server URL from `url.origin` plus a hand-built
path, so `https://host/mcp.tool?tenant=b` resolved to a different endpoint than
the user typed. The connection then targets the wrong tenant, and the stripped
URL no longer matches a configured server whose URL carries a query, so the CLI
falls through to an ad-hoc server.

normalizeHttpUrl in the same module already round-trips through URL; do the same
here so both functions answer with the URL they were given.
@clawsweeper

clawsweeper Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

🦞👀
ClawSweeper picked this up.

Pull request received. I will update this pull request when review starts.

@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: f2a11a2558

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/cli/http-utils.ts
// Serialize through URL rather than concatenating the origin, so the query and fragment the
// user typed reach the server instead of being dropped from the connection target.
url.pathname = basePath.startsWith('/') ? basePath : `/${basePath}`;
return { baseUrl: url.href, tool };

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 Strip fragments before matching HTTP server selectors

When a quoted selector contains a fragment (for example, https://example.com/mcp.tool#frag) but the configured server uses the normal fragment-free URL, returning url.href makes findServerByHttpUrl compare https://example.com/mcp#frag against https://example.com/mcp and miss the configured definition. prepareEphemeralServerTarget then registers an ad-hoc server, dropping configured headers, OAuth settings, and other options. Since URL fragments are never sent in HTTP requests and cannot identify a different MCP endpoint, preserve the query but clear url.hash before returning the base URL.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in ea240b5.

I measured the case you describe before changing anything:

splitHttpToolSelector('https://example.com/mcp.tool#frag').baseUrl
  -> 'https://example.com/mcp#frag'
normalizeHttpUrl(that)                  -> 'https://example.com/mcp#frag'
normalizeHttpUrl('https://example.com/mcp')  -> 'https://example.com/mcp'
match -> false

So the fragment did exactly what you said: it only affected comparison, and the configured definition was missed. The commit keeps url.search and clears url.hash, and the test now asserts both halves - the query survives, and a fragment selector still matches a fragment-free configured server.

@Yigtwxx

Yigtwxx commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

The Windows job failed in tests/oauth-refresh-process.integration.test.ts, not in anything this branch touches:

Error: EPERM: operation not permitted, unlink
  'C:\...\wave-refresh-cached-h2uESj\data\mcporter\credentials.json.lock'
    at src/fs-json.ts:124        (the unlink in withLocalLock's finally block)
    at withLocalLock             src/fs-json.ts:152
    at reconcileVaultServerUrl   src/oauth-vault.ts:120
    at VaultPersistence.readSnapshot
    at tests/fixtures/oauth-refresh-process.mjs:43

withLocalLock rethrows anything that is not ENOENT, and on Windows an unlink of a lock file another handle still holds returns EPERM. The run reports 1 failed | 1572 passed; the two files in this branch, src/cli/call-command.ts and src/cli/http-utils.ts, are not on that call path.

The same case is flaky in my local runs too, which is why the PR body called it out: running the three Node-24-sensitive suites on main and on this branch gave 9 failures and 8 failures in one round and the reverse in another, with redeems a rotating refresh token exactly once across concurrent processes (provider path) as the only differing name.

For a control: #332 was opened from the same base commit a few minutes earlier and passed the same build (windows-latest) job.

I do not have rerun rights here. Say the word and I will rebase to trigger a fresh run, or open a separate PR if you would rather have the EPERM retry handled in withLocalLock.

A fragment is never sent on the wire, so keeping it on the resolved base URL
only affected comparison: findServerByHttpUrl normalizes both sides, so a
selector written with a fragment stopped matching a configured server whose URL
has none, and the CLI fell through to an ad-hoc server without its headers or
OAuth settings.

Keep the query, clear the hash.
@clawsweeper clawsweeper Bot added P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Aug 23, 2026
@clawsweeper

clawsweeper Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Thanks for the context here. I swept through the related work, and this is now duplicate or superseded.

Close this PR as superseded by #325.

So I’m closing this here and keeping the remaining discussion on #325.

Review details

Do we have a high-confidence way to reproduce the issue?

Yes—current-main source directly shows both faulty transformations, and the PR supplies focused before/after terminal evidence plus regression cases for each path.

Is this the best way to solve the issue?

Yes—the patch reuses the existing first-dot helper and URL serializer rather than adding a parallel parsing or normalization path.

Security review:

Security review cleared: The four-file diff adds no dependency, workflow, permission, secret-handling, or new code-execution surface; it preserves the user-selected HTTP query and drops the non-transmitted fragment.

AGENTS.md: found and applied where relevant.

What I checked:

  • linked superseding PR: fix(cli): use canonical URL serialization #325 (fix(cli): use canonical URL serialization) is merged at 2026-08-21T07:01:34Z.
  • cluster evidence: the durable review links that PR in the work cluster or recommended risk path.
  • no human follow-up: live comments and timeline hydrated by apply contain no non-automation activity after the ClawSweeper review.

Likely related people:

  • Peter Steinberger: Current-main blame attributes both the configured selector split and shared first-dot helper to the v0.13.6 source tree. (role: original area contributor; confidence: high; commits: e53ef107e4c9; files: src/cli/call-command.ts)
  • Vincent Koc: Authored the merged canonical URL serialization change that this HTTP selector path should now consistently use. (role: adjacent URL-normalization contributor; confidence: high; commits: 8f9318cf94f1; files: src/cli/http-utils.ts, tests/http-utils.test.ts)

Codex review notes: model internal, reasoning high; reviewed against ae3d9000c320.

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

Labels

P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant