Skip to content

fix(cli): capability resolver matches provider identities, not name fragments (#7652) - #7935

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-7652-mcp-capability-substring-match
Aug 12, 2026
Merged

fix(cli): capability resolver matches provider identities, not name fragments (#7652)#7935
hotlong merged 1 commit into
mainfrom
claude/issue-7652-mcp-capability-substring-match

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#7652

The class of bug, not the collision

os serve auto-adds mcp to requires, then skips loading a provider when the app already supplies one. That "already supplied?" check compared each provider's nameMatch fragments against loaded plugin names with String.includes():

returnfragments.some((f)=>n.includes(f)||c.includes(f));

A plugin that consumes a capability is conventionally named after the capability it consumes. Substring matching cannot tell the two apart, so a consumer reliably satisfies its own provider's fragment and suppresses the provider it depends on.

The stock showcase hit exactly that: it loads com.objectstack.connector.mcp — the outbound MCP client connector — 'mcp' is a substring of that name, so MCPServerPlugin never loaded and /api/v1/mcp and /api/v1/mcp/skill answered 501 "MCP server is not available" under a boot banner advertising the endpoint.

Premise verified on current origin/main (3c9a67e). The card's line numbers had drifted (auto-add now :888, CAPABILITY_PROVIDERS.mcp:431, hasPluginMatching:2331) but the mechanism was exactly as described.

The fix

Serve.providesCapability compares a plugin's name and its constructor name against the registry's declared identities by equality, and every entry declares the provider's real registered plugin id (com.objectstack.mcp) rather than a fragment of it. nameMatch is renamed to identities so the semantic change can't be missed by a caller.

No exclusion list for the connector, no lengthened fragment, no reordering so the provider wins by luck. A plugin either is the provider or it is not.

Both directions — measured, not assumed

A resolver that "fixes" this by matching nothing would make every capability load its default provider and look green in this exact repro. So the identities were read out of the provider packages rather than guessed. That measurement turned up something worth stating:

Most of the old name fragments were already dead.service-cache never matched com.objectstack.service.cache (dash vs dot), plugin-email never matched com.objectstack.service.email, trigger-record-change never matched com.objectstack.trigger.record-change. 18 of 23 entries were carried entirely by their class name; only service-automation, service-analytics, audit, plugin-webhook-outbox and mcp had a live name fragment. The registry now carries the ids those packages actually register, which is strictly more correct than before.

Two dead aliases were dropped as part of that: 'mcp-server' (the plugin is com.objectstack.mcp) and 'SharingPlugin' (the class is SharingServicePlugin). Neither matched anything.

A drift test imports every provider package, constructs the exported class, and asserts the name it registers is one the registry declares — so a provider renaming itself fails loudly instead of quietly returning the resolver to double-loading.

Sweep for the same exposure elsewhere

Scanned all 26 fragment lists (including the three inline hasPluginMatching call sites) against a corpus of 59 plugin names and 64 plugin classes in this repo, asking which fragments were satisfiable by a plugin that is not the provider:

FragmentForeign hit
mcpcom.objectstack.connector.mcpthe realized bug
everything elsenone today

mcp was the only realized collision. audit is the only other single-word fragment and was one consumer away from the same fate — any plugin named com.*.audit-* or a class ending in AuditPlugin would have satisfied it. Class-name fragments were all exposed the same way (MyAuditPlugin contains AuditPlugin); none collide in-repo today. Reported, not separately special-cased — the uniform equality fix covers all of them, and nothing here justifies widening the change further.

Acceptance — the consequence, not the mechanism

packages/cli/test/serve-mcp-capability-collision.e2e.test.ts spawns the shipped bin/run.js, boots an app that loads the consumer plugin and never declares mcp, then asks the card's own question:

beforeafter
GET /api/v1/mcp/skill501200
POST /api/v1/mcpinitialize501200, real protocolVersion + serverInfo
POST /api/v1/mcptools/list501200, non-empty tool array

/mcp is authenticated, so the test mints a real osk_ key through the product route against the serve --dev admin seed (same approach as serve-mcp-stdio-answers.e2e.test.ts).

Reverse-verified: with the substring match restored in hasPluginMatching, this file fails with expected 501 to be 200 on both routes — the card's exact symptom.

The consumer is declared in the fixture rather than imported because @objectstack/connector-mcp is not a dependency of @objectstack/cli, so turbo run test's ^build never builds it and it would be absent in CI. The resolver reads exactly two fields off a loaded plugin, so a plugin declaring the connector's real identity reproduces the defect with full fidelity — and serve-capability-identity.test.ts pins that identity against the connector's actual source, so the fixture cannot drift into testing a name nobody registers.

Tests

  • packages/cli/test/serve-capability-identity.test.ts (new, 53 tests) — both directions: the consumer must not satisfy mcp; every registered provider must still satisfy its own capability by name and by class; near-misses substring matching used to accept are rejected; connectors satisfy no capability they merely consume; plus the package drift guard.
  • packages/cli/test/serve-mcp-capability-collision.e2e.test.ts (new, 3 tests) — the acceptance pin above.

Gates run

  • packages/cli suite — 113 files, 1247 tests, all pass
  • node scripts/pm/dispatch-gates.mjs <changed paths>pnpm check:startup-registry-verdict ✅, pnpm check:nul-bytes
  • pnpm build (turbo, 71 tasks) ✅
  • pnpm check:type-check-debt ✅ — no error line for packages/cli; no ledger entry raised
  • packages/cli's tsconfig excludes test/, so both new test files were type-checked standalone under the same strict config — zero errors
  • eslint --no-inline-config on all three changed files ✅

Related, deliberately not folded in

#7645 (stdio transport deaf — merged) and #7915 (os serve banner pollutes the stdio transport's stdout — filed). Nothing measured here changes how either should be read: both are transport-layer defects that occur afterMCPServerPlugin loads, whereas this one prevented it from loading at all. Worth noting the three compound — on the stock showcase the plugin never loaded, so #7645's and #7915's surfaces were unreachable there regardless.


Generated by Claude Code

…ragments (#7652)
`os serve` auto-adds `mcp` to `requires`, then skips loading a provider when the
app already supplies one. That check compared each provider's `nameMatch`
fragments against loaded plugin names with `String.includes()` — and a plugin
that CONSUMES a capability is conventionally named after what it consumes. So a
consumer reliably satisfied its own provider's fragment and suppressed it.
The stock showcase hit exactly that: it loads `com.objectstack.connector.mcp`
(the outbound MCP *client* connector), `'mcp'` is a substring of that name, so
`MCPServerPlugin` never loaded and `/api/v1/mcp` and `/api/v1/mcp/skill`
answered 501 under a boot banner advertising the endpoint.
Fix the class, not the collision. `Serve.providesCapability` now compares a
plugin's `name` and constructor name to the declared identities by EQUALITY, and
every registry entry declares the provider's real registered plugin id rather
than a fragment of it. No exclusion list, no lengthened fragment, no load-order
luck.
Both directions were measured, not assumed. Reading the provider packages showed
most name fragments were already dead — `service-cache` never matched
`com.objectstack.service.cache` (dash vs dot), and 18 of 23 entries were carried
entirely by their class name — so the entries now carry the ids those packages
actually register. A drift test imports every provider package and asserts the
name it registers is one the registry declares, so a rename cannot quietly
return the resolver to double-loading.
Acceptance is the card's own repro, not the resolver: a spawned `os serve` with
the consumer plugin loaded answers `GET /api/v1/mcp/skill` 200 and returns real
JSON-RPC results for `initialize` and `tools/list`. Reverse-verified — with the
substring match restored, both go back to 501.
Sweep of the remaining fragments for the same exposure: `mcp` was the only one
with a realized in-repo collision (59 plugin names, 64 plugin classes scanned).
`audit` was the only other single-word fragment, one consumer away from the same
fate. Reported, not separately special-cased — the uniform fix covers both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BTDu3CXAxGiTc75pg9vT8
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 5:53am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli.

17 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via packages/cli)
  • content/docs/api/client-sdk.mdx(via @objectstack/cli)
  • content/docs/api/data-flow.mdx(via @objectstack/cli)
  • content/docs/api/environment-routing.mdx(via @objectstack/cli)
  • content/docs/api/error-catalog.mdx(via @objectstack/cli)
  • content/docs/automation/hook-bodies.mdx(via packages/cli)
  • content/docs/deployment/backup-restore.mdx(via @objectstack/cli)
  • content/docs/deployment/cli.mdx(via @objectstack/cli)
  • content/docs/deployment/self-hosting.mdx(via @objectstack/cli)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/cli)
  • content/docs/kernel/runtime-services/data-service.mdx(via @objectstack/cli)
  • content/docs/kernel/runtime-services/index.mdx(via packages/cli)
  • content/docs/permissions/authentication.mdx(via @objectstack/cli)
  • content/docs/plugins/index.mdx(via @objectstack/cli)
  • content/docs/plugins/packages.mdx(via @objectstack/cli)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/cli)
  • content/docs/protocol/kernel/realtime-protocol.mdx(via @objectstack/cli)

3 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/cli)
  • content/docs/releases/v16.mdx(via @objectstack/cli)
  • content/docs/releases/v17.mdx(via @objectstack/cli)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
@hotlong
hotlong marked this pull request as ready for review August 12, 2026 06:07
@hotlong
hotlong added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit 2daafe1Aug 12, 2026
26 checks passed
@hotlong
hotlong deleted the claude/issue-7652-mcp-capability-substring-match branch August 12, 2026 06:24
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mcp-http-surface (b): the stock showcase cannot serve MCP at all — capability resolver substring-matches the outbound MCP client connector

2 participants

@hotlong@claude