mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
207 lines
7.6 KiB
Go
207 lines
7.6 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The local MCP server is the daemon's whole contribution to the agent trigger.
|
|
// These cover the two things that decide whether it is safe: that a failing
|
|
// hook is a TOOL error rather than a broken transport, and that the tool list
|
|
// is exactly what the server said it was.
|
|
|
|
func testHookTools() []PluginHookTool {
|
|
return []PluginHookTool{
|
|
{InstallationID: "inst-1", HookKey: "summarize", Name: "triage_a1b2__summarize",
|
|
Description: "Summarize the thread.", InputSchema: json.RawMessage(`{"type":"object"}`)},
|
|
{InstallationID: "inst-2", HookKey: "summarize", Name: "release_c3d4__summarize",
|
|
Description: "Summarize the release."},
|
|
}
|
|
}
|
|
|
|
func startTestHookMCP(t *testing.T, invoke pluginHookInvoker) (*pluginHookMCPServer, string) {
|
|
t.Helper()
|
|
tools := testHookTools()
|
|
byName := map[string]PluginHookTool{}
|
|
for _, tool := range tools {
|
|
byName[tool.Name] = tool
|
|
}
|
|
return &pluginHookMCPServer{
|
|
taskID: "task-1", tools: tools, byName: byName, invoke: invoke, path: "/token",
|
|
}, "/token"
|
|
}
|
|
|
|
func callMCP(t *testing.T, server *pluginHookMCPServer, path, body string) map[string]any {
|
|
t.Helper()
|
|
request, err := http.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("build request: %v", err)
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
server.ServeHTTP(recorder, request)
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &decoded); err != nil {
|
|
t.Fatalf("decode response %q: %v", recorder.Body.String(), err)
|
|
}
|
|
return decoded
|
|
}
|
|
|
|
// An unreachable plugin endpoint must not fail the agent's task. It comes back
|
|
// as a tool result flagged isError, which the agent reads and works around —
|
|
// not as a protocol error, which would look to the agent like its tooling is
|
|
// broken.
|
|
func TestPluginHookToolFailureIsAToolErrorNotATransportError(t *testing.T) {
|
|
server, path := startTestHookMCP(t, func(context.Context, string, string, string, json.RawMessage) (json.RawMessage, error) {
|
|
return nil, errors.New("hook endpoint did not answer")
|
|
})
|
|
|
|
response := callMCP(t, server, path,
|
|
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"triage_a1b2__summarize","arguments":{}}}`)
|
|
|
|
if _, isProtocolError := response["error"]; isProtocolError {
|
|
t.Fatalf("a failing hook produced a JSON-RPC error, which reads as broken tooling: %v", response)
|
|
}
|
|
result, ok := response["result"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("no result in %v", response)
|
|
}
|
|
if result["isError"] != true {
|
|
t.Fatalf("the tool result must be flagged as an error, got %v", result)
|
|
}
|
|
}
|
|
|
|
// Both plugins' hooks are offered, under their distinct names.
|
|
func TestPluginHookToolsListIsWhatTheServerSent(t *testing.T) {
|
|
server, path := startTestHookMCP(t, nil)
|
|
response := callMCP(t, server, path, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
|
|
|
result, ok := response["result"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("no result in %v", response)
|
|
}
|
|
tools, ok := result["tools"].([]any)
|
|
if !ok || len(tools) != 2 {
|
|
t.Fatalf("tools = %v, want the two the server sent", result["tools"])
|
|
}
|
|
names := map[string]bool{}
|
|
for _, entry := range tools {
|
|
tool := entry.(map[string]any)
|
|
names[tool["name"].(string)] = true
|
|
// A tool with no declared input still needs a schema or providers
|
|
// reject the whole list.
|
|
if tool["inputSchema"] == nil {
|
|
t.Fatalf("tool %v has no inputSchema", tool["name"])
|
|
}
|
|
}
|
|
if !names["triage_a1b2__summarize"] || !names["release_c3d4__summarize"] {
|
|
t.Fatalf("both plugins' hooks must appear under distinct names, got %v", names)
|
|
}
|
|
}
|
|
|
|
func TestPluginHookDuplicateNamesKeepTheFirstDescriptorAndRoute(t *testing.T) {
|
|
tools := []PluginHookTool{
|
|
{InstallationID: "inst-first", HookKey: "first", Name: "shared_tool",
|
|
Description: "The first hook.", InputSchema: json.RawMessage(`{"type":"object","properties":{"first":{"type":"string"}}}`)},
|
|
{InstallationID: "inst-duplicate", HookKey: "duplicate", Name: "shared_tool",
|
|
Description: "The duplicate hook.", InputSchema: json.RawMessage(`{"type":"object","properties":{"duplicate":{"type":"integer"}}}`)},
|
|
{InstallationID: "inst-other", HookKey: "other", Name: "other_tool",
|
|
Description: "The independent hook.", InputSchema: json.RawMessage(`{"type":"object","properties":{"other":{"type":"boolean"}}}`)},
|
|
}
|
|
original, err := json.Marshal(tools)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var calls []string
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
_, set, err := startTaskPluginHookMCP(ctx, "task-1", tools,
|
|
func(_ context.Context, taskID, installationID, hookKey string, _ json.RawMessage) (json.RawMessage, error) {
|
|
calls = append(calls, taskID+"/"+installationID+"/"+hookKey)
|
|
return json.RawMessage(`{"ok":true}`), nil
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("start hook MCP: %v", err)
|
|
}
|
|
t.Cleanup(set.Close)
|
|
server := set.server.Handler.(*pluginHookMCPServer)
|
|
unchanged, err := json.Marshal(tools)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(unchanged) != string(original) {
|
|
t.Fatalf("starting the server mutated the caller's tools: got %s, want %s", unchanged, original)
|
|
}
|
|
|
|
response := callMCP(t, server, server.path, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
|
result, ok := response["result"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("no result in %v", response)
|
|
}
|
|
listed, ok := result["tools"].([]any)
|
|
if !ok || len(listed) != 2 {
|
|
t.Fatalf("tools = %v, want the first shared tool and the independent tool", result["tools"])
|
|
}
|
|
for i, want := range []PluginHookTool{tools[0], tools[2]} {
|
|
var schema map[string]any
|
|
if err := json.Unmarshal(want.InputSchema, &schema); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantDescriptor := map[string]any{
|
|
"name": want.Name, "description": want.Description, "inputSchema": schema,
|
|
}
|
|
if !reflect.DeepEqual(listed[i], wantDescriptor) {
|
|
t.Fatalf("descriptor %d = %v, want %v", i, listed[i], wantDescriptor)
|
|
}
|
|
request, err := json.Marshal(map[string]any{
|
|
"jsonrpc": "2.0", "id": i + 2, "method": "tools/call",
|
|
"params": map[string]any{"name": want.Name, "arguments": map[string]any{}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
response := callMCP(t, server, server.path, string(request))
|
|
result, ok := response["result"].(map[string]any)
|
|
if !ok || result["isError"] == true {
|
|
t.Fatalf("call %q failed: %v", want.Name, response)
|
|
}
|
|
}
|
|
if want := []string{"task-1/inst-first/first", "task-1/inst-other/other"}; !reflect.DeepEqual(calls, want) {
|
|
t.Fatalf("invocations = %v, want %v", calls, want)
|
|
}
|
|
}
|
|
|
|
// A name the server did not send is not callable, however plausible it looks.
|
|
func TestPluginHookToolRefusesAnUnknownTool(t *testing.T) {
|
|
called := false
|
|
server, path := startTestHookMCP(t, func(context.Context, string, string, string, json.RawMessage) (json.RawMessage, error) {
|
|
called = true
|
|
return nil, nil
|
|
})
|
|
response := callMCP(t, server, path,
|
|
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"something__else","arguments":{}}}`)
|
|
|
|
if _, isError := response["error"]; !isError {
|
|
t.Fatalf("an unknown tool must be refused, got %v", response)
|
|
}
|
|
if called {
|
|
t.Fatal("an unknown tool reached the invoker")
|
|
}
|
|
}
|
|
|
|
// The path is a per-task random token, so knowing the port is not enough.
|
|
func TestPluginHookMCPRefusesTheWrongPath(t *testing.T) {
|
|
server, _ := startTestHookMCP(t, nil)
|
|
request, _ := http.NewRequest(http.MethodPost, "/guessed", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
|
|
recorder := httptest.NewRecorder()
|
|
server.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404 for an unknown path", recorder.Code)
|
|
}
|
|
}
|