Files
Jiayuan Zhangandmultica-agent a4e1ea7140 MUL-6581: expose globally versioned Plugin Public API (#7446)
* feat(plugins): expose globally versioned public API

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): proxy same-origin public API

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-23 17:15:46 +08:00

486 lines
19 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/util/secretbox"
"github.com/multica-ai/multica/server/pkg/plugincontract"
"github.com/multica-ai/multica/server/pkg/remotemcp"
)
// examples/plugins/deploy-sentinel, driven end to end.
//
// This installs the REAL manifest from the repository, not a fixture written
// next to the assertions. That is the point: a hand-written fixture keeps
// passing after the example rots, and the example is what a plugin author
// copies. If somebody breaks the manifest, this is what says so.
//
// The plugin declares all four triggers and both transports, so one install
// covers the whole PR-4 surface: a skill resource lands in the skill table, its
// http hooks reach an agent as tools and are invoked over a real signed request,
// and its mcp hook is discovered and approved.
//
// The only thing rewritten on the way in is the two endpoint hosts — a test
// server's port is not knowable when the manifest is written. Everything else,
// including every tool description an agent reads, is the file's own text.
func exampleRepoDir(t *testing.T) string {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot locate this test file")
}
// server/internal/handler -> repo root
dir := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "examples", "plugins", "deploy-sentinel")
if _, err := os.Stat(filepath.Join(dir, plugincontract.ManifestFilename)); err != nil {
t.Fatalf("the example plugin is missing: %v", err)
}
return dir
}
type exampleServers struct {
sentinel *httptest.Server
metrics *httptest.Server
}
func hostOf(t *testing.T, rawURL string) string {
t.Helper()
parsed, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("parse %q: %v", rawURL, err)
}
return parsed.Hostname()
}
// stageExamplePlugin copies the example into a temp MULTICA_PLUGIN_DIR and
// repoints its two endpoints (and the net: scopes that authorise them) at the
// running test servers.
func stageExamplePlugin(t *testing.T, servers exampleServers) (string, []string) {
t.Helper()
source := exampleRepoDir(t)
root := t.TempDir()
staged := filepath.Join(root, "deploy-sentinel")
if err := os.CopyFS(staged, os.DirFS(source)); err != nil {
t.Fatalf("stage the example: %v", err)
}
manifestPath := filepath.Join(staged, plugincontract.ManifestFilename)
raw, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatalf("read the example manifest: %v", err)
}
sentinelHost, metricsHost := hostOf(t, servers.sentinel.URL), hostOf(t, servers.metrics.URL)
rewritten := strings.NewReplacer(
"https://sentinel.example.com", servers.sentinel.URL,
"https://metrics.example.com", servers.metrics.URL,
"net:sentinel.example.com", "net:"+sentinelHost,
"net:metrics.example.com", "net:"+metricsHost,
).Replace(string(raw))
// A net: scope names a host, not a URL, and both test servers land on the
// same loopback host with different ports. Two identical scope lines are a
// manifest error, so collapse them — this is an artefact of the rewrite,
// not of the example.
rewritten = dedupeScopes(t, rewritten)
if err := os.WriteFile(manifestPath, []byte(rewritten), 0o644); err != nil {
t.Fatalf("write the staged manifest: %v", err)
}
// Read the scopes back out of the staged file rather than listing them
// here: granting a set this test maintains by hand would let the manifest
// add a scope that nothing ever notices.
var parsed struct {
Scopes []string `json:"scopes"`
}
if err := json.Unmarshal([]byte(rewritten), &parsed); err != nil {
t.Fatalf("parse the staged manifest: %v", err)
}
return root, parsed.Scopes
}
// dedupeScopes rewrites the manifest's scope array in place, dropping repeats
// while preserving order.
func dedupeScopes(t *testing.T, manifest string) string {
t.Helper()
var document map[string]json.RawMessage
if err := json.Unmarshal([]byte(manifest), &document); err != nil {
t.Fatalf("parse the staged manifest: %v", err)
}
var scopes []string
if err := json.Unmarshal(document["scopes"], &scopes); err != nil {
t.Fatalf("parse scopes: %v", err)
}
seen := map[string]bool{}
unique := make([]string, 0, len(scopes))
for _, scope := range scopes {
if seen[scope] {
continue
}
seen[scope] = true
unique = append(unique, scope)
}
encoded, err := json.Marshal(unique)
if err != nil {
t.Fatalf("encode scopes: %v", err)
}
document["scopes"] = encoded
out, err := json.Marshal(document)
if err != nil {
t.Fatalf("encode the staged manifest: %v", err)
}
return string(out)
}
func installExamplePlugin(t *testing.T, servers exampleServers) string {
t.Helper()
withPluginsV1Flag(t, testHandler, true)
root, scopes := stageExamplePlugin(t, servers)
plugins := testHandler.PluginService
previous := *plugins
plugins.LocalDir = root
plugins.Host = plugincontract.HostCapabilities()
// The two test servers run on loopback with self-signed certs, which the
// outbound guard refuses by design. DevOrigins is the operator opt-in a
// plugin author uses for exactly this, and it is scoped to these origins.
plugins.DevOrigins = []string{servers.sentinel.URL, servers.metrics.URL}
plugins.HookClient = servers.sentinel.Client()
// The manifest declares two secret config fields, so storing configuration
// needs the secretbox the deployment would normally supply.
box, err := secretbox.New(bytes.Repeat([]byte{9}, 32))
if err != nil {
t.Fatalf("secretbox.New: %v", err)
}
plugins.Secrets = box
// Signing is what makes a hook call verifiable by the plugin's own server;
// without a deployment key the engine refuses to call out at all.
plugins.DeploymentKey = bytes.Repeat([]byte{3}, 32)
plugins.Callbacks = service.NewCallbackTokens()
plugins.CallbackBaseURL = "https://plugin-api.multica.test/v1"
t.Cleanup(func() { *plugins = previous })
// Publishing is what validates the whole artifact: the manifest, the surface
// script, and the SKILL.md all have to be there and be loadable. The example
// is the file a plugin author copies, so this is the check that keeps it
// runnable rather than merely well-formed.
versionID := publishLocalPlugin(t, "deploy-sentinel")
body, _ := json.Marshal(map[string]any{
"version_id": versionID,
"granted_scopes": scopes,
})
recorder := httptest.NewRecorder()
testHandler.InstallPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", body, map[string]string{"id": testWorkspaceID}))
if recorder.Code != http.StatusCreated {
t.Fatalf("install deploy-sentinel: status=%d body=%s", recorder.Code, recorder.Body.String())
}
var installed struct {
ID string `json:"id"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &installed); err != nil {
t.Fatalf("decode installation: %v", err)
}
t.Cleanup(func() {
cleanup := httptest.NewRecorder()
testHandler.UninstallPlugin(cleanup, pluginHandlerRequest(http.MethodDelete, "/plugins", nil,
map[string]string{"id": testWorkspaceID, "installationId": installed.ID}))
})
return installed.ID
}
func configureExamplePlugin(t *testing.T, installationID string) {
t.Helper()
body, _ := json.Marshal(map[string]any{"values": map[string]any{
"service_prefix": "prod-",
"rollback_window_minutes": 60,
"require_incident_label": true,
"environment": "production",
"sentinel_token": "sentinel-secret",
}})
recorder := httptest.NewRecorder()
testHandler.ConfigurePlugin(recorder, pluginHandlerRequest(http.MethodPut, "/config", body,
map[string]string{"id": testWorkspaceID, "installationId": installationID}))
if recorder.Code != http.StatusOK {
t.Fatalf("configure: status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
// quietServer is the plugin's own hook endpoint: TLS, because the http
// transport's client is supplied by the test and can trust its certificate.
func quietServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
server := httptest.NewTLSServer(handler)
t.Cleanup(server.Close)
return server
}
// metricsServer is the plugin author's own MCP server: HTTPS, because the
// manifest validator requires it and a dev origin does not relax that.
//
// remotemcp.Discover builds its own HTTP client, so the only way for it to
// trust this certificate is the same one a plugin author uses locally —
// MULTICA_PLUGIN_DEV_CA, pointing at a CA bundle.
func metricsServer(t *testing.T) *httptest.Server {
t.Helper()
server := httptest.NewTLSServer(http.HandlerFunc(serveMetricsMCP))
t.Cleanup(server.Close)
caPath := filepath.Join(t.TempDir(), "dev-ca.pem")
pem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})
if err := os.WriteFile(caPath, pem, 0o600); err != nil {
t.Fatalf("write the dev CA: %v", err)
}
t.Setenv(remotemcp.DevCAEnv, caPath)
t.Setenv(remotemcp.DevOriginsEnv, server.URL)
return server
}
// The example's SKILL.md becomes an ordinary workspace skill. No bundle
// pipeline, no digest, no new table — that was the whole claim.
func TestExamplePluginContributesItsSkill(t *testing.T) {
servers := exampleServers{
sentinel: quietServer(t, func(http.ResponseWriter, *http.Request) {}),
metrics: metricsServer(t),
}
installationID := installExamplePlugin(t, servers)
names := skillNamesForInstallation(t, installationID)
if len(names) != 1 || names[0] != "incident-response" {
t.Fatalf("skills for the installation = %v, want [incident-response]", names)
}
var body string
if err := testPool.QueryRow(context.Background(),
`SELECT content FROM skill WHERE plugin_installation_id = $1`, installationID).Scan(&body); err != nil {
t.Fatalf("read the skill content: %v", err)
}
// Asserting on a sentence the skill actually teaches, not on its length: a
// truncated or mis-resolved entry would still have bytes in it.
if !strings.Contains(body, "Blast radius") {
t.Fatalf("the skill did not come from the example's SKILL.md; got %d bytes", len(body))
}
}
// What an agent working an incident actually sees.
func TestExamplePluginHooksReachAnAgentAsTools(t *testing.T) {
servers := exampleServers{
sentinel: quietServer(t, func(http.ResponseWriter, *http.Request) {}),
metrics: metricsServer(t),
}
installExamplePlugin(t, servers)
tools, err := testHandler.PluginService.AgentHookTools(context.Background(), parseUUID(testWorkspaceID))
if err != nil {
t.Fatalf("agent hook tools: %v", err)
}
byHook := make(map[string]service.PluginHookTool, len(tools))
for _, tool := range tools {
byHook[tool.HookKey] = tool
}
for _, hookKey := range []string{"correlate_deploys", "request_rollback"} {
if _, ok := byHook[hookKey]; !ok {
t.Fatalf("hook %q declares the agent trigger but is not offered as a tool", hookKey)
}
}
// page_oncall is event-only. An agent being able to page on-call by
// choosing to would defeat the point of the trigger being separate.
if _, offered := byHook["page_oncall"]; offered {
t.Fatal("page_oncall is event-only and must never appear in an agent's tool list")
}
rollback := byHook["request_rollback"]
// The description is the manifest's verbatim, because that text is how the
// agent decides which tool to reach for.
if !strings.Contains(rollback.Description, "does NOT roll anything back") {
t.Fatalf("the tool description must be the manifest's own text, got: %q", rollback.Description)
}
if !strings.HasPrefix(rollback.Name, "deploy_sentinel_") || !strings.HasSuffix(rollback.Name, "__request_rollback") {
t.Fatalf("tool name %q is not namespaced by plugin", rollback.Name)
}
if len(rollback.InputSchema) == 0 || !strings.Contains(string(rollback.InputSchema), "deploy_id") {
t.Fatalf("the manifest's input schema did not reach the agent: %s", rollback.InputSchema)
}
}
// The whole round trip: an agent calls the tool, Multica signs the request, the
// plugin's own server sees that signature and answers, and the answer comes
// back. Nothing is mocked except the author's business logic, which is theirs.
func TestExamplePluginAgentHookRoundTripIsSigned(t *testing.T) {
var seenSignature, seenTimestamp, seenHook, seenTrigger string
var seenConfig map[string]any
sentinel := quietServer(t, func(w http.ResponseWriter, r *http.Request) {
var payload struct {
HookKey string `json:"hook_key"`
Trigger string `json:"trigger"`
Config map[string]any `json:"config"`
}
_ = json.NewDecoder(r.Body).Decode(&payload)
seenSignature = r.Header.Get("X-Multica-Signature")
seenTimestamp = r.Header.Get("X-Multica-Timestamp")
seenHook, seenTrigger, seenConfig = payload.HookKey, payload.Trigger, payload.Config
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"deploy_count":1,"summary":"1 deploy to %vcheckout-api"}`, payload.Config["service_prefix"])
})
servers := exampleServers{sentinel: sentinel, metrics: metricsServer(t)}
installationID := installExamplePlugin(t, servers)
configureExamplePlugin(t, installationID)
input := json.RawMessage(`{"service":"checkout-api","window_minutes":120}`)
result, err := testHandler.PluginService.InvokeAgentHook(
context.Background(), installationID, "correlate_deploys", parseUUID(testUserID), input)
if err != nil {
t.Fatalf("agent hook invocation: %v", err)
}
if seenHook != "correlate_deploys" || seenTrigger != "agent" {
t.Fatalf("the plugin saw hook=%q trigger=%q", seenHook, seenTrigger)
}
if seenSignature == "" || seenTimestamp == "" {
t.Fatal("the request reached the plugin unsigned")
}
// The plugin's configuration travels with the call — its business rules
// depend on it, and it must not have to keep a second copy.
if seenConfig["service_prefix"] != "prod-" {
t.Fatalf("config did not reach the hook: %v", seenConfig)
}
// Structural, not behavioural: a secret-typed value is written to a
// different table and never reaches installation.Config, so this cannot
// fail today. It is here for the day somebody merges those two tables.
if _, leaked := seenConfig["sentinel_token"]; leaked {
t.Fatal("a secret config value was sent to the hook endpoint")
}
if result.Status != "ok" {
t.Fatalf("hook result status = %q, want ok", result.Status)
}
body, _ := json.Marshal(result.Output)
if !strings.Contains(string(body), "prod-checkout-api") {
t.Fatalf("the plugin's answer did not come back to the agent: %s", body)
}
}
// Discovery lists what the metrics server offers; approval is what makes any of
// it callable. That gap is the difference between this transport and http.
func TestExamplePluginMCPToolsNeedApproval(t *testing.T) {
servers := exampleServers{
sentinel: quietServer(t, func(http.ResponseWriter, *http.Request) {}),
metrics: metricsServer(t),
}
installationID := installExamplePlugin(t, servers)
recorder := httptest.NewRecorder()
testHandler.ListPluginMCPTools(recorder, pluginHandlerRequest(http.MethodGet, "/mcp/tools", nil, map[string]string{
"id": testWorkspaceID, "installationId": installationID, "hookKey": "metrics",
}))
if recorder.Code != http.StatusOK {
t.Fatalf("discover: status=%d body=%s", recorder.Code, recorder.Body.String())
}
var discovered struct {
Tools []struct {
Name string `json:"name"`
Approved bool `json:"approved"`
} `json:"tools"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &discovered); err != nil {
t.Fatalf("decode tools: %v", err)
}
if len(discovered.Tools) != 3 {
t.Fatalf("discovered %d tools, want the metrics server's 3", len(discovered.Tools))
}
for _, tool := range discovered.Tools {
if tool.Approved {
t.Fatalf("tool %q reported as approved before anybody approved it", tool.Name)
}
}
// Nothing approved yet, so an agent gets no connection at all.
connections, err := testHandler.PluginService.AgentMCPConnections(context.Background(), parseUUID(testWorkspaceID))
if err != nil {
t.Fatalf("agent mcp connections: %v", err)
}
if len(connections) != 0 {
t.Fatalf("an unapproved mcp hook produced %d connections", len(connections))
}
approve, _ := json.Marshal(map[string]any{"tools": []string{"query_timeseries", "list_alerts"}})
recorder = httptest.NewRecorder()
testHandler.ApprovePluginMCPTools(recorder, pluginHandlerRequest(http.MethodPut, "/mcp/tools", approve, map[string]string{
"id": testWorkspaceID, "installationId": installationID, "hookKey": "metrics",
}))
if recorder.Code != http.StatusOK {
t.Fatalf("approve: status=%d body=%s", recorder.Code, recorder.Body.String())
}
connections, err = testHandler.PluginService.AgentMCPConnections(context.Background(), parseUUID(testWorkspaceID))
if err != nil {
t.Fatalf("agent mcp connections after approval: %v", err)
}
if len(connections) != 1 {
t.Fatalf("got %d connections after approval, want 1", len(connections))
}
pinned := map[string]bool{}
for _, tool := range connections[0].ApprovedTools {
pinned[tool.Name] = true
if tool.SchemaDigest == "" {
t.Fatalf("approved tool %q was pinned without a schema digest", tool.Name)
}
}
// The third tool was discovered and deliberately not ticked. If it rode
// along, the approval step would be decorative.
if len(pinned) != 2 || !pinned["query_timeseries"] || !pinned["list_alerts"] {
t.Fatalf("pinned tools = %v, want exactly the two that were approved", pinned)
}
}
// serveMetricsMCP mirrors examples/plugins/deploy-sentinel/server/metrics-mcp.mjs.
// The .mjs file is what a person runs by hand; this is what CI runs, so the two
// must offer the same tools.
func serveMetricsMCP(w http.ResponseWriter, r *http.Request) {
var request struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
_ = json.NewDecoder(r.Body).Decode(&request)
w.Header().Set("Content-Type", "application/json")
send := func(result any) {
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": request.ID, "result": result})
}
switch request.Method {
case "initialize":
send(map[string]any{
"protocolVersion": "2025-03-26",
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": "deploy-sentinel-metrics", "version": "1.0.0"},
})
case "notifications/initialized":
w.WriteHeader(http.StatusAccepted)
case "tools/list":
send(map[string]any{"tools": []map[string]any{
{"name": "query_timeseries", "description": "Query a metric over a time window.",
"inputSchema": map[string]any{"type": "object", "required": []string{"metric", "window_minutes"}}},
{"name": "list_alerts", "description": "Alerts that fired in a window.",
"inputSchema": map[string]any{"type": "object"}},
{"name": "service_dependencies", "description": "Which services this one calls.",
"inputSchema": map[string]any{"type": "object", "required": []string{"service"}}},
}})
default:
send(map[string]any{})
}
}