feat(mcp): pin MCP-plugin delivery contract (core#3080) — includes prerequisite build fix #3084

Merged
core-devops merged 8 commits from core-3080-mcp-plugin-delivery-contract into main 2026-06-19 21:59:15 +00:00
7 changed files with 430 additions and 3 deletions
+5
View File
@@ -164,6 +164,11 @@ jobs:
# errors on heavy jobs, e.g. test -race link "too many errors" and
# go-arch-lint "without types"). Fleet sweep after the cp ci.yml find.
cache: false
- if: ${{ needs.changes.outputs.platform == 'true' }}
name: Install workspace-runtime wheel for contract tests
working-directory: .
run: |
pip install --index-url "https://agent-dev-a:${{ secrets.AUTO_SYNC_TOKEN }}@git.moleculesai.app/api/packages/molecule-ai/pypi/simple/" molecule-ai-workspace-runtime || true
- if: ${{ needs.changes.outputs.platform == 'true' }}
run: go mod download
- if: ${{ needs.changes.outputs.platform == 'true' }}
@@ -0,0 +1,119 @@
name: mcp-plugin-delivery-contract-drift
# Cross-repo drift gate for the MCP-plugin delivery contract (core#3080).
#
# The contract file contracts/mcp-plugin-delivery.contract.json is the SSOT
# shared between molecule-core (producer side) and
# molecule-ai-workspace-template-claude-code (consumer side). This workflow
# fetches the template's copy via the Gitea raw endpoint and byte-compares it
# against core's local copy. RED if they differ — the two repos must stay
# byte-identical so the drift gate and runtime agree on the same contract.
#
# ENFORCEMENT GATING: standalone workflow, NOT a job in ci.yml and NOT in
# branch protection (soak-then-promote; mirrors sync-providers-yaml.yml).
#
# AUTH: uses AUTO_SYNC_TOKEN (the existing cross-repo read token). If the
# secret is absent on a trusted context the job fails closed; on untrusted
# fork PRs it soft-skips.
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'contracts/mcp-plugin-delivery.contract.json'
- '.gitea/workflows/mcp-plugin-delivery-contract-drift.yml'
push:
branches: [main, staging]
paths:
- 'contracts/mcp-plugin-delivery.contract.json'
- '.gitea/workflows/mcp-plugin-delivery-contract-drift.yml'
schedule:
# Daily at :27 — catch a template-side contract change that landed without
# a paired core re-sync PR (off-zero to spread cron load).
- cron: '27 4 * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: mcp-plugin-delivery-contract-drift-${{ github.ref }}
cancel-in-progress: true
jobs:
# bp-required: pending #3080 — soak-then-promote; standalone drift gate, not in branch protection yet.
compare:
name: Compare MCP plugin delivery contract against template canonical
runs-on: ubuntu-latest
timeout-minutes: 6
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Fetch template contract and byte-compare
env:
AUTO_SYNC_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
API_ROOT: ${{ github.server_url }}/api/v1
run: |
set -euo pipefail
case "${{ github.event_name }}" in
push|schedule|workflow_dispatch)
is_trusted=true
;;
pull_request)
if [ "${{ github.event.pull_request.head.repo.fork }}" = "false" ]; then
is_trusted=true
else
is_trusted=false
fi
;;
*)
is_trusted=true
;;
esac
if [ -z "${AUTO_SYNC_TOKEN:-}" ]; then
if [ "$is_trusted" = "true" ]; then
echo "::error::AUTO_SYNC_TOKEN secret missing on trusted context (${{ github.event_name }}). Live cross-repo contract-drift detection cannot run."
exit 1
fi
echo "::warning::AUTO_SYNC_TOKEN secret missing on untrusted fork PR — skipping live cross-repo compare."
exit 0
fi
REF="${{ github.head_ref || github.ref_name }}"
CANON_URL="${API_ROOT}/repos/molecule-ai/molecule-ai-workspace-template-claude-code/raw/contracts/mcp-plugin-delivery.contract.json?ref=${REF}"
set +e
curl -fsS \
-H "Authorization: token ${AUTO_SYNC_TOKEN}" \
"${CANON_URL}" -o /tmp/canonical-mcp-plugin-delivery.contract.json
curl_status=$?
set -e
if [ "$curl_status" -ne 0 ]; then
if [ "$curl_status" -eq 22 ]; then
echo "::warning::Canonical contract not found on template ref ${REF} (HTTP 404); falling back to main."
CANON_URL="${API_ROOT}/repos/molecule-ai/molecule-ai-workspace-template-claude-code/raw/contracts/mcp-plugin-delivery.contract.json?ref=main"
set +e
curl -fsS \
-H "Authorization: token ${AUTO_SYNC_TOKEN}" \
"${CANON_URL}" -o /tmp/canonical-mcp-plugin-delivery.contract.json
curl_status=$?
set -e
if [ "$curl_status" -eq 22 ]; then
echo "::warning::Canonical contract not found on template main either (HTTP 404) — expected during bootstrap. Skipping drift check."
exit 0
fi
if [ "$curl_status" -ne 0 ]; then
echo "::error::Failed to fetch canonical contract from template main (exit $curl_status)."
exit 1
fi
else
echo "::error::Failed to fetch canonical contract (exit $curl_status)."
exit 1
fi
fi
LOCAL=contracts/mcp-plugin-delivery.contract.json
if diff -u /tmp/canonical-mcp-plugin-delivery.contract.json "$LOCAL"; then
echo "OK — core's contract is byte-identical to the template canonical."
else
echo "::error::core's mcp-plugin-delivery.contract.json DRIFTED from the template canonical."
echo "Re-sync: copy molecule-ai-workspace-template-claude-code contracts/mcp-plugin-delivery.contract.json verbatim over $LOCAL."
exit 1
fi
@@ -0,0 +1 @@
{"settings_path":"/configs/.claude/settings.json","key":"mcpServers","entry_shape":"name->{command,args?,env?}","producer":"MCPServerAdaptor","consumer":"claude_sdk_executor._load_settings_mcp"}
@@ -0,0 +1,40 @@
package handlers
// MCP-plugin delivery contract (core#3080).
//
// This is the producer-side guard for the MCP-plugin delivery contract
// between molecule-core (which ships plugin declarations) and the
// claude-code workspace template (whose runtime writes/reads
// /configs/.claude/settings.json under the mcpServers key).
//
// The contract file is duplicated byte-for-byte in both repos and kept
// honest by a cross-repo drift gate.
import (
"encoding/json"
"os"
)
const mcpPluginDeliveryContractPath = "../../../contracts/mcp-plugin-delivery.contract.json"
// MCPPluginDeliveryContract describes the pinned MCP-plugin delivery surface.
type MCPPluginDeliveryContract struct {
SettingsPath string `json:"settings_path"`
Key string `json:"key"`
EntryShape string `json:"entry_shape"`
Producer string `json:"producer"`
Consumer string `json:"consumer"`
}
// LoadMCPPluginDeliveryContract loads the contract from the repo root.
func LoadMCPPluginDeliveryContract() (*MCPPluginDeliveryContract, error) {
data, err := os.ReadFile(mcpPluginDeliveryContractPath)
if err != nil {
return nil, err
}
var c MCPPluginDeliveryContract
if err := json.Unmarshal(data, &c); err != nil {
return nil, err
}
return &c, nil
}
@@ -0,0 +1,262 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// mergeMCPPluginSettings models the runtime MCPServerAdaptor's write side for
// the contract test: it reads a plugin's settings-fragment.json and merges its
// mcpServers block into the contract settings_path/key. Keeping this logic in
// the test file makes the contract test hermetic and avoids adding dead
// production code; the live pipeline still uses the Python runtime adaptor.
func mergeMCPPluginSettings(configsDir, pluginRoot string, contract *MCPPluginDeliveryContract) error {
data, err := os.ReadFile(filepath.Join(pluginRoot, "settings-fragment.json"))
if err != nil {
return fmt.Errorf("read settings-fragment.json: %w", err)
}
var fragment map[string]any
if err := json.Unmarshal(data, &fragment); err != nil {
return fmt.Errorf("parse settings-fragment.json: %w", err)
}
mcpServers, ok := fragment[contract.Key].(map[string]any)
if !ok {
return fmt.Errorf("settings-fragment.json %q key missing or not an object", contract.Key)
}
if !strings.HasPrefix(contract.SettingsPath, "/configs/") {
return fmt.Errorf("contract settings_path %q does not start with /configs/", contract.SettingsPath)
}
rel := strings.TrimPrefix(contract.SettingsPath, "/configs/")
settingsPath := filepath.Join(configsDir, rel)
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
return fmt.Errorf("mkdir settings dir: %w", err)
}
existing := map[string]any{}
if cur, err := os.ReadFile(settingsPath); err == nil {
_ = json.Unmarshal(cur, &existing)
}
existing[contract.Key] = mcpServers
out, err := json.MarshalIndent(existing, "", " ")
if err != nil {
return fmt.Errorf("marshal settings: %w", err)
}
out = append(out, '\n')
if err := os.WriteFile(settingsPath, out, 0o644); err != nil {
return fmt.Errorf("write settings: %w", err)
}
return nil
}
// TestMCPPluginDeliveryContract_MatchesSSOT pins the producer side of the
// MCP-plugin delivery contract. The contract file is the SSOT shared with
// molecule-ai-workspace-template-claude-code; any change to the pinned path,
// key, producer, or consumer must be deliberate and synchronized.
func TestMCPPluginDeliveryContract_MatchesSSOT(t *testing.T) {
c, err := LoadMCPPluginDeliveryContract()
if err != nil {
t.Fatalf("load contract: %v", err)
}
if c.SettingsPath != "/configs/.claude/settings.json" {
t.Errorf("settings_path = %q, want /configs/.claude/settings.json", c.SettingsPath)
}
if c.Key != "mcpServers" {
t.Errorf("key = %q, want mcpServers", c.Key)
}
if c.EntryShape != "name->{command,args?,env?}" {
t.Errorf("entry_shape = %q, want name->{command,args?,env?}", c.EntryShape)
}
if c.Producer != "MCPServerAdaptor" {
t.Errorf("producer = %q, want MCPServerAdaptor", c.Producer)
}
if c.Consumer != "claude_sdk_executor._load_settings_mcp" {
t.Errorf("consumer = %q, want claude_sdk_executor._load_settings_mcp", c.Consumer)
}
}
// TestMCPPluginDeliveryContract_LoadableFromRepoRoot guards against a moved
// or missing contract file, which would silently break the cross-repo drift
// gate and any code that loads the contract at runtime.
func TestMCPPluginDeliveryContract_LoadableFromRepoRoot(t *testing.T) {
if _, err := LoadMCPPluginDeliveryContract(); err != nil {
t.Fatalf("contract must be loadable from repo root: %v", err)
}
}
// TestMCPPluginDeliveryContract_MCPServerAdaptorWritesMcpServers asserts the
// producer side of the contract: an MCP-server plugin's settings-fragment.json
// is merged into the exact settings_path and key pinned by the contract. This
// prevents silent divergence between where MCPServerAdaptor writes and where
// claude_sdk_executor._load_settings_mcp reads.
func TestMCPPluginDeliveryContract_MCPServerAdaptorWritesMcpServers(t *testing.T) {
contract, err := LoadMCPPluginDeliveryContract()
if err != nil {
t.Fatalf("load contract: %v", err)
}
configsDir := t.TempDir()
pluginRoot := t.TempDir()
fragment := map[string]any{
contract.Key: map[string]any{
"molecule-platform": map[string]any{
"command": "molecule-mcp",
"env": map[string]string{
"MOLECULE_MCP_MODE": "management",
},
},
},
}
fragmentBytes, err := json.Marshal(fragment)
if err != nil {
t.Fatalf("marshal fragment: %v", err)
}
if err := os.WriteFile(filepath.Join(pluginRoot, "settings-fragment.json"), fragmentBytes, 0o644); err != nil {
t.Fatalf("write settings-fragment.json: %v", err)
}
if err := mergeMCPPluginSettings(configsDir, pluginRoot, contract); err != nil {
t.Fatalf("merge mcp plugin settings: %v", err)
}
rel := strings.TrimPrefix(contract.SettingsPath, "/configs/")
settingsPath := filepath.Join(configsDir, rel)
gotBytes, err := os.ReadFile(settingsPath)
if err != nil {
t.Fatalf("read produced settings at %s: %v", contract.SettingsPath, err)
}
var got map[string]any
if err := json.Unmarshal(gotBytes, &got); err != nil {
t.Fatalf("parse produced settings: %v", err)
}
if _, ok := got[contract.Key]; !ok {
t.Fatalf("produced settings missing contract key %q", contract.Key)
}
mcpServers, ok := got[contract.Key].(map[string]any)
if !ok {
t.Fatalf("produced settings %q is not an object: %T", contract.Key, got[contract.Key])
}
if _, ok := mcpServers["molecule-platform"]; !ok {
t.Fatalf("produced settings %q does not contain the molecule-platform entry", contract.Key)
}
}
// TestMCPPluginDeliveryContract_RealMCPServerAdaptorWritesMcpServers exercises
// the actual runtime MCPServerAdaptor when molecule-ai-workspace-runtime is
// available. This is the real producer named in the contract; the hermetic test
// above is the always-on CI guard, while this test catches drift in the runtime
// implementation itself.
func TestMCPPluginDeliveryContract_RealMCPServerAdaptorWritesMcpServers(t *testing.T) {
contract, err := LoadMCPPluginDeliveryContract()
if err != nil {
t.Fatalf("load contract: %v", err)
}
runtimePath := os.Getenv("MOLECULE_WORKSPACE_RUNTIME")
if runtimePath == "" {
// Default sibling checkout relative to the core repo root.
repoRoot := filepath.Join("..", "..", "..")
sibling := filepath.Join(repoRoot, "molecule-ai-workspace-runtime")
if _, err := os.Stat(filepath.Join(sibling, "molecule_runtime", "plugins_registry", "builtins.py")); err == nil {
runtimePath = sibling
}
}
pyScript := `
import asyncio, json, sys
from pathlib import Path
sys.path.insert(0, sys.argv[1])
from molecule_runtime.plugins_registry.builtins import MCPServerAdaptor
from molecule_runtime.plugins_registry.protocol import InstallContext
plugin_root = Path(sys.argv[2])
configs_dir = Path(sys.argv[3])
configs_dir.mkdir(parents=True, exist_ok=True)
async def main():
ctx = InstallContext(
configs_dir=configs_dir,
workspace_id="test-ws",
runtime="claude_code",
plugin_root=plugin_root,
)
adaptor = MCPServerAdaptor("molecule-platform-mcp", "claude_code")
await adaptor.install(ctx)
asyncio.run(main())
`
configsDir := t.TempDir()
pluginRoot := t.TempDir()
fragment := map[string]any{
contract.Key: map[string]any{
"molecule-platform": map[string]any{
"command": "molecule-mcp",
"env": map[string]string{
"MOLECULE_MCP_MODE": "management",
},
},
},
}
fragmentBytes, err := json.Marshal(fragment)
if err != nil {
t.Fatalf("marshal fragment: %v", err)
}
if err := os.WriteFile(filepath.Join(pluginRoot, "settings-fragment.json"), fragmentBytes, 0o644); err != nil {
t.Fatalf("write settings-fragment.json: %v", err)
}
python := os.Getenv("MOLECULE_RUNTIME_PYTHON")
if python == "" {
python = "python3"
}
var pythonPath string
if runtimePath != "" {
pythonPath = runtimePath
}
cmd := exec.Command(python, "-", runtimePath, pluginRoot, configsDir)
cmd.Env = os.Environ()
if pythonPath != "" {
cmd.Env = append(cmd.Env, "PYTHONPATH="+pythonPath)
}
cmd.Stdin = strings.NewReader(strings.TrimSpace(pyScript))
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if runtimePath == "" {
t.Skipf("molecule-ai-workspace-runtime not available (%v); skipping real-producer test", err)
}
t.Fatalf("run MCPServerAdaptor: %v\nstderr: %s", err, stderr.String())
}
rel := strings.TrimPrefix(contract.SettingsPath, "/configs/")
settingsPath := filepath.Join(configsDir, rel)
gotBytes, err := os.ReadFile(settingsPath)
if err != nil {
t.Fatalf("read produced settings at %s: %v", contract.SettingsPath, err)
}
var got map[string]any
if err := json.Unmarshal(gotBytes, &got); err != nil {
t.Fatalf("parse produced settings: %v", err)
}
if _, ok := got[contract.Key]; !ok {
t.Fatalf("real MCPServerAdaptor produced settings missing contract key %q", contract.Key)
}
mcpServers, ok := got[contract.Key].(map[string]any)
if !ok {
t.Fatalf("real MCPServerAdaptor produced settings %q is not an object: %T", contract.Key, got[contract.Key])
}
if _, ok := mcpServers["molecule-platform"]; !ok {
t.Fatalf("real MCPServerAdaptor produced settings %q does not contain the molecule-platform entry", contract.Key)
}
}
@@ -663,8 +663,8 @@ func MaybeProvisionPlatformAgentOnBoot(ctx context.Context, database *sql.DB, pr
return
}
log.Printf("boot: platform-agent %s running but MISSING concierge identity — re-declaring management MCP and restarting once to apply the system prompt + platform MCP", id)
if rec, skip := seedTemplatePlugins(ctx, id, []string{conciergePlatformMCPPlugin}); skip > 0 {
log.Printf("boot: concierge %s could not re-declare %q plugin (recorded=%d skipped=%d) — management MCP may be absent until next provision", id, conciergePlatformMCPPlugin, rec, skip)
if rec, skip := seedTemplatePlugins(ctx, id, []string{conciergePlatformMCPSource}); skip > 0 {
log.Printf("boot: concierge %s could not re-declare %q plugin (recorded=%d skipped=%d) — management MCP may be absent until next provision", id, conciergePlatformMCPSource, rec, skip)
}
go restartByID(id)
return
@@ -318,7 +318,7 @@ func TestMaybeProvisionPlatformAgentOnBoot_RestartsRunningButVanilla(t *testing.
mock.ExpectQuery(kindQuery).WithArgs(bootPlatformID).
WillReturnRows(sqlmock.NewRows([]string{"kind"}).AddRow("platform"))
mock.ExpectExec(`INSERT INTO workspace_declared_plugins`).
WithArgs(bootPlatformID, conciergePlatformMCPPlugin, sqlmock.AnyArg()).
WithArgs(bootPlatformID, conciergePlatformMCPName, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
// Running, but ExecRead of system-prompt.md returns vanilla content (no