mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* feat(plugins): host plugin artifacts, bind installs to immutable versions A plugin used to be a URL. The manifest was frozen at install, but the surface script was fetched from the author's server every time a panel opened, with no integrity check — so an administrator consented to a manifest while the browser ran whatever that host served that day, inside the scopes already granted. The author's uptime was our uptime, every panel open leaked the reader's IP and "who read which issue" to the author, and publishing at all required a public HTTPS domain, which is why the repo's own deploy-sentinel example never ran. The author now uploads an artifact bundle and Multica stores it: - plugin_package / plugin_package_version / plugin_package_file. A version is insert-only, and the (package_id, version) unique index is what makes immutability a database rule rather than a convention. - An installation names one version. Publishing another changes nothing for a workspace until an administrator upgrades, which is a second consent. - The bundle is validated once, at publish: the manifest parses, every file it declares is present and loadable, and a surface entry with a top-level import is refused with the line named. That failure used to surface months later in a reader's browser. - The host inlines the surface script into the document it generates, so the CSP names no remote script origin at all. `connect-src 'none'` now means it: a surface with no net: scope cannot reach anywhere, including its author. - Install-by-URL is gone rather than kept alongside. Two paths would make "is this code frozen?" depend on how the plugin was installed. - MULTICA_PLUGIN_DIR stays the development channel, but publishes an ordinary immutable version instead of being a second render path; re-publishing an unchanged version lands as `+dev.N`. Hook endpoints and MCP servers remain on the author's own infrastructure. Migration 376 drops existing installations: they name a source URL that is no longer a concept, and the code they would run was never published here. plugins_v1 has never left its feature flag and holds no production data — the same call migration 344 made. Closes MUL-6469 Co-authored-by: multica-agent <[email protected]> * fix(plugins): serialize publish/install/delete, correct the isolation claim Review of #7321 found five problems. Four are fixed here; the fifth is real, needs its own design pass, and is now tracked and described honestly in the code instead of claimed away. Migration prefix collision. main took 376 for agent_task_durable_work_dir while this branch was open, so `migration prefix 376 is reused` failed backend-tests. Rebased and renumbered the six migrations to 377-382. TOCTOU between delete and install/publish. Relationships are application-owned by repository policy, so nothing made "the version this installation names still exists" true across statements: `delete counts zero installs` -> `install reads the version` -> `delete commits` -> `install commits` left an installation whose panel 404s forever, and a publish racing a delete left a version whose package row was gone. Publish, install and delete now hold a (workspace, plugin key) advisory lock for their whole transaction, and delete counts installations inside it. Both interleavings have regression tests that reproduce the bug with the lock removed. Failed publishes moved package state. upsertPackage ran before the version transaction, so a republish that lost the unique index had already renamed the package, and a first publish that failed afterwards left a plugin with zero versions. It now runs inside the same transaction. The top-level import check was wrong in BOTH directions. It read line prefixes, so it missed ` import x` and `/* c */ import x` — and it refused a valid file whose template literal contained the word at a line start, which is ordinary in a surface that renders code samples. A refusal blocks a publish with no way around it, so that false positive was the worse half. Replaced with a scanner that skips comments and string/template literals; where it is imprecise it is imprecise safely (`/` always reads as division), so a mistake can only cost a detection, never invent one. Sandbox self-navigation is NOT fixed. `sandbox="allow-scripts"` permits `_self` navigation by design, no shipped CSP directive covers it, and contentWindow is unchanged across it — so a hostile artifact can still reach its author once and hand the replacement document the bridge port. Closing it needs an embedder frame-src policy or a handshake a navigated document cannot complete; both are decisions of their own. What lands here is damage control, labelled as such: the generated document beacons on pagehide from a listener the plugin cannot detach, and the embedder drops the bridge and unmounts the frame. The "a surface cannot reach its author" wording is removed from the SDK README and the tests. Closes MUL-6469 Refs MUL-6485 Co-authored-by: multica-agent <[email protected]> * fix(plugins): harden surface validation and error reporting * fix(migrations): renumber plugin package migrations * fix(plugins): validate classic surfaces and reset failures --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]>
486 lines
19 KiB
Go
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://multica.test/api/v1/plugin"
|
|
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{})
|
|
}
|
|
}
|