feat(mcp): pin MCP-plugin delivery contract (core#3080) — includes prerequisite build fix #3084
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user