guardrail(SSOT): pin required_tool + loaded_mcp_tools_field in the MCP delivery contract #3258

Merged
devops-engineer merged 3 commits from guardrail/contract-pin-required-tool into main 2026-06-25 06:42:50 +00:00
4 changed files with 65 additions and 25 deletions
+1 -1
View File
@@ -1 +1 @@
{"settings_path":"/configs/.claude/settings.json","key":"mcpServers","entry_shape":"name->{command,args?,env?}","mcp_server_name":"molecule-platform","legacy_binary_path":"/opt/molecule-mcp-server","runtime_present_field":"mcp_server_present","producer":"MCPServerAdaptor","consumer":"claude_sdk_executor._load_settings_mcp","consumers":["claude_sdk_executor._load_settings_mcp","platform_agent_identity.mcp_server_present","workspace-server/internal/handlers/registry.go#RCA2970-online-gate"],"descriptor":"runtime-agnostic name->{command,args?,env?}; the plugin is the SSOT for this descriptor (settings-fragment.json is the Claude adapter's rendering of it). The MCP-wiring PORT (InstallContext.register_mcp_server -> BaseAdapter.register_mcp_server_hook) renders the descriptor into the native config the ACTIVE runtime reads.","port":{"hook":"InstallContext.register_mcp_server","impl":"BaseAdapter.register_mcp_server_hook","present_probe":"BaseAdapter.management_mcp_present","dispatch":"BaseAdapter default hook dispatches on self.name() via mcp_render.render_for_runtime/mcp_settings_path_for/management_mcp_present_for; per-template adapter override NOT required.","resolver_default":"plugins_registry.resolve defaults an mcpServers-shaped plugin to MCPServerAdaptor (AdaptorSource.MCP_SERVER) for ANY runtime, so molecule-platform-mcp needs no per-runtime adapters/<runtime>.py."},"runtimes":{"claude_code":{"settings_path":"/configs/.claude/settings.json","format":"json","key":"mcpServers","renderer":"mcp_render.render_claude_settings","status":"implemented"},"codex":{"settings_path":"~/.codex/config.toml","format":"toml","table":"mcp_servers","renderer":"mcp_render.render_codex_config","status":"implemented"},"gemini_cli":{"settings_path":"~/.gemini/settings.json","format":"json","key":"mcpServers","renderer":"mcp_render.render_gemini_settings","status":"todo-unverified"},"hermes":{"settings_path":"unverified","format":"unverified","renderer":"mcp_render.render_hermes_config","status":"todo-unverified"}}}
{"settings_path":"/configs/.claude/settings.json","key":"mcpServers","entry_shape":"name->{command,args?,env?}","mcp_server_name":"molecule-platform","required_tool":"create_workspace","loaded_mcp_tools_field":"loaded_mcp_tools","legacy_binary_path":"/opt/molecule-mcp-server","runtime_present_field":"mcp_server_present","producer":"MCPServerAdaptor","consumer":"claude_sdk_executor._load_settings_mcp","consumers":["claude_sdk_executor._load_settings_mcp","platform_agent_identity.mcp_server_present","workspace-server/internal/handlers/registry.go#RCA2970-online-gate"],"descriptor":"runtime-agnostic name->{command,args?,env?}; the plugin is the SSOT for this descriptor (settings-fragment.json is the Claude adapter's rendering of it). The MCP-wiring PORT (InstallContext.register_mcp_server -> BaseAdapter.register_mcp_server_hook) renders the descriptor into the native config the ACTIVE runtime reads.","port":{"hook":"InstallContext.register_mcp_server","impl":"BaseAdapter.register_mcp_server_hook","present_probe":"BaseAdapter.management_mcp_present","dispatch":"BaseAdapter default hook dispatches on self.name() via mcp_render.render_for_runtime/mcp_settings_path_for/management_mcp_present_for; per-template adapter override NOT required.","resolver_default":"plugins_registry.resolve defaults an mcpServers-shaped plugin to MCPServerAdaptor (AdaptorSource.MCP_SERVER) for ANY runtime, so molecule-platform-mcp needs no per-runtime adapters/<runtime>.py."},"runtimes":{"claude_code":{"settings_path":"/configs/.claude/settings.json","format":"json","key":"mcpServers","renderer":"mcp_render.render_claude_settings","status":"implemented"},"codex":{"settings_path":"~/.codex/config.toml","format":"toml","table":"mcp_servers","renderer":"mcp_render.render_codex_config","status":"implemented"},"gemini_cli":{"settings_path":"~/.gemini/settings.json","format":"json","key":"mcpServers","renderer":"mcp_render.render_gemini_settings","status":"todo-unverified"},"hermes":{"settings_path":"unverified","format":"unverified","renderer":"mcp_render.render_hermes_config","status":"todo-unverified"}}}
@@ -32,6 +32,21 @@ type MCPPluginDeliveryContract struct {
// TestSSOT_DegradeGateToolDerivesFromContract.
MCPServerName string `json:"mcp_server_name"`
// RequiredTool is the SSOT for the management MCP's REQUIRED tool VERB (e.g.
// "create_workspace"). Combined with MCPServerName it yields the full
// dispatcher id the online/degraded gate looks for:
// `mcp__<MCPServerName>__<RequiredTool>`. Pinning the verb here (not as a
// hardcoded literal in the gate const or the test) closes the SSOT gap the
// 2026-06-25 audit flagged: previously only the `mcp__<server>__` prefix was
// contract-derived while `create_workspace` was re-spelled in both the Go
// const and the test. See TestSSOT_DegradeGateToolDerivesFromContract.
RequiredTool string `json:"required_tool"`
// LoadedMCPToolsField is the SSOT for the register/heartbeat status-field NAME
// the runtime emits (the loaded MCP tool inventory) and core's #3082 gate
// consumes. Pinned so a rename on either side is caught by the drift gate.
LoadedMCPToolsField string `json:"loaded_mcp_tools_field"`
// Descriptor is the prose SSOT describing the runtime-agnostic MCP descriptor
// shape and the wiring-PORT indirection (register_mcp_server →
// register_mcp_server_hook). It is pinned so a refactor that collapses the
@@ -105,6 +120,8 @@ func (c *MCPPluginDeliveryContract) MatchesSSOT() []string {
eq("producer", c.Producer, "MCPServerAdaptor")
eq("consumer", c.Consumer, "claude_sdk_executor._load_settings_mcp")
eq("mcp_server_name", c.MCPServerName, "molecule-platform")
eq("required_tool", c.RequiredTool, "create_workspace")
eq("loaded_mcp_tools_field", c.LoadedMCPToolsField, "loaded_mcp_tools")
// PORT symbols (#3159). These pin the wiring seam: if the adaptor regresses
// to a hard-coded Claude write, the hook/impl indirection disappears and this
@@ -48,11 +48,26 @@ func TestSSOT_DegradeGateToolDerivesFromContract(t *testing.T) {
if c.MCPServerName == "" {
t.Fatal("contract mcp_server_name is empty — SSOT for the platform MCP tool prefix")
}
want := "mcp__" + c.MCPServerName + "__create_workspace"
if c.RequiredTool == "" {
t.Fatal("contract required_tool is empty — SSOT for the management MCP's required tool verb")
}
// The PRODUCTION gate const is COMPOSED from two building-block consts; pin
// EACH to the contract so the gate (not just this test) can never use a
// server name or verb that diverges from the SSOT.
if conciergePlatformMCPServerName != c.MCPServerName {
t.Errorf("SSOT drift: conciergePlatformMCPServerName = %q, but contract mcp_server_name = %q",
conciergePlatformMCPServerName, c.MCPServerName)
}
if conciergePlatformMCPRequiredTool != c.RequiredTool {
t.Errorf("SSOT drift: conciergePlatformMCPRequiredTool = %q, but contract required_tool = %q",
conciergePlatformMCPRequiredTool, c.RequiredTool)
}
// And the composed full id must equal the contract-derived id (mcp__<server>__<verb>).
want := "mcp__" + c.MCPServerName + "__" + c.RequiredTool
if conciergePlatformMCPCreateWorkspaceTool != want {
t.Errorf("SSOT drift: conciergePlatformMCPCreateWorkspaceTool = %q, but contract mcp_server_name = %q implies %q.\n"+
"The degraded gate must look for the tool id the runtime actually emits (mcp__<server>__create_workspace).",
conciergePlatformMCPCreateWorkspaceTool, c.MCPServerName, want)
t.Errorf("SSOT drift: conciergePlatformMCPCreateWorkspaceTool = %q, but contract implies %q (mcp__%s__%s).\n"+
"The degraded gate must look for the tool id the runtime actually emits (mcp__<server>__<required_tool>).",
conciergePlatformMCPCreateWorkspaceTool, want, c.MCPServerName, c.RequiredTool)
}
}
@@ -555,27 +555,35 @@ const conciergePlatformMCPSource = "gitea://molecule-ai/molecule-ai-plugin-molec
// derivation, not the human label "molecule-platform-mcp".
const conciergePlatformMCPName = "molecule-ai-plugin-molecule-platform-mcp"
// conciergePlatformMCPCreateWorkspaceTool is the literal MCP tool identifier
// the platform concierge must surface for the post-online fail-loud gate
// (core#3082) to consider the management MCP actually loaded. The Claude
// Code dispatcher formats every MCP tool as `mcp__<server>__<tool>`; the
// platform MCP server's install name derives to "molecule-platform" via
// PluginNameFromSource(conciergePlatformMCPSource), so the create_workspace
// tool's namespaced identifier is `mcp__molecule-platform__create_workspace`.
// This is the SAME literal the staging concierge E2E (tests/e2e/test_staging_
// concierge_creates_workspace_e2e.sh:4.5/6) probes for; pinning it as a
// constant keeps the runtime gate and the E2E in lock-step so a drift in
// one breaks both with the same signal.
// The platform concierge must surface mcp__molecule-platform__create_workspace
// for the post-online fail-loud gate (core#3082) to consider the management MCP
// actually loaded. The Claude Code dispatcher formats every MCP tool as
// `mcp__<server>__<tool>`.
//
// Why we don't match the server/plugin NAME here: the heartbeat's
// loaded_mcp_tools list carries TOOL identifiers (mcp__<server>__<tool>),
// not plugin names. The previous check (loadedSet[conciergePlatformMCPName])
// was a no-op false-green — it matched the plugin NAME against a list of
// TOOL strings, which would always be empty for the management MCP (the
// plugin is named "molecule-ai-plugin-molecule-platform-mcp" while its
// tools are namespaced "mcp__molecule-platform__*"). The literal-tool
// match below is the actual contract the runtime must satisfy.
const conciergePlatformMCPCreateWorkspaceTool = "mcp__molecule-platform__create_workspace"
// SSOT (audit 2026-06-25): the gate tool id is COMPOSED from two building blocks,
// each pinned to the shared contract (contracts/mcp-plugin-delivery.contract.json)
// by TestSSOT_DegradeGateToolDerivesFromContract — there is NO standalone
// hardcoded full tool-id and NO independently-spelled verb. The gate and the test
// share the single `mcp__<server>__<verb>` formula and the same contract source.
// (The contract file lives at repo root, OUTSIDE this Go module, so the embed
// directive cannot reach it for a true runtime load; compile-time composition +
// the dual contract-assertion test is the deployment-safe SSOT enforcement — a
// drift on either building block vs the contract fails CI before the gate.)
//
// Note we match the TOOL id, not the plugin/server NAME: the heartbeat's
// loaded_mcp_tools list carries TOOL identifiers (mcp__<server>__<tool>); a name
// match would be a no-op false-green (the plugin is named
// "molecule-ai-plugin-molecule-platform-mcp" while its tools are
// "mcp__molecule-platform__*").
// conciergePlatformMCPServerName == contract.mcp_server_name (the mcp__<server>__
// prefix). conciergePlatformMCPRequiredTool == contract.required_tool (the verb).
const conciergePlatformMCPServerName = "molecule-platform"
const conciergePlatformMCPRequiredTool = "create_workspace"
// conciergePlatformMCPCreateWorkspaceTool is composed from the two contract-pinned
// constants above via the canonical mcp__<server>__<tool> formula.
const conciergePlatformMCPCreateWorkspaceTool = "mcp__" + conciergePlatformMCPServerName + "__" + conciergePlatformMCPRequiredTool
// ensureConciergeProvider pins the concierge's LLM provider to `platform` (core
// companion to ensureConciergeModel). It guarantees the env-level provider pin