Skip to content

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

Closed

Description

@huangyiirene

Symptom

The stock showcase boot banner advertises "MCP server — connect a coding agent: Endpoint …/api/v1/mcp", yet both /mcp and /mcp/skill answer 501 "MCP server is not available".

  • Observed: banner promises the MCP surface; /mcp and /mcp/skill both 501.
  • Expected: the MCP surface the banner promises (keyed initialize/tools/list, /mcp/skill 200).

Root cause

serve.ts auto-adds mcp to requires when the MCP server is enabled (packages/cli/src/commands/serve.ts:884), and CAPABILITY_PROVIDERS.mcp declares nameMatch: ['mcp-server', 'MCPServerPlugin', 'mcp'] (serve.ts:427). hasPluginMatching (serve.ts:2327) does substring matching over loaded plugin names:

const hasPluginMatching = (fragments) =>
  plugins.some((p) => {
    const n = String(p?.name ?? '');
    const c = String(p?.constructor?.name ?? '');
    return fragments.some((f) => n.includes(f) || c.includes(f));
  });

The showcase loads com.objectstack.connector.mcp — the outbound MCP client connector — whose name contains the substring mcp. So the resolver concludes the mcp capability is already provided and never loads MCPServerPlugin.

Proven: removing only ConnectorMcpPlugin from a scratch copy makes [MCP] Plugin initialized appear and /mcp/skill answer 200; @objectstack/mcp was resolvable from the CLI the whole time.

The general hazard is a substring match between a provider plugin name and a consumer plugin name: a consumer whose name contains the provider's fragment satisfies the provider's nameMatch, suppressing the real provider. Still present on origin/main (serve.ts:427 and the .includes(f) in hasPluginMatching at 2327-2332).

Reproduction

Boot the stock showcase; curl the advertised endpoint:

curl http://<host>/api/v1/mcp/skill

Expected the MCP surface the banner promises. Actual 501 "MCP server is not available".

Source

Extracted from the QA run #7627 (framework 92f26f7, console 6314e87f).

Activity

  1. added
    bugSomething isn't working
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    on Aug 11, 2026
  2. self-assigned this
    on Aug 12, 2026
  3. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Claim: PM loop round 5 — domain:cli seat.

    Scope: fix the resolver, not the one collision

    The card names the instance (com.objectstack.connector.mcp contains mcp, satisfies CAPABILITY_PROVIDERS.mcp.nameMatch, so MCPServerPlugin never loads) and the class: a substring match between a provider plugin name and a consumer plugin name lets a consumer suppress the provider it consumes. The class is the fix. ⛔ Special-casing the connector's name — an exclusion list, a longer fragment, reordering — leaves the next consumer to rediscover this, and mcp is not the only fragment short enough to collide.

    ⚠️ Both directions, or it is not a fix. Tightening the match must not stop a legitimate provider from being recognised: whatever nameMatch currently satisfies correctly has to keep satisfying. A resolver that fixes this by matching nothing would make every capability load its default provider and look green in exactly this repro. Pin a provider that should match alongside the consumer that should not.

    Pin the consequence

    The acceptance criterion is the card's own repro: on a stock showcase boot, /api/v1/mcp/skill answers 200 (and /mcp keys initialize/tools/list) with ConnectorMcpPlugin still loaded. ⛔ Not "the resolver returns false for this name" — that pins the mechanism and would pass over a build where the plugin still fails to load for some other reason. The dev proved causality by removal (dropping ConnectorMcpPlugin makes MCP work); the fix must make it work without dropping it.

    Context worth having, not in scope

    This is the third MCP-surface defect this shift: #7645 (stdio transport started but deaf — merged) and #7915 (os serve writes its banner to the stdout the stdio transport owns — filed, ungraded). ⛔ Do not fold either in. But if the measurement here turns up something that changes the reading of those, say so in the report.


    Generated by Claude Code

  4. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor
    {
      "issue": 7652,
      "status": "done",
      "branch": "claude/issue-7652-mcp-capability-substring-match",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/7935",
      "premise_still_valid": true,
      "summary": "Verified the premise on current origin/main (3c9a67e) by text, not line number — the card's cites had drifted (auto-add 884->888, CAPABILITY_PROVIDERS.mcp 427->431, hasPluginMatching 2327->2331) but the mechanism was exactly as described. Fixed the CLASS: Serve.providesCapability now compares a loaded plugin's `name` and constructor name against the registry's declared identities by EQUALITY, and every entry declares the provider's real registered plugin id instead of a fragment of it. `nameMatch` renamed to `identities` so the semantic change breaks any caller loudly. No exclusion list for the connector, no lengthened fragment, no reordering. BOTH DIRECTIONS MEASURED, NOT ASSUMED: reading the provider packages showed most 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, and only service-automation/service-analytics/audit/plugin-webhook-outbox/mcp had a live name fragment. The registry now carries the ids those packages actually register (strictly more correct than before), and a drift test imports every provider package, constructs the exported class, and asserts the name it registers is declared — so a rename fails loudly instead of quietly restoring double-loading. Two dead aliases dropped: 'mcp-server' (plugin is com.objectstack.mcp) and 'SharingPlugin' (class is SharingServicePlugin); neither matched anything.",
      "tests": {
        "acceptance_pin": "packages/cli/test/serve-mcp-capability-collision.e2e.test.ts — spawns the shipped bin/run.js, boots an app loading the consumer plugin that never declares `mcp`, asks the card's own question. NOT the resolver's return value.",
        "measured_before_after": {
          "GET /api/v1/mcp/skill": "501 -> 200",
          "POST /api/v1/mcp initialize": "501 -> 200 with real protocolVersion + serverInfo",
          "POST /api/v1/mcp tools/list": "501 -> 200 with a non-empty tool array"
        },
        "reverse_verification": "PASSED — with the substring match restored in hasPluginMatching (fix reverted via patch, tests kept), the new e2e fails 'expected 501 to be 200' on BOTH /mcp/skill and /mcp. The card's exact symptom. Fix re-applied and re-confirmed green.",
        "new_files": [
          "packages/cli/test/serve-capability-identity.test.ts (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; the four real connectors satisfy no capability they merely consume; plus the package-import drift guard and a source-level pin on the connector's own name.",
          "packages/cli/test/serve-mcp-capability-collision.e2e.test.ts (3 tests) — the acceptance pin above."
        ],
        "gates": {
          "packages/cli suite": "PASS — 113 files, 1247 tests",
          "pnpm build (turbo, 71 tasks)": "PASS",
          "dispatch-gates matched: pnpm check:startup-registry-verdict": "PASS",
          "pnpm check:nul-bytes": "PASS",
          "pnpm check:type-check-debt": "PASS — no `•` error line for packages/cli; NO ledger entry raised",
          "standalone typecheck of both new test files": "PASS — zero errors under packages/cli's strict config (its tsconfig excludes test/, so the ratchet does not measure them)",
          "eslint --no-inline-config on all 3 changed files": "PASS"
        },
        "changeset": ".changeset/serve-capability-identity-match.md (@objectstack/cli patch)"
      },
      "sweep_for_same_exposure": {
        "method": "All 26 fragment lists (23 CAPABILITY_PROVIDERS entries + 3 extras + 3 inline hasPluginMatching call sites) tested 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.",
        "realized_collisions": "Exactly one: 'mcp' contained in com.objectstack.connector.mcp — the reported bug.",
        "latent_exposure": "'audit' was the only other single-word fragment, one consumer away from the same fate (any plugin named com.*.audit-* or a class ending in AuditPlugin would have satisfied it). Every class-name fragment was exposed the same way — 'AuditPlugin' is contained in 'MyAuditPlugin' — but no in-repo class collides today.",
        "action": "Reported, not separately special-cased. The uniform equality fix covers all of them; nothing measured justifies widening the change further."
      },
      "open_questions": [
        "Resolved myself, not asked: the e2e declares the consumer plugin in its fixture rather than importing @objectstack/connector-mcp, because that package is NOT a dependency of @objectstack/cli — turbo's `test` dependsOn ^build would never build it, so it would be absent in CI. Importing it would have required adding a devDependency, i.e. touching packages/cli/package.json + the lockfile, outside the declared file surface. The resolver reads exactly two fields off a loaded plugin (name, constructor.name), so a plugin declaring the connector's real identity reproduces the defect with full fidelity; serve-capability-identity.test.ts additionally pins that identity against the connector's actual source so the fixture cannot drift into testing a name nobody registers. If the PM would rather the e2e import the real class, that is a one-line devDependency add.",
        "Resolved myself: renamed the CapabilitySpec field `nameMatch` -> `identities`. Grepped the whole repo — the field is referenced only inside serve.ts (packages/spec owns a separate PLATFORM_CAPABILITY_PROVIDERS with a different shape). A rename makes any out-of-repo consumer of the public static `Serve.CAPABILITY_PROVIDERS` (e.g. cloud's objectos-runtime) fail at compile time rather than silently inherit substring semantics — that seemed the safer failure mode, but it is a visible API change on a public static and worth a reviewer's eye."
      ],
      "out_of_scope_findings": [],
      "file_surface": {
        "declared": "packages/cli/src/commands/serve.ts + its tests",
        "actual": "packages/cli/src/commands/serve.ts, packages/cli/test/serve-capability-identity.test.ts (new), packages/cli/test/serve-mcp-capability-collision.e2e.test.ts (new), .changeset/serve-capability-identity-match.md (new)",
        "breach": "None beyond the changeset, which CLAUDE.md requires of every code PR."
      },
      "sibling_mcp_defects": "#7645 (stdio transport deaf — merged) and #7915 (os serve banner pollutes the stdio transport's stdout — filed) were NOT folded in. Nothing measured here changes how either should be read: both are transport-layer defects occurring AFTER MCPServerPlugin loads, whereas this one prevented it from loading at all. Worth noting for the PM that the three compound — on the stock showcase the plugin never loaded, so the surfaces #7645 and #7915 describe were unreachable there regardless."
    }

    Generated by Claude Code

  5. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    ACCEPTED — PR #7935. Landing on gate-green.

    Reviewed by the domain:cli seat (hotlong, session session_01B3Kurx8qufrDzNjk4rag7V) against the diff. The ruling was fix-the-class, and that is what landed: nameMatch fragments tested with String.includes() become identities compared by equality, and each entry declares the provider's real registered plugin id. ⛔ No exclusion list, no lengthened fragment, no load-order luck — a plugin either is the provider or it is not.

    The comment states the general hazard in the form that will actually help the next reader: a plugin that CONSUMES a capability is conventionally named after the capability it consumes, so substring matching cannot tell a provider from its consumer — and mcp was not the only exposure (audit was one consumer away, and every class-name fragment was satisfied by any class merely ending in it, e.g. MyAuditPlugin for AuditPlugin).

    The measurement found something worth more than the fix

    Tightening the comparison could have broken legitimate providers, so the identities were measured against the provider packages instead of assumed — and that measurement found the registry was already substantially broken:

    • service-cache never matched com.objectstack.service.cache — dash versus dot. It has presumably never matched.
    • Eighteen of twenty-three entries were carried entirely by their class name.

    So the "provider already supplied?" check had been running on one working limb for an unknown length of time, and nobody could see it because class-name containment quietly covered for every dead id. That is the kind of thing only a both-directions measurement surfaces; a fix that just special-cased the connector would have left all of it in place.

    Both directions are pinned, and the vacuity traps are closed

    The test file states the trap explicitly — "a resolver that matches nothing would make every capability load its default provider and look green in the repro" — and pins the negative half accordingly: every registered provider must still satisfy its own capability, by name and by class, across the whole CAPABILITY_PROVIDERS table, plus a coverage assertion so the expectation table cannot silently fall behind the registry.

    Near-misses that substring matching used to accept are pinned as rejections (com.objectstack.mcp-inspector, com.acme.mcp, FakeMCPServerPlugin), as are the degenerate inputs (empty identity list, null-prototype plugin, nameless plugin).

    Two guards I did not ask for and would have wanted

    1. A drift guard for the new brittleness. Exact ids are precise but rename-fragile, and a provider renaming itself would silently return the resolver to double-loading. The new test imports each provider package, constructs it, and asserts the name it registers is one the registry declares — so the fix cannot rot the way the thing it replaced did.
    2. The fixture's consumer identity is pinned against the real connector source. The e2e declares the consumer inline rather than importing @objectstack/connector-mcp (not a cli dependency, so turbo run test's ^build never builds it), which creates an obvious drift risk — the fixture could end up testing a name nobody registers. They closed it by reading connector-mcp-plugin.ts and asserting name = 'com.objectstack.connector.mcp'. That is the failure mode of their own workaround, found and fenced.

    The acceptance is the real repro, not the mechanism

    The e2e boots the shipped bin/run.js with the consumer plugin loaded and asserts GET /api/v1/mcp/skill → 200 and POST /api/v1/mcp → real JSON-RPC results for initialize and tools/list — the endpoints the card names, with a minted osk_ key because /mcp is authenticated. A providesCapability(...) === false assertion would have stayed green on a build where MCPServerPlugin fails to load for an unrelated reason; the test file says exactly that and rejects it as the criterion. Reverse-verified: restore the substring match and /mcp/skill returns to 501.

    It also carries a vacuity check — "the boot really did load the consumer plugin — otherwise this file proves nothing" — because a fixture that stopped loading the connector would pass for the wrong reason.

    Scope

    4 files: packages/cli/src/commands/serve.ts, two new tests under packages/cli/test/, and the changeset. No docs/adr/**, no skills paths, no content/docs/releases/.

    ⛔ Not flipped yet: ESLint and TypeScript Type Check must both read conclusion: success on the head commit. ⚠️ Note packages/cli's tsconfig excludes test/, so the ratchet may not cover the two new test files — if the job trips anyway, the fix is to type them, ⛔ never to raise the ledger.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions