feat: expose plugin agent tools through task MCP (#2552)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -28,6 +28,33 @@ capabilities:
|
||||
secrets: true
|
||||
user_state: true
|
||||
|
||||
agent_tools:
|
||||
- name: test_echo
|
||||
description: Echoes test input and invocation context for plugin MCP tests
|
||||
surfaces: [kanban-task, office-task]
|
||||
input_schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [value]
|
||||
properties:
|
||||
value:
|
||||
type: string
|
||||
output_schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [value, task_id, surface]
|
||||
properties:
|
||||
value:
|
||||
type: string
|
||||
task_id:
|
||||
type: string
|
||||
surface:
|
||||
type: string
|
||||
annotations:
|
||||
read_only_hint: true
|
||||
destructive_hint: false
|
||||
idempotent_hint: true
|
||||
|
||||
webhooks:
|
||||
- key: "test-hook"
|
||||
description: "Records inbound webhooks for e2e polling"
|
||||
|
||||
@@ -29,6 +29,8 @@ func TestFixtureManifest_ParsesAndValidates(t *testing.T) {
|
||||
require.True(t, m.HasEvent("task.created"))
|
||||
require.True(t, m.Capabilities.State)
|
||||
require.True(t, m.Capabilities.UserState)
|
||||
require.Len(t, m.AgentTools, 1)
|
||||
require.Equal(t, "test_echo", m.AgentTools[0].Name)
|
||||
|
||||
require.Len(t, m.Webhooks, 1)
|
||||
require.Equal(t, "test-hook", m.Webhooks[0].Key)
|
||||
|
||||
@@ -59,6 +59,21 @@ func TestOnEvent_AppendsJSONLineToDataDir(t *testing.T) {
|
||||
require.Equal(t, "evt_1", recs[0].EventID)
|
||||
}
|
||||
|
||||
func TestInvokeAgentToolEchoesContext(t *testing.T) {
|
||||
p := &fixturePlugin{}
|
||||
result, err := p.InvokeAgentTool(context.Background(), &pluginsdk.AgentToolRequest{
|
||||
Name: "test_echo", Arguments: map[string]any{"value": "hello"},
|
||||
Context: pluginsdk.AgentToolContext{TaskID: "task-1", Surface: "kanban-task"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "fixture echo: hello", result.Text)
|
||||
require.False(t, result.IsError)
|
||||
require.Equal(t, "hello", result.StructuredContent["value"])
|
||||
require.Equal(t, "task-1", result.StructuredContent["task_id"])
|
||||
require.Equal(t, "kanban-task", result.StructuredContent["surface"])
|
||||
}
|
||||
|
||||
func TestOnEvent_AppendsMultipleDeliveriesInOrder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := &fixturePlugin{dataDir: dir}
|
||||
|
||||
@@ -57,6 +57,18 @@ type fixturePlugin struct {
|
||||
|
||||
var _ pluginsdk.Plugin = (*fixturePlugin)(nil)
|
||||
|
||||
var _ pluginsdk.AgentToolPlugin = (*fixturePlugin)(nil)
|
||||
|
||||
func (p *fixturePlugin) InvokeAgentTool(_ context.Context, req *pluginsdk.AgentToolRequest) (*pluginsdk.AgentToolResult, error) {
|
||||
value, _ := req.Arguments["value"].(string)
|
||||
return &pluginsdk.AgentToolResult{
|
||||
Text: fmt.Sprintf("fixture echo: %s", value),
|
||||
StructuredContent: map[string]any{
|
||||
"value": value, "task_id": req.Context.TaskID, "surface": req.Context.Surface,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// newFixturePlugin builds a fixturePlugin whose data directory is resolved
|
||||
// from KANDEV_PLUGIN_DATA_DIR (falling back to the current working
|
||||
// directory), per §2 of docs/plans/plugins/GRPC-CONTRACT.md.
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/agentctl/tracing"
|
||||
"github.com/kandev/kandev/internal/agentctl/types"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/mcp/plugintools"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -410,6 +411,31 @@ func (c *Client) SetMcpProviders(ctx context.Context, providers []string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPluginTools replaces the plugin-contributed MCP catalog on agentctl.
|
||||
func (c *Client) SetPluginTools(ctx context.Context, snapshot plugintools.Snapshot) error {
|
||||
ctx, span := tracing.TraceHTTPRequest(ctx, "PUT", "/api/v1/mcp/plugin-tools", c.executionID)
|
||||
defer span.End()
|
||||
body, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", c.baseURL+"/api/v1/mcp/plugin-tools", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
respBody, _ := readResponseBody(resp)
|
||||
return fmt.Errorf("set plugin tools failed with status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start starts the agent process and returns the full command that was executed.
|
||||
func (c *Client) Start(ctx context.Context) (string, error) {
|
||||
ctx, span := tracing.TraceHTTPRequest(ctx, "POST", "/api/v1/start", c.executionID)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/types"
|
||||
"github.com/kandev/kandev/internal/mcp/plugintools"
|
||||
)
|
||||
|
||||
func TestNewClient_BuildsBaseURLAndTimeouts(t *testing.T) {
|
||||
@@ -382,6 +383,24 @@ func TestSetMcpProviders_FailsOnNonOKStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPluginToolsPutsSnapshotUnderControlEndpoint(t *testing.T) {
|
||||
srv, got := captureServer(t, jsonResponder(http.StatusOK, `{}`))
|
||||
snapshot := plugintools.Snapshot{Generation: "g", Revision: 7}
|
||||
if err := newHTTPOnlyClient(srv.URL).SetPluginTools(context.Background(), snapshot); err != nil {
|
||||
t.Fatalf("SetPluginTools: %v", err)
|
||||
}
|
||||
if got.Method != http.MethodPut || got.Path != "/api/v1/mcp/plugin-tools" {
|
||||
t.Fatalf("request = %s %s", got.Method, got.Path)
|
||||
}
|
||||
var sent plugintools.Snapshot
|
||||
if err := json.Unmarshal(got.Body, &sent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sent.Generation != "g" || sent.Revision != 7 {
|
||||
t.Fatalf("snapshot = %#v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStart_ReturnsTheExecutedCommand(t *testing.T) {
|
||||
srv, got := captureServer(t, jsonResponder(http.StatusOK,
|
||||
`{"success":true,"command":"claude-code acp --workspace-root /w"}`))
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/common/subproc"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/gitconfigenv"
|
||||
"github.com/kandev/kandev/internal/mcp/plugintools"
|
||||
storageworkspaces "github.com/kandev/kandev/internal/system/storage/workspaces"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
@@ -1617,6 +1618,22 @@ func (m *Manager) SetMcpProvidersForSession(ctx context.Context, sessionID strin
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPluginToolsForAllExecutions pushes a complete revisioned catalog to each
|
||||
// live agentctl. One unavailable execution does not prevent the others from
|
||||
// converging; stale delivery is rejected by agentctl's snapshot revision.
|
||||
func (m *Manager) SetPluginToolsForAllExecutions(ctx context.Context, snapshot plugintools.Snapshot) error {
|
||||
var refreshErr error
|
||||
for _, execution := range m.ListExecutions() {
|
||||
if execution == nil || execution.agentctl == nil {
|
||||
continue
|
||||
}
|
||||
if err := execution.agentctl.SetPluginTools(ctx, snapshot); err != nil {
|
||||
refreshErr = errors.Join(refreshErr, fmt.Errorf("refresh execution %s plugin tools: %w", execution.ID, err))
|
||||
}
|
||||
}
|
||||
return refreshErr
|
||||
}
|
||||
|
||||
// resolveApprovalPolicyAndDisplayName resolves the approval policy and agent display name
|
||||
// from the execution's agent profile and registry.
|
||||
func (m *Manager) resolveApprovalPolicyAndDisplayName(ctx context.Context, execution *AgentExecution) (string, string) {
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/mcp/plugintools"
|
||||
)
|
||||
|
||||
func TestSetMcpProvidersForSessionCallsLiveAgentctl(t *testing.T) {
|
||||
@@ -42,3 +44,27 @@ func TestSetMcpProvidersForSession_NoExecutionIsNoOp(t *testing.T) {
|
||||
t.Fatalf("SetMcpProvidersForSession without execution: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPluginToolsForAllExecutionsCallsLiveAgentctl(t *testing.T) {
|
||||
got := make(chan plugintools.Snapshot, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut || r.URL.Path != "/api/v1/mcp/plugin-tools" {
|
||||
t.Errorf("request = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
var snapshot plugintools.Snapshot
|
||||
if err := json.NewDecoder(r.Body).Decode(&snapshot); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
got <- snapshot
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
mgr, _ := workspaceSourceTestManager(t, server.URL, nil)
|
||||
want := plugintools.Snapshot{Generation: "g", Revision: 3}
|
||||
if err := mgr.SetPluginToolsForAllExecutions(context.Background(), want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot := <-got; snapshot.Generation != want.Generation || snapshot.Revision != want.Revision {
|
||||
t.Fatalf("snapshot = %#v, want %#v", snapshot, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
@@ -25,13 +26,23 @@ type MCPIdentityScoper func(ctx context.Context, taskID string) (context.Context
|
||||
// payload session_id would let it name another user's session and inherit
|
||||
// their identity — turning the scoping fix into a privilege escalation.
|
||||
type taskScopedMCPHandler struct {
|
||||
inner agentctl.MCPHandler
|
||||
scope MCPIdentityScoper
|
||||
taskID string
|
||||
logger *logger.Logger
|
||||
inner agentctl.MCPHandler
|
||||
scope MCPIdentityScoper
|
||||
executionID string
|
||||
taskID string
|
||||
sessionID string
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
func (h *taskScopedMCPHandler) Dispatch(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
ctx = streams.WithMCPExecutionContext(ctx, streams.MCPExecutionContext{
|
||||
ExecutionID: h.executionID,
|
||||
TaskID: h.taskID,
|
||||
SessionID: h.sessionID,
|
||||
})
|
||||
if h.scope == nil {
|
||||
return h.inner.Dispatch(ctx, msg)
|
||||
}
|
||||
scoped, err := h.scope(ctx, h.taskID)
|
||||
if err != nil {
|
||||
h.logger.Error("denying in-session MCP request: cannot resolve the task owner",
|
||||
@@ -44,18 +55,19 @@ func (h *taskScopedMCPHandler) Dispatch(ctx context.Context, msg *ws.Message) (*
|
||||
return h.inner.Dispatch(scoped, msg)
|
||||
}
|
||||
|
||||
// mcpHandlerFor returns the MCP handler for one execution's stream, scoped to
|
||||
// the task's owning user when per-user scoping has been wired. Without a
|
||||
// scoper (tests, or the runtime tier used standalone) the handler is passed
|
||||
// through unchanged.
|
||||
// mcpHandlerFor returns the MCP handler for one execution's stream. The
|
||||
// backend-owned execution identity is always attached; user identity is also
|
||||
// attached when per-user scoping has been wired.
|
||||
func (sm *StreamManager) mcpHandlerFor(execution *AgentExecution) agentctl.MCPHandler {
|
||||
if sm.mcpHandler == nil || sm.mcpIdentityScoper == nil || execution.TaskID == "" {
|
||||
if sm.mcpHandler == nil || execution.TaskID == "" {
|
||||
return sm.mcpHandler
|
||||
}
|
||||
return &taskScopedMCPHandler{
|
||||
inner: sm.mcpHandler,
|
||||
scope: sm.mcpIdentityScoper,
|
||||
taskID: execution.TaskID,
|
||||
logger: sm.logger,
|
||||
inner: sm.mcpHandler,
|
||||
scope: sm.mcpIdentityScoper,
|
||||
executionID: execution.ID,
|
||||
taskID: execution.TaskID,
|
||||
sessionID: execution.SessionID,
|
||||
logger: sm.logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/auth/authn"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
@@ -55,7 +56,7 @@ func TestMCPHandlerForScopesToExecutionTask(t *testing.T) {
|
||||
return authn.WithIdentity(ctx, authn.Identity{UserID: "owner-of-" + taskID, Role: authn.RoleMember}), nil
|
||||
})
|
||||
|
||||
handler := sm.mcpHandlerFor(&AgentExecution{ID: "exec-1", TaskID: "task-a"})
|
||||
handler := sm.mcpHandlerFor(&AgentExecution{ID: "exec-1", TaskID: "task-a", SessionID: "session-a"})
|
||||
resp, err := handler.Dispatch(context.Background(), mcpRequest(t, map[string]interface{}{}))
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
@@ -74,6 +75,13 @@ func TestMCPHandlerForScopesToExecutionTask(t *testing.T) {
|
||||
if identity.UserID != "owner-of-task-a" {
|
||||
t.Errorf("UserID = %q, want owner-of-task-a", identity.UserID)
|
||||
}
|
||||
executionContext, ok := streams.MCPExecutionContextFromContext(inner.gotCtx)
|
||||
if !ok {
|
||||
t.Fatal("tool handlers received no execution context")
|
||||
}
|
||||
if executionContext.TaskID != "task-a" || executionContext.SessionID != "session-a" {
|
||||
t.Errorf("execution context = %#v, want task-a/session-a", executionContext)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPHandlerForIgnoresPayloadSessionID is the privilege-escalation pin. The
|
||||
@@ -87,7 +95,7 @@ func TestMCPHandlerForIgnoresPayloadSessionID(t *testing.T) {
|
||||
return ctx, nil
|
||||
})
|
||||
|
||||
handler := sm.mcpHandlerFor(&AgentExecution{ID: "exec-1", TaskID: "task-a"})
|
||||
handler := sm.mcpHandlerFor(&AgentExecution{ID: "exec-1", TaskID: "task-a", SessionID: "session-a"})
|
||||
_, err := handler.Dispatch(context.Background(), mcpRequest(t, map[string]interface{}{
|
||||
"session_id": "session-of-victim",
|
||||
"task_id": "task-victim",
|
||||
@@ -109,7 +117,7 @@ func TestMCPHandlerForDeniesWhenScopingFails(t *testing.T) {
|
||||
return nil, errors.New("db unavailable")
|
||||
})
|
||||
|
||||
handler := sm.mcpHandlerFor(&AgentExecution{ID: "exec-1", TaskID: "task-a"})
|
||||
handler := sm.mcpHandlerFor(&AgentExecution{ID: "exec-1", TaskID: "task-a", SessionID: "session-a"})
|
||||
resp, err := handler.Dispatch(context.Background(), mcpRequest(t, map[string]interface{}{}))
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
@@ -123,14 +131,35 @@ func TestMCPHandlerForDeniesWhenScopingFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPHandlerForPassesThroughWithoutScoper keeps single-user instances and
|
||||
// isolated tests on the original unwrapped handler.
|
||||
func TestMCPHandlerForPassesThroughWithoutScoper(t *testing.T) {
|
||||
func TestMCPHandlerForScopesTaskWithoutSessionID(t *testing.T) {
|
||||
inner := &recordingMCPHandler{}
|
||||
sm := newMCPStreamManager(t, inner, func(ctx context.Context, taskID string) (context.Context, error) {
|
||||
return authn.WithIdentity(ctx, authn.Identity{UserID: "owner-of-" + taskID, Role: authn.RoleMember}), nil
|
||||
})
|
||||
|
||||
handler := sm.mcpHandlerFor(&AgentExecution{ID: "exec-1", TaskID: "task-a"})
|
||||
if _, err := handler.Dispatch(context.Background(), mcpRequest(t, nil)); err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
identity, ok := authn.IdentityFromContext(inner.gotCtx)
|
||||
if !ok || identity.UserID != "owner-of-task-a" {
|
||||
t.Fatalf("identity = %#v, present = %v; want task owner", identity, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPHandlerForCarriesExecutionWithoutScoper keeps standalone runtimes
|
||||
// execution-bound even when user identity scoping is not configured.
|
||||
func TestMCPHandlerForCarriesExecutionWithoutScoper(t *testing.T) {
|
||||
inner := &recordingMCPHandler{}
|
||||
sm := newMCPStreamManager(t, inner, nil)
|
||||
|
||||
if got := sm.mcpHandlerFor(&AgentExecution{ID: "exec-1", TaskID: "task-a"}); got != inner {
|
||||
t.Errorf("handler = %T, want the unwrapped inner handler", got)
|
||||
handler := sm.mcpHandlerFor(&AgentExecution{ID: "exec-1", TaskID: "task-a", SessionID: "session-a"})
|
||||
if _, err := handler.Dispatch(context.Background(), mcpRequest(t, nil)); err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
execution, ok := streams.MCPExecutionContextFromContext(inner.gotCtx)
|
||||
if !ok || execution.ExecutionID != "exec-1" {
|
||||
t.Errorf("execution context = %#v, want exec-1", execution)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/server/config"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/process"
|
||||
"github.com/kandev/kandev/internal/mcp/plugintools"
|
||||
mcpserver "github.com/kandev/kandev/internal/mcp/server"
|
||||
)
|
||||
|
||||
@@ -109,3 +110,33 @@ func TestHandleSetMcpProviders_NormalizesAndPreservesMode(t *testing.T) {
|
||||
t.Fatalf("providers = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSetPluginToolsReplacesLiveCatalog(t *testing.T) {
|
||||
s := newTestServerWithMCP(t)
|
||||
definition := plugintools.Definition{
|
||||
PluginID: "echo", LocalName: "echo", ExposedName: plugintools.ExposedName("echo", "echo"),
|
||||
Description: "Echo", Surfaces: []string{plugintools.SurfaceKanban},
|
||||
InputSchema: []byte(`{"type":"object"}`),
|
||||
}
|
||||
body, err := json.Marshal(plugintools.Snapshot{Generation: "g", Revision: 1, Tools: []plugintools.Definition{definition}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/mcp/plugin-tools", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Generation string `json:"generation"`
|
||||
Revision uint64 `json:"revision"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Generation != "g" || response.Revision != 1 {
|
||||
t.Fatalf("response = %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/common/httpmw"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
lspinstaller "github.com/kandev/kandev/internal/lsp/installer"
|
||||
"github.com/kandev/kandev/internal/mcp/plugintools"
|
||||
mcpproviders "github.com/kandev/kandev/internal/mcp/providers"
|
||||
"github.com/kandev/kandev/internal/mcp/server"
|
||||
"github.com/kandev/kandev/internal/system/metrics"
|
||||
@@ -213,6 +214,7 @@ func (s *Server) setupRoutes() {
|
||||
s.mcpServer.RegisterRoutes(s.router)
|
||||
api.PUT("/mcp/mode", s.handleSetMcpMode)
|
||||
api.PUT("/mcp/providers", s.handleSetMcpProviders)
|
||||
api.PUT("/mcp/plugin-tools", s.handleSetPluginTools)
|
||||
}
|
||||
|
||||
// pprof + memory stats (enabled via KANDEV_DEBUG_PPROF_ENABLED=true)
|
||||
@@ -345,6 +347,19 @@ func (s *Server) handleSetMcpProviders(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"mcp_providers": providers})
|
||||
}
|
||||
|
||||
func (s *Server) handleSetPluginTools(c *gin.Context) {
|
||||
var snapshot plugintools.Snapshot
|
||||
if err := c.ShouldBindJSON(&snapshot); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.mcpServer.SetPluginTools(snapshot); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"generation": snapshot.Generation, "revision": snapshot.Revision})
|
||||
}
|
||||
|
||||
// Status response
|
||||
type StatusResponse struct {
|
||||
AgentStatus string `json:"agent_status"`
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package streams
|
||||
|
||||
import "context"
|
||||
|
||||
type mcpExecutionContextKey struct{}
|
||||
|
||||
// MCPExecutionContext identifies the backend-owned execution behind one
|
||||
// in-session MCP stream. Agent-controlled request payloads cannot override it.
|
||||
type MCPExecutionContext struct {
|
||||
ExecutionID string
|
||||
TaskID string
|
||||
SessionID string
|
||||
}
|
||||
|
||||
// WithMCPExecutionContext attaches trusted execution identity to a dispatch.
|
||||
func WithMCPExecutionContext(ctx context.Context, execution MCPExecutionContext) context.Context {
|
||||
return context.WithValue(ctx, mcpExecutionContextKey{}, execution)
|
||||
}
|
||||
|
||||
// MCPExecutionContextFromContext returns trusted execution identity.
|
||||
func MCPExecutionContextFromContext(ctx context.Context) (MCPExecutionContext, bool) {
|
||||
execution, ok := ctx.Value(mcpExecutionContextKey{}).(MCPExecutionContext)
|
||||
return execution, ok && execution.TaskID != "" && execution.SessionID != ""
|
||||
}
|
||||
@@ -1529,6 +1529,7 @@ func registerMCPAndDebugRoutes(
|
||||
p.taskSvc, wfCtrl,
|
||||
clarificationStore, clarificationCanceller, p.msgCreator, p.taskRepo, p.taskRepo, p.eventBus, planService, walkthroughService, p.orchestratorSvc, p.orchestratorSvc.GetMessageQueue(), p.log,
|
||||
)
|
||||
mcpHandlers.SetPluginService(p.services.Plugins)
|
||||
mcpHandlers.SetRemoteContributionService(newRemoteContributionCoordinator(p.services.GitHub, p.services.GitLab))
|
||||
// Wire config-mode dependencies for agent-native configuration
|
||||
mcpHandlers.SetConfigDeps(p.services.Workflow, p.agentSettingsController, p.mcpConfigSvc)
|
||||
|
||||
@@ -669,7 +669,7 @@ func startAgentInfrastructure(
|
||||
// Start the plugin system's event delivery and health monitor
|
||||
// background loops.
|
||||
if services.Plugins != nil {
|
||||
startPluginsSubsystems(ctx, services.Plugins, eventBus, log, addCleanup)
|
||||
startPluginsSubsystems(ctx, services.Plugins, lifecycleMgr, eventBus, log, addCleanup)
|
||||
}
|
||||
|
||||
return startGatewayAndServe(ctx, cfg, log, eventBus, agentRuntimeAvailability, dbPool, repos, services,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package backendapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/mcp/plugintools"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type pluginToolCatalogSource interface {
|
||||
AgentToolCatalog() (plugintools.Snapshot, error)
|
||||
}
|
||||
|
||||
type pluginToolCatalogLifecycle interface {
|
||||
SetPluginToolsForAllExecutions(context.Context, plugintools.Snapshot) error
|
||||
}
|
||||
|
||||
type pluginToolCatalogRefresher struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
source pluginToolCatalogSource
|
||||
lifecycle pluginToolCatalogLifecycle
|
||||
logger *logger.Logger
|
||||
requests chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func newPluginToolCatalogRefresher(ctx context.Context, source pluginToolCatalogSource, lifecycle pluginToolCatalogLifecycle, log *logger.Logger) *pluginToolCatalogRefresher {
|
||||
workerCtx, cancel := context.WithCancel(ctx)
|
||||
r := &pluginToolCatalogRefresher{
|
||||
ctx: workerCtx, cancel: cancel, source: source, lifecycle: lifecycle,
|
||||
logger: log, requests: make(chan struct{}, 1),
|
||||
}
|
||||
r.wg.Add(1)
|
||||
go r.run()
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *pluginToolCatalogRefresher) NotifyAgentToolCatalogChanged() {
|
||||
select {
|
||||
case r.requests <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (r *pluginToolCatalogRefresher) Stop() {
|
||||
r.cancel()
|
||||
r.wg.Wait()
|
||||
}
|
||||
|
||||
func (r *pluginToolCatalogRefresher) run() {
|
||||
defer r.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
return
|
||||
case <-r.requests:
|
||||
snapshot, err := r.source.AgentToolCatalog()
|
||||
if err == nil && r.lifecycle != nil {
|
||||
err = r.lifecycle.SetPluginToolsForAllExecutions(r.ctx, snapshot)
|
||||
}
|
||||
if err != nil && r.logger != nil {
|
||||
r.logger.Warn("live plugin tool catalog refresh failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package backendapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/mcp/plugintools"
|
||||
)
|
||||
|
||||
type pluginToolSourceFake struct{ snapshot plugintools.Snapshot }
|
||||
|
||||
func (f pluginToolSourceFake) AgentToolCatalog() (plugintools.Snapshot, error) {
|
||||
return f.snapshot, nil
|
||||
}
|
||||
|
||||
type pluginToolLifecycleFake struct{ calls chan plugintools.Snapshot }
|
||||
|
||||
func (f pluginToolLifecycleFake) SetPluginToolsForAllExecutions(_ context.Context, snapshot plugintools.Snapshot) error {
|
||||
f.calls <- snapshot
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPluginToolCatalogRefresherPushesLifecycleNotification(t *testing.T) {
|
||||
want := plugintools.Snapshot{Generation: "g", Revision: 4}
|
||||
lifecycle := pluginToolLifecycleFake{calls: make(chan plugintools.Snapshot, 1)}
|
||||
refresher := newPluginToolCatalogRefresher(context.Background(), pluginToolSourceFake{snapshot: want}, lifecycle, nil)
|
||||
t.Cleanup(refresher.Stop)
|
||||
refresher.NotifyAgentToolCatalogChanged()
|
||||
select {
|
||||
case got := <-lifecycle.calls:
|
||||
if got.Generation != want.Generation || got.Revision != want.Revision {
|
||||
t.Fatalf("snapshot = %#v, want %#v", got, want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("catalog notification was not pushed")
|
||||
}
|
||||
}
|
||||
@@ -42,13 +42,16 @@ func (a pluginActivePluginsAdapter) ActivePlugins() []delivery.PluginRecord {
|
||||
// how the Jira/Linear/Sentry pollers are started in
|
||||
// startAgentInfrastructure: construction happens in initPluginsService
|
||||
// (services.go), lifecycle start happens here once ctx/addCleanup exist.
|
||||
func startPluginsSubsystems(ctx context.Context, svc *plugins.Service, eventBus bus.EventBus, log *logger.Logger, addCleanup func(func() error)) {
|
||||
func startPluginsSubsystems(ctx context.Context, svc *plugins.Service, lifecycle pluginToolCatalogLifecycle, eventBus bus.EventBus, log *logger.Logger, addCleanup func(func() error)) {
|
||||
transport := plugins.NewRuntimeTransport(svc.Runtime())
|
||||
deliverer := delivery.New(eventBus, transport, pluginActivePluginsAdapter{svc: svc}, log)
|
||||
svc.SetDeliverer(deliverer)
|
||||
deliverer.Refresh()
|
||||
addCleanup(func() error { deliverer.Stop(); return nil })
|
||||
addCleanup(func() error { svc.Shutdown(); return nil })
|
||||
toolRefresher := newPluginToolCatalogRefresher(ctx, svc, lifecycle, log)
|
||||
svc.SetAgentToolCatalogListener(toolRefresher)
|
||||
addCleanup(func() error { toolRefresher.Stop(); return nil })
|
||||
|
||||
svc.StartActivePlugins(ctx)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/orchestrator"
|
||||
"github.com/kandev/kandev/internal/orchestrator/executor"
|
||||
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
|
||||
"github.com/kandev/kandev/internal/plugins"
|
||||
promptservice "github.com/kandev/kandev/internal/prompts/service"
|
||||
"github.com/kandev/kandev/internal/sysprompt"
|
||||
"github.com/kandev/kandev/internal/task/dto"
|
||||
@@ -235,6 +236,7 @@ type Handlers struct {
|
||||
// registered — see registerReviewHandlers.
|
||||
reviewService *service.ReviewService
|
||||
reviewRunner ReviewRunner
|
||||
pluginSvc *plugins.Service
|
||||
|
||||
// Optional task-bound GitHub PR automation controls.
|
||||
taskPRAutomation TaskPRAutomationService
|
||||
@@ -329,12 +331,21 @@ func (h *Handlers) SetConfigDeps(
|
||||
h.mcpConfigSvc = mcpConfigSvc
|
||||
}
|
||||
|
||||
// SetPluginService wires the plugin agent-tool catalog and invocation bridge.
|
||||
func (h *Handlers) SetPluginService(svc *plugins.Service) {
|
||||
h.pluginSvc = svc
|
||||
}
|
||||
|
||||
// RegisterHandlers registers all MCP handlers with the dispatcher.
|
||||
func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) {
|
||||
before := d.HandlerCount()
|
||||
|
||||
// Task-mode handlers (always registered)
|
||||
d.RegisterFunc(ws.ActionMCPListWorkspaces, h.handleListWorkspaces)
|
||||
if h.pluginSvc != nil {
|
||||
d.RegisterFunc(ws.ActionMCPListPluginTools, h.handleListPluginTools)
|
||||
d.RegisterFunc(ws.ActionMCPInvokePluginTool, h.handleInvokePluginTool)
|
||||
}
|
||||
d.RegisterFunc(ws.ActionMCPListWorkflows, h.handleListWorkflows)
|
||||
d.RegisterFunc(ws.ActionMCPListWorkflowSteps, h.handleListWorkflowSteps)
|
||||
d.RegisterFunc(ws.ActionMCPListRepositories, h.handleListRepositories)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/plugins"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
type pluginToolsListRequest struct {
|
||||
Surface string `json:"surface"`
|
||||
}
|
||||
|
||||
type pluginToolInvocationRequest struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
LocalName string `json:"local_name"`
|
||||
InvocationID string `json:"invocation_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Surface string `json:"surface"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
|
||||
func (h *Handlers) handleListPluginTools(_ context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req pluginToolsListRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid request: "+err.Error(), nil)
|
||||
}
|
||||
snapshot, err := h.pluginSvc.AgentToolCatalog()
|
||||
if err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
|
||||
}
|
||||
if req.Surface != "" {
|
||||
filtered := snapshot
|
||||
filtered.Tools = filtered.Tools[:0]
|
||||
for _, tool := range snapshot.Tools {
|
||||
for _, surface := range tool.Surfaces {
|
||||
if surface == req.Surface {
|
||||
filtered.Tools = append(filtered.Tools, tool)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
snapshot = filtered
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, snapshot)
|
||||
}
|
||||
|
||||
func (h *Handlers) handleInvokePluginTool(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req pluginToolInvocationRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid request: "+err.Error(), nil)
|
||||
}
|
||||
if req.PluginID == "" || req.LocalName == "" || req.Surface == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "plugin_id, local_name, and surface are required", nil)
|
||||
}
|
||||
invocation, contextErrorResponse, err := h.resolvePluginToolInvocationContext(ctx, msg, req)
|
||||
if err != nil || contextErrorResponse != nil {
|
||||
return contextErrorResponse, err
|
||||
}
|
||||
result, err := h.pluginSvc.InvokeAgentTool(ctx, req.PluginID, req.LocalName, req.Arguments, invocation)
|
||||
if err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fmt.Sprintf("plugin tool invocation failed: %v", err), nil)
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, map[string]any{
|
||||
"text": result.Text, "structured_content": result.StructuredContent, "is_error": result.IsError,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) resolvePluginToolInvocationContext(ctx context.Context, msg *ws.Message, req pluginToolInvocationRequest) (plugins.AgentToolInvocationContext, *ws.Message, error) {
|
||||
execution, ok := streams.MCPExecutionContextFromContext(ctx)
|
||||
if !ok || h.sessionRepo == nil || h.taskSvc == nil {
|
||||
return pluginToolContextError(msg, ws.ErrorCodeInternalError, "plugin tool execution context is unavailable")
|
||||
}
|
||||
taskID, sessionID := execution.TaskID, execution.SessionID
|
||||
if pluginToolRequestContextMismatch(req, execution) {
|
||||
return pluginToolContextError(msg, ws.ErrorCodeBadRequest, "request context does not match the running execution")
|
||||
}
|
||||
session, err := h.sessionRepo.GetTaskSession(ctx, sessionID)
|
||||
if err != nil || session == nil || session.TaskID != taskID {
|
||||
return pluginToolContextError(msg, ws.ErrorCodeBadRequest, "running session is not bound to task")
|
||||
}
|
||||
task, err := h.taskSvc.GetTask(ctx, taskID)
|
||||
if err != nil || task == nil || task.WorkspaceID == "" {
|
||||
return pluginToolContextError(msg, ws.ErrorCodeInternalError, "failed to resolve the execution workspace")
|
||||
}
|
||||
if req.WorkspaceID != "" && req.WorkspaceID != task.WorkspaceID {
|
||||
return pluginToolContextError(msg, ws.ErrorCodeBadRequest, "request context does not match the running execution")
|
||||
}
|
||||
return plugins.AgentToolInvocationContext{
|
||||
InvocationID: req.InvocationID, TaskID: taskID, SessionID: sessionID,
|
||||
WorkspaceID: task.WorkspaceID, Surface: req.Surface,
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
func pluginToolRequestContextMismatch(req pluginToolInvocationRequest, execution streams.MCPExecutionContext) bool {
|
||||
return (req.TaskID != "" && req.TaskID != execution.TaskID) ||
|
||||
(req.SessionID != "" && req.SessionID != execution.SessionID)
|
||||
}
|
||||
|
||||
func pluginToolContextError(msg *ws.Message, code, message string) (plugins.AgentToolInvocationContext, *ws.Message, error) {
|
||||
response, err := ws.NewError(msg.ID, msg.Action, code, message, nil)
|
||||
return plugins.AgentToolInvocationContext{}, response, err
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/plugins"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHandleInvokePluginToolRejectsForeignExecutionContext(t *testing.T) {
|
||||
svc, repo := newTestTaskService(t)
|
||||
seedMCPHandlerSession(t, repo, "task-owned", "session-owned", models.TaskSessionStateRunning)
|
||||
now := time.Now().UTC()
|
||||
require.NoError(t, repo.CreateTask(context.Background(), &models.Task{
|
||||
ID: "task-foreign", WorkspaceID: "ws-state-event", Title: "Foreign",
|
||||
State: v1.TaskStateInProgress, CreatedAt: now, UpdatedAt: now,
|
||||
}))
|
||||
require.NoError(t, repo.CreateTaskSession(context.Background(), &models.TaskSession{
|
||||
ID: "session-foreign", TaskID: "task-foreign", State: models.TaskSessionStateRunning,
|
||||
StartedAt: now, UpdatedAt: now,
|
||||
}))
|
||||
|
||||
h := NewHandlers(svc, nil, nil, nil, nil, repo, repo, nil, nil, nil, nil, nil, testLogger(t))
|
||||
h.SetPluginService(plugins.NewService(nil, plugins.NewRegistry(), nil, testLogger(t)))
|
||||
ctx := streams.WithMCPExecutionContext(context.Background(), streams.MCPExecutionContext{
|
||||
ExecutionID: "execution-owned", TaskID: "task-owned", SessionID: "session-owned",
|
||||
})
|
||||
resp, err := h.handleInvokePluginTool(ctx, makeWSMessage(t, ws.ActionMCPInvokePluginTool, map[string]any{
|
||||
"plugin_id": "echo", "local_name": "echo", "surface": "kanban-task",
|
||||
"task_id": "task-foreign", "session_id": "session-foreign", "workspace_id": "ws-state-event",
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ws.MessageTypeError, resp.Type)
|
||||
require.Contains(t, string(resp.Payload), "does not match the running execution")
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package plugintools
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
SurfaceKanban = "kanban-task"
|
||||
SurfaceOffice = "office-task"
|
||||
)
|
||||
|
||||
const maxExposedNameLength = 64
|
||||
|
||||
// Definition is the backend-owned descriptor transported to agentctl. Schemas
|
||||
// are raw JSON so agentctl can register them without reinterpreting YAML.
|
||||
type Definition struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
PluginDisplayName string `json:"plugin_display_name,omitempty"`
|
||||
LocalName string `json:"local_name"`
|
||||
ExposedName string `json:"exposed_name"`
|
||||
Description string `json:"description"`
|
||||
Surfaces []string `json:"surfaces"`
|
||||
InputSchema json.RawMessage `json:"input_schema"`
|
||||
OutputSchema json.RawMessage `json:"output_schema,omitempty"`
|
||||
ReadOnlyHint bool `json:"read_only_hint"`
|
||||
DestructiveHint bool `json:"destructive_hint"`
|
||||
IdempotentHint bool `json:"idempotent_hint"`
|
||||
OpenWorldHint bool `json:"open_world_hint"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Generation string `json:"generation"`
|
||||
Revision uint64 `json:"revision"`
|
||||
Tools []Definition `json:"tools"`
|
||||
}
|
||||
|
||||
func ExposedName(pluginID, localName string) string {
|
||||
slug := pluginIDSlug(pluginID)
|
||||
readable := "kandev_" + slug + "_" + localName
|
||||
if len(readable) <= maxExposedNameLength {
|
||||
return readable
|
||||
}
|
||||
|
||||
// Keep long names mostly readable, using a short stable suffix only when
|
||||
// the provider-safe length budget requires disambiguation.
|
||||
digest := sha256.Sum256([]byte(pluginID))
|
||||
suffix := "_" + hex.EncodeToString(digest[:4])
|
||||
available := maxExposedNameLength - len("kandev__") - len(localName) - len(suffix)
|
||||
if available < 1 {
|
||||
available = 1
|
||||
}
|
||||
if len(slug) > available {
|
||||
slug = slug[:available]
|
||||
}
|
||||
return "kandev_" + slug + "_" + localName + suffix
|
||||
}
|
||||
|
||||
func pluginIDSlug(pluginID string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(pluginID) {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune('_')
|
||||
} else {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "_")
|
||||
}
|
||||
|
||||
func Normalize(snapshot Snapshot) Snapshot {
|
||||
snapshot.Tools = append([]Definition(nil), snapshot.Tools...)
|
||||
for i := range snapshot.Tools {
|
||||
snapshot.Tools[i].Surfaces = append([]string(nil), snapshot.Tools[i].Surfaces...)
|
||||
sort.Strings(snapshot.Tools[i].Surfaces)
|
||||
}
|
||||
sort.Slice(snapshot.Tools, func(i, j int) bool {
|
||||
return snapshot.Tools[i].ExposedName < snapshot.Tools[j].ExposedName
|
||||
})
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func Equal(left, right Snapshot) bool {
|
||||
l, r := Normalize(left), Normalize(right)
|
||||
bl, _ := json.Marshal(l)
|
||||
br, _ := json.Marshal(r)
|
||||
return string(bl) == string(br)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package plugintools
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExposedNameIsReadableAndProviderSafe(t *testing.T) {
|
||||
got := ExposedName("task-tags.v1", "add_tag")
|
||||
if got != "kandev_task_tags_v1_add_tag" {
|
||||
t.Fatalf("ExposedName() = %q", got)
|
||||
}
|
||||
if len(got) > 64 {
|
||||
t.Fatalf("ExposedName() length = %d, want <= 64", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExposedNameUsesHashOnlyForLongPluginIDs(t *testing.T) {
|
||||
got := ExposedName("a-very-long-plugin-id-that-exceeds-the-readable-name-budget", "run")
|
||||
if len(got) > 64 {
|
||||
t.Fatalf("ExposedName() length = %d, want <= 64", len(got))
|
||||
}
|
||||
if got == "kandev_a_very_long_plugin_id_that_exceeds_the_readable_name_budget_run" {
|
||||
t.Fatal("long exposed name was not bounded")
|
||||
}
|
||||
if got[len(got)-9] != '_' {
|
||||
t.Fatalf("long exposed name = %q, want hash suffix", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSortsToolsAndSurfaces(t *testing.T) {
|
||||
got := Normalize(Snapshot{Tools: []Definition{
|
||||
{ExposedName: "b", Surfaces: []string{SurfaceOffice, SurfaceKanban}},
|
||||
{ExposedName: "a", Surfaces: []string{SurfaceOffice}},
|
||||
}})
|
||||
if got.Tools[0].ExposedName != "a" || got.Tools[1].ExposedName != "b" {
|
||||
t.Fatalf("tools not sorted: %+v", got.Tools)
|
||||
}
|
||||
if got.Tools[1].Surfaces[0] != SurfaceKanban {
|
||||
t.Fatalf("surfaces not sorted: %+v", got.Tools[1].Surfaces)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,11 @@ type ChannelBackendClient struct {
|
||||
requestCh chan *ws.Message
|
||||
pending map[string]chan *ws.Message
|
||||
pendingMu sync.Mutex
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
closeMu sync.Mutex
|
||||
closed bool
|
||||
publishWG sync.WaitGroup
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
@@ -47,6 +52,7 @@ func NewChannelBackendClient(log *logger.Logger) *ChannelBackendClient {
|
||||
return &ChannelBackendClient{
|
||||
requestCh: make(chan *ws.Message, 100),
|
||||
pending: make(map[string]chan *ws.Message),
|
||||
done: make(chan struct{}),
|
||||
logger: clientLogger,
|
||||
}
|
||||
}
|
||||
@@ -78,6 +84,16 @@ func (c *ChannelBackendClient) HandleResponse(msg *ws.Message) {
|
||||
// RequestPayload sends a request to the backend and unmarshals the response.
|
||||
// The request will be cancelled if the context is cancelled or if Reset() is called.
|
||||
func (c *ChannelBackendClient) RequestPayload(ctx context.Context, action string, payload, result interface{}) error {
|
||||
if !c.beginPublish() {
|
||||
return fmt.Errorf("MCP backend client is closed")
|
||||
}
|
||||
publishing := true
|
||||
defer func() {
|
||||
if publishing {
|
||||
c.publishWG.Done()
|
||||
}
|
||||
}()
|
||||
|
||||
id := uuid.New().String()
|
||||
start := time.Now()
|
||||
|
||||
@@ -95,7 +111,7 @@ func (c *ChannelBackendClient) RequestPayload(ctx context.Context, action string
|
||||
c.logger.Debug("sending MCP request through agent stream",
|
||||
zap.String("request_id", id),
|
||||
zap.String("action", action),
|
||||
zap.Any("payload", payload))
|
||||
zap.Any("payload", backendPayloadForLog(action, payload)))
|
||||
|
||||
// Ensure cleanup on exit
|
||||
defer func() {
|
||||
@@ -108,6 +124,8 @@ func (c *ChannelBackendClient) RequestPayload(ctx context.Context, action string
|
||||
select {
|
||||
case c.requestCh <- msg:
|
||||
// Request sent
|
||||
case <-c.done:
|
||||
return fmt.Errorf("MCP backend client is closed")
|
||||
case <-ctx.Done():
|
||||
c.logger.Debug("MCP request cancelled before send",
|
||||
zap.String("request_id", id),
|
||||
@@ -122,6 +140,8 @@ func (c *ChannelBackendClient) RequestPayload(ctx context.Context, action string
|
||||
zap.Duration("duration", time.Since(start)))
|
||||
return fmt.Errorf("timeout sending request to agent stream")
|
||||
}
|
||||
publishing = false
|
||||
c.publishWG.Done()
|
||||
|
||||
// Wait for response
|
||||
select {
|
||||
@@ -162,9 +182,38 @@ func (c *ChannelBackendClient) RequestPayload(ctx context.Context, action string
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Error(ctx.Err()))
|
||||
return ctx.Err()
|
||||
case <-c.done:
|
||||
return fmt.Errorf("MCP backend client is closed")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ChannelBackendClient) beginPublish() bool {
|
||||
c.closeMu.Lock()
|
||||
defer c.closeMu.Unlock()
|
||||
if c.closed {
|
||||
return false
|
||||
}
|
||||
c.publishWG.Add(1)
|
||||
return true
|
||||
}
|
||||
|
||||
func backendPayloadForLog(action string, payload interface{}) interface{} {
|
||||
if action != ws.ActionMCPInvokePluginTool {
|
||||
return payload
|
||||
}
|
||||
values, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return "<redacted>"
|
||||
}
|
||||
safe := make(map[string]any, len(values))
|
||||
for key, value := range values {
|
||||
if key != "arguments" {
|
||||
safe[key] = value
|
||||
}
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
// Reset clears all pending MCP requests.
|
||||
// This should be called when starting a new ACP session to prevent
|
||||
// stale requests from a previous session from interfering.
|
||||
@@ -179,7 +228,13 @@ func (c *ChannelBackendClient) Reset() {
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the request channel.
|
||||
// Close prevents new requests and cancels pending requests.
|
||||
func (c *ChannelBackendClient) Close() {
|
||||
close(c.requestCh)
|
||||
c.closeOnce.Do(func() {
|
||||
c.closeMu.Lock()
|
||||
c.closed = true
|
||||
close(c.done)
|
||||
c.closeMu.Unlock()
|
||||
})
|
||||
c.publishWG.Wait()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func TestChannelBackendClientCloseBeforeRequestDoesNotPublish(t *testing.T) {
|
||||
client := NewChannelBackendClient(nil)
|
||||
t.Cleanup(client.Close)
|
||||
client.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
t.Cleanup(cancel)
|
||||
err := client.RequestPayload(ctx, "test.action", map[string]any{"value": "test"}, nil)
|
||||
require.EqualError(t, err, "MCP backend client is closed")
|
||||
|
||||
select {
|
||||
case msg := <-client.GetRequestChannel():
|
||||
t.Fatalf("closed client published request %q", msg.Action)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelBackendClientCloseReleasesPublishedRequest(t *testing.T) {
|
||||
client := NewChannelBackendClient(nil)
|
||||
t.Cleanup(client.Close)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- client.RequestPayload(context.Background(), "test.action", nil, nil)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-client.GetRequestChannel():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("request was not published")
|
||||
}
|
||||
client.Close()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
require.EqualError(t, err, "MCP backend client is closed")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("published request remained blocked after client close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelBackendClientRedactsPluginInvocationPayload(t *testing.T) {
|
||||
core, observed := observer.New(zap.DebugLevel)
|
||||
log, err := logger.NewFromZap(zap.New(core))
|
||||
require.NoError(t, err)
|
||||
client := NewChannelBackendClient(log)
|
||||
t.Cleanup(client.Close)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_ = client.RequestPayload(ctx, ws.ActionMCPInvokePluginTool, map[string]any{
|
||||
"plugin_id": "echo", "arguments": map[string]any{"token": "secret-value"},
|
||||
}, nil)
|
||||
|
||||
entries := observed.FilterMessage("sending MCP request through agent stream").All()
|
||||
require.Len(t, entries, 1)
|
||||
payload, ok := entries[0].ContextMap()["payload"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "echo", payload["plugin_id"])
|
||||
_, loggedArguments := payload["arguments"]
|
||||
require.False(t, loggedArguments)
|
||||
}
|
||||
@@ -14,8 +14,10 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/mcp/plugintools"
|
||||
mcpprofile "github.com/kandev/kandev/internal/mcp/profile"
|
||||
mcpproviders "github.com/kandev/kandev/internal/mcp/providers"
|
||||
"github.com/kandev/kandev/internal/mcp/toolschema"
|
||||
"github.com/kandev/kandev/internal/task/service"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
@@ -89,26 +91,30 @@ func normalizeMode(mode string) string {
|
||||
|
||||
// Server wraps the MCP server with backend client for communication.
|
||||
type Server struct {
|
||||
backend BackendClient
|
||||
sessionID string
|
||||
taskID string
|
||||
disableAskQuestion bool
|
||||
mode string // "task" (default), "task-title-pending", "config", or "office"
|
||||
mcpProviders []string
|
||||
profile mcpprofile.Context
|
||||
mcpServer *server.MCPServer
|
||||
sseServer *server.SSEServer
|
||||
httpServer *server.StreamableHTTPServer
|
||||
logger *logger.Logger
|
||||
mcpLogger *zap.Logger // optional file logger for MCP debug traces
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
attachmentMu sync.RWMutex
|
||||
attachmentAttempt streams.MCPAttachmentAttempt
|
||||
attachmentAttempts map[string]streams.MCPAttachmentAttempt
|
||||
attachmentReporter func(streams.MCPAttachmentEvidence)
|
||||
validatorMu sync.RWMutex
|
||||
toolValidators map[string]toolArgumentValidator
|
||||
backend BackendClient
|
||||
sessionID string
|
||||
taskID string
|
||||
disableAskQuestion bool
|
||||
mode string // "task" (default), "task-title-pending", "config", or "office"
|
||||
mcpProviders []string
|
||||
profile mcpprofile.Context
|
||||
mcpServer *server.MCPServer
|
||||
sseServer *server.SSEServer
|
||||
httpServer *server.StreamableHTTPServer
|
||||
logger *logger.Logger
|
||||
mcpLogger *zap.Logger // optional file logger for MCP debug traces
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
attachmentMu sync.RWMutex
|
||||
attachmentAttempt streams.MCPAttachmentAttempt
|
||||
attachmentAttempts map[string]streams.MCPAttachmentAttempt
|
||||
attachmentReporter func(streams.MCPAttachmentEvidence)
|
||||
validatorMu sync.RWMutex
|
||||
toolValidators map[string]toolArgumentValidator
|
||||
pluginToolsUpdateMu sync.Mutex
|
||||
pluginToolsMu sync.Mutex
|
||||
pluginTools plugintools.Snapshot
|
||||
pluginToolsReady bool
|
||||
}
|
||||
|
||||
// New creates a new MCP server for agentctl.
|
||||
@@ -229,6 +235,9 @@ func newServerWithProfile(backend BackendClient, sessionID, taskID string, log *
|
||||
hooks.AddAfterListTools(func(ctx context.Context, _ any, _ *mcp.ListToolsRequest, result *mcp.ListToolsResult) {
|
||||
s.observeMCPConnection(mcpConnectionID(ctx), streams.MCPAttachmentEvidenceToolsListObserved, len(result.Tools), "")
|
||||
})
|
||||
hooks.AddBeforeListTools(func(ctx context.Context, _ any, _ *mcp.ListToolsRequest) {
|
||||
s.syncPluginTools(ctx)
|
||||
})
|
||||
hooks.AddAfterCallTool(func(ctx context.Context, _ any, _ *mcp.CallToolRequest, _ *mcp.CallToolResult) {
|
||||
s.observeMCPConnection(mcpConnectionID(ctx), streams.MCPAttachmentEvidenceToolCallObserved, 0, "")
|
||||
})
|
||||
@@ -405,18 +414,26 @@ func (s *Server) Close(ctx context.Context) error {
|
||||
|
||||
// wrapHandler wraps a tool handler with debug logging for tracing MCP calls.
|
||||
func (s *Server) wrapHandler(toolName string, handler server.ToolHandlerFunc) server.ToolHandlerFunc {
|
||||
return s.wrapHandlerWithArgumentLogging(toolName, handler, true)
|
||||
}
|
||||
|
||||
func (s *Server) wrapSensitiveHandler(toolName string, handler server.ToolHandlerFunc) server.ToolHandlerFunc {
|
||||
return s.wrapHandlerWithArgumentLogging(toolName, handler, false)
|
||||
}
|
||||
|
||||
func (s *Server) wrapHandlerWithArgumentLogging(toolName string, handler server.ToolHandlerFunc, logArguments bool) server.ToolHandlerFunc {
|
||||
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
start := time.Now()
|
||||
args := req.GetArguments()
|
||||
|
||||
s.logger.Debug("MCP tool call",
|
||||
zap.String("tool", toolName),
|
||||
zap.Any("args", args))
|
||||
fields := []zap.Field{zap.String("tool", toolName)}
|
||||
if logArguments {
|
||||
fields = append(fields, zap.Any("args", req.GetArguments()))
|
||||
}
|
||||
s.logger.Debug("MCP tool call", fields...)
|
||||
if s.mcpLogger != nil {
|
||||
s.mcpLogger.Debug("MCP tool call",
|
||||
zap.String("tool", toolName),
|
||||
zap.String("session_id", s.sessionID),
|
||||
zap.Any("args", args))
|
||||
mcpFields := append([]zap.Field(nil), fields...)
|
||||
mcpFields = append(mcpFields, zap.String("session_id", s.sessionID))
|
||||
s.mcpLogger.Debug("MCP tool call", mcpFields...)
|
||||
}
|
||||
|
||||
validatedReq, validationErr := s.validateToolArguments(toolName, req)
|
||||
@@ -443,16 +460,18 @@ func (s *Server) wrapHandler(toolName string, handler server.ToolHandlerFunc) se
|
||||
zap.Error(err))
|
||||
}
|
||||
case result != nil && result.IsError:
|
||||
s.logger.Debug("MCP tool returned error",
|
||||
resultFields := []zap.Field{
|
||||
zap.String("tool", toolName),
|
||||
zap.Duration("duration", duration),
|
||||
zap.Any("result", result.Content))
|
||||
}
|
||||
if logArguments {
|
||||
resultFields = append(resultFields, zap.Any("result", result.Content))
|
||||
}
|
||||
s.logger.Debug("MCP tool returned error", resultFields...)
|
||||
if s.mcpLogger != nil {
|
||||
s.mcpLogger.Debug("MCP tool returned error",
|
||||
zap.String("tool", toolName),
|
||||
zap.String("session_id", s.sessionID),
|
||||
zap.Duration("duration", duration),
|
||||
zap.Any("result", result.Content))
|
||||
mcpResultFields := append([]zap.Field(nil), resultFields...)
|
||||
mcpResultFields = append(mcpResultFields, zap.String("session_id", s.sessionID))
|
||||
s.mcpLogger.Debug("MCP tool returned error", mcpResultFields...)
|
||||
}
|
||||
default:
|
||||
s.logger.Debug("MCP tool success",
|
||||
@@ -569,6 +588,173 @@ func (s *Server) rebuildTools() {
|
||||
s.mcpServer.SetTools(s.assembleTools()...)
|
||||
}
|
||||
|
||||
// SetPluginTools validates and atomically replaces sideloaded tools. SetTools
|
||||
// emits one tools/list_changed notification to initialized MCP clients.
|
||||
func (s *Server) SetPluginTools(snapshot plugintools.Snapshot) error {
|
||||
s.pluginToolsUpdateMu.Lock()
|
||||
defer s.pluginToolsUpdateMu.Unlock()
|
||||
if err := validatePluginToolSnapshot(snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
normalized := plugintools.Normalize(snapshot)
|
||||
s.pluginToolsMu.Lock()
|
||||
if s.pluginToolsReady && normalized.Generation == s.pluginTools.Generation && normalized.Revision <= s.pluginTools.Revision {
|
||||
s.pluginToolsMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if s.pluginToolsReady && equivalentPluginToolCatalog(s.pluginTools, normalized) {
|
||||
s.pluginTools = normalized
|
||||
s.pluginToolsMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.pluginTools = normalized
|
||||
s.pluginToolsReady = true
|
||||
s.pluginToolsMu.Unlock()
|
||||
s.mu.Lock()
|
||||
s.rebuildTools()
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func equivalentPluginToolCatalog(left, right plugintools.Snapshot) bool {
|
||||
left.Generation, right.Generation = "", ""
|
||||
left.Revision, right.Revision = 0, 0
|
||||
return plugintools.Equal(left, right)
|
||||
}
|
||||
|
||||
func validatePluginToolSnapshot(snapshot plugintools.Snapshot) error {
|
||||
if snapshot.Generation == "" {
|
||||
return fmt.Errorf("plugin tool snapshot generation is required")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(snapshot.Tools))
|
||||
for i, definition := range snapshot.Tools {
|
||||
name := fmt.Sprintf("plugin tool %d", i)
|
||||
if definition.PluginID == "" || definition.LocalName == "" || definition.ExposedName == "" || definition.Description == "" {
|
||||
return fmt.Errorf("%s has incomplete identity or description", name)
|
||||
}
|
||||
if expected := plugintools.ExposedName(definition.PluginID, definition.LocalName); definition.ExposedName != expected {
|
||||
return fmt.Errorf("%s exposed name %q does not match %q", name, definition.ExposedName, expected)
|
||||
}
|
||||
if _, ok := seen[definition.ExposedName]; ok {
|
||||
return fmt.Errorf("duplicate plugin tool exposed name %q", definition.ExposedName)
|
||||
}
|
||||
seen[definition.ExposedName] = struct{}{}
|
||||
if err := validatePluginToolSurfaces(name, definition.Surfaces); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePluginToolSchema(definition.ExposedName+"/input", definition.InputSchema); err != nil {
|
||||
return fmt.Errorf("%s input schema: %w", name, err)
|
||||
}
|
||||
if len(definition.OutputSchema) > 0 {
|
||||
if err := validatePluginToolSchema(definition.ExposedName+"/output", definition.OutputSchema); err != nil {
|
||||
return fmt.Errorf("%s output schema: %w", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePluginToolSurfaces(name string, surfaces []string) error {
|
||||
if len(surfaces) == 0 {
|
||||
return fmt.Errorf("%s has no surfaces", name)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(surfaces))
|
||||
for _, surface := range surfaces {
|
||||
if surface != plugintools.SurfaceKanban && surface != plugintools.SurfaceOffice {
|
||||
return fmt.Errorf("%s has unsupported surface %q", name, surface)
|
||||
}
|
||||
if _, ok := seen[surface]; ok {
|
||||
return fmt.Errorf("%s duplicates surface %q", name, surface)
|
||||
}
|
||||
seen[surface] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePluginToolSchema(name string, raw json.RawMessage) error {
|
||||
var document map[string]any
|
||||
if err := json.Unmarshal(raw, &document); err != nil {
|
||||
return fmt.Errorf("decode schema: %w", err)
|
||||
}
|
||||
if _, err := toolschema.Compile(name, document); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) syncPluginTools(ctx context.Context) {
|
||||
if s.backend == nil {
|
||||
return
|
||||
}
|
||||
requestCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
s.mu.RLock()
|
||||
surface := string(s.profile.Surface)
|
||||
s.mu.RUnlock()
|
||||
var snapshot plugintools.Snapshot
|
||||
if err := s.backend.RequestPayload(requestCtx, ws.ActionMCPListPluginTools, map[string]string{"surface": surface}, &snapshot); err != nil {
|
||||
return
|
||||
}
|
||||
if err := s.SetPluginTools(snapshot); err != nil {
|
||||
s.logger.Warn("ignoring invalid plugin tool catalog", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) registerPluginTools() {
|
||||
s.pluginToolsMu.Lock()
|
||||
ready := s.pluginToolsReady
|
||||
snapshot := plugintools.Normalize(s.pluginTools)
|
||||
s.pluginToolsMu.Unlock()
|
||||
if !ready {
|
||||
return
|
||||
}
|
||||
for _, definition := range snapshot.Tools {
|
||||
if !pluginToolSupportsSurface(definition, string(s.profile.Surface)) {
|
||||
continue
|
||||
}
|
||||
tool := mcp.NewToolWithRawSchema(definition.ExposedName, definition.Description, definition.InputSchema)
|
||||
tool.RawOutputSchema = append(json.RawMessage(nil), definition.OutputSchema...)
|
||||
tool.Annotations = mcp.ToolAnnotation{
|
||||
ReadOnlyHint: &definition.ReadOnlyHint, DestructiveHint: &definition.DestructiveHint,
|
||||
IdempotentHint: &definition.IdempotentHint, OpenWorldHint: &definition.OpenWorldHint,
|
||||
}
|
||||
d := definition
|
||||
s.mcpServer.AddTool(tool, s.wrapSensitiveHandler(d.ExposedName, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
s.mu.RLock()
|
||||
surface := string(s.profile.Surface)
|
||||
s.mu.RUnlock()
|
||||
payload := map[string]any{
|
||||
"plugin_id": d.PluginID, "local_name": d.LocalName, "arguments": req.GetArguments(),
|
||||
"invocation_id": fmt.Sprintf("mcp-%d", time.Now().UnixNano()), "surface": surface,
|
||||
}
|
||||
var result struct {
|
||||
Text string `json:"text"`
|
||||
StructuredContent map[string]any `json:"structured_content,omitempty"`
|
||||
IsError bool `json:"is_error"`
|
||||
}
|
||||
if err := s.backend.RequestPayload(ctx, ws.ActionMCPInvokePluginTool, payload, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.IsError {
|
||||
return mcp.NewToolResultError(result.Text), nil
|
||||
}
|
||||
if result.StructuredContent != nil {
|
||||
return mcp.NewToolResultStructured(result.StructuredContent, result.Text), nil
|
||||
}
|
||||
return mcp.NewToolResultText(result.Text), nil
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func pluginToolSupportsSurface(definition plugintools.Definition, surface string) bool {
|
||||
for _, allowed := range definition.Surfaces {
|
||||
if allowed == surface {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sameProviderSet(left, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
@@ -667,6 +853,7 @@ func (s *Server) registerTools() {
|
||||
group.register(s)
|
||||
}
|
||||
}
|
||||
s.registerPluginTools()
|
||||
s.logger.Info("registered MCP tools",
|
||||
zap.String("mode", s.mode),
|
||||
zap.Int("count", len(s.mcpServer.ListTools())),
|
||||
|
||||
@@ -3,23 +3,27 @@ package mcp
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/mcp/plugintools"
|
||||
mcpprofile "github.com/kandev/kandev/internal/mcp/profile"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
mcplib "github.com/mark3labs/mcp-go/mcp"
|
||||
mcpsrv "github.com/mark3labs/mcp-go/server"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func TestMCPAttachmentObserverEmitsSafeConnectionEvidence(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
backend := NewChannelBackendClient(log)
|
||||
defer backend.Close()
|
||||
t.Cleanup(backend.Close)
|
||||
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
|
||||
events := make(chan streams.MCPAttachmentEvidence, 4)
|
||||
s.SetAttachmentReporter(func(evidence streams.MCPAttachmentEvidence) { events <- evidence })
|
||||
@@ -46,7 +50,7 @@ func TestMCPAttachmentObserverEmitsSafeConnectionEvidence(t *testing.T) {
|
||||
func TestMCPAttachmentObserverKeepsConnectionAttemptAcrossRollover(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
backend := NewChannelBackendClient(log)
|
||||
defer backend.Close()
|
||||
t.Cleanup(backend.Close)
|
||||
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
|
||||
events := make(chan streams.MCPAttachmentEvidence, 8)
|
||||
s.SetAttachmentReporter(func(evidence streams.MCPAttachmentEvidence) { events <- evidence })
|
||||
@@ -79,6 +83,271 @@ func newTestLogger(t *testing.T) *logger.Logger {
|
||||
return log
|
||||
}
|
||||
|
||||
func TestSetPluginToolsReplacesRuntimeRegistry(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
backend := NewChannelBackendClient(log)
|
||||
t.Cleanup(backend.Close)
|
||||
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
|
||||
definition := plugintools.Definition{
|
||||
PluginID: "task-tags.v1", LocalName: "add_tag", ExposedName: plugintools.ExposedName("task-tags.v1", "add_tag"),
|
||||
Description: "Add a tag", InputSchema: []byte(`{"type":"object","properties":{"tag":{"type":"string"}}}`),
|
||||
Surfaces: []string{plugintools.SurfaceKanban},
|
||||
}
|
||||
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{Generation: "g", Revision: 1, Tools: []plugintools.Definition{definition}}))
|
||||
if _, ok := s.mcpServer.ListTools()[definition.ExposedName]; !ok {
|
||||
t.Fatalf("plugin tool was not registered")
|
||||
}
|
||||
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{Generation: "g", Revision: 2}))
|
||||
if _, ok := s.mcpServer.ListTools()[definition.ExposedName]; ok {
|
||||
t.Fatalf("plugin tool was not removed on replacement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPluginToolsRebuildsArgumentValidators(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
backend := NewChannelBackendClient(log)
|
||||
t.Cleanup(backend.Close)
|
||||
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
|
||||
definition := plugintools.Definition{
|
||||
PluginID: "task-tags.v1", LocalName: "add_tag", ExposedName: plugintools.ExposedName("task-tags.v1", "add_tag"),
|
||||
Description: "Add a tag", InputSchema: []byte(`{"type":"object","properties":{"tag":{"type":"string"}},"required":["tag"]}`),
|
||||
Surfaces: []string{plugintools.SurfaceKanban},
|
||||
}
|
||||
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{Generation: "g", Revision: 1, Tools: []plugintools.Definition{definition}}))
|
||||
|
||||
req := mcplib.CallToolRequest{Params: mcplib.CallToolParams{Name: definition.ExposedName, Arguments: map[string]any{"tag": "urgent"}}}
|
||||
if _, err := s.validateToolArguments(definition.ExposedName, req); err != nil {
|
||||
t.Fatalf("validateToolArguments() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPluginToolsRejectsMalformedSnapshotAndPreservesRegistry(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
backend := NewChannelBackendClient(log)
|
||||
t.Cleanup(backend.Close)
|
||||
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
|
||||
definition := plugintools.Definition{
|
||||
PluginID: "echo", LocalName: "echo", ExposedName: plugintools.ExposedName("echo", "echo"),
|
||||
Description: "Echo", InputSchema: []byte(`{"type":"object"}`),
|
||||
Surfaces: []string{plugintools.SurfaceKanban},
|
||||
}
|
||||
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{
|
||||
Generation: "g", Revision: 1, Tools: []plugintools.Definition{definition},
|
||||
}))
|
||||
|
||||
invalid := definition
|
||||
invalid.InputSchema = []byte(`{"type":`)
|
||||
err := s.SetPluginTools(plugintools.Snapshot{
|
||||
Generation: "g", Revision: 2, Tools: []plugintools.Definition{invalid},
|
||||
})
|
||||
require.ErrorContains(t, err, "input schema")
|
||||
require.Contains(t, s.mcpServer.ListTools(), definition.ExposedName)
|
||||
require.Equal(t, uint64(1), s.pluginTools.Revision)
|
||||
}
|
||||
|
||||
func TestSetPluginToolsPublishesDeclaredOutputSchema(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
backend := NewChannelBackendClient(log)
|
||||
t.Cleanup(backend.Close)
|
||||
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
|
||||
definition := plugintools.Definition{
|
||||
PluginID: "echo", LocalName: "echo", ExposedName: plugintools.ExposedName("echo", "echo"),
|
||||
Description: "Echo", InputSchema: []byte(`{"type":"object"}`),
|
||||
OutputSchema: []byte(`{"type":"object","properties":{"value":{"type":"string"}}}`),
|
||||
Surfaces: []string{plugintools.SurfaceKanban},
|
||||
}
|
||||
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{
|
||||
Generation: "g", Revision: 1, Tools: []plugintools.Definition{definition},
|
||||
}))
|
||||
|
||||
tool := s.mcpServer.ListTools()[definition.ExposedName]
|
||||
require.JSONEq(t, string(definition.OutputSchema), string(tool.Tool.RawOutputSchema))
|
||||
}
|
||||
|
||||
func TestSetPluginToolsSkipsEquivalentEffectiveRegistry(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
backend := NewChannelBackendClient(log)
|
||||
t.Cleanup(backend.Close)
|
||||
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
|
||||
session := &providerRefreshTestSession{
|
||||
id: "initialized-plugin-noop", notifications: make(chan mcplib.JSONRPCNotification, 4),
|
||||
}
|
||||
require.NoError(t, s.mcpServer.RegisterSession(context.Background(), session))
|
||||
definition := plugintools.Definition{
|
||||
PluginID: "echo", LocalName: "echo", ExposedName: plugintools.ExposedName("echo", "echo"),
|
||||
Description: "Echo", InputSchema: []byte(`{"type":"object"}`),
|
||||
Surfaces: []string{plugintools.SurfaceKanban},
|
||||
}
|
||||
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{
|
||||
Generation: "g", Revision: 1, Tools: []plugintools.Definition{definition},
|
||||
}))
|
||||
<-session.notifications
|
||||
|
||||
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{
|
||||
Generation: "g", Revision: 2, Tools: []plugintools.Definition{definition},
|
||||
}))
|
||||
select {
|
||||
case notification := <-session.notifications:
|
||||
t.Fatalf("equivalent plugin catalog emitted notification %q", notification.Method)
|
||||
default:
|
||||
}
|
||||
require.Equal(t, uint64(2), s.pluginTools.Revision)
|
||||
}
|
||||
|
||||
func TestPluginToolCallDoesNotLogArguments(t *testing.T) {
|
||||
core, observed := observer.New(zap.DebugLevel)
|
||||
log, err := logger.NewFromZap(zap.New(core))
|
||||
require.NoError(t, err)
|
||||
backend := &testBackend{response: map[string]any{"text": "ok"}}
|
||||
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
|
||||
definition := plugintools.Definition{
|
||||
PluginID: "echo", LocalName: "echo", ExposedName: plugintools.ExposedName("echo", "echo"),
|
||||
Description: "Echo", InputSchema: []byte(`{"type":"object","properties":{"token":{"type":"string"}}}`),
|
||||
Surfaces: []string{plugintools.SurfaceKanban},
|
||||
}
|
||||
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{Generation: "g", Revision: 1, Tools: []plugintools.Definition{definition}}))
|
||||
callTool(t, s, definition.ExposedName, map[string]interface{}{"token": "secret-value"})
|
||||
|
||||
entries := observed.FilterMessage("MCP tool call").All()
|
||||
require.Len(t, entries, 1)
|
||||
_, loggedArgs := entries[0].ContextMap()["args"]
|
||||
require.False(t, loggedArgs, "plugin arguments must not be attached to logs")
|
||||
}
|
||||
|
||||
func TestPluginToolErrorDoesNotLogResult(t *testing.T) {
|
||||
core, observed := observer.New(zap.DebugLevel)
|
||||
log, err := logger.NewFromZap(zap.New(core))
|
||||
require.NoError(t, err)
|
||||
backend := &testBackend{response: map[string]any{"text": "secret-result", "is_error": true}}
|
||||
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
|
||||
definition := plugintools.Definition{
|
||||
PluginID: "echo", LocalName: "echo", ExposedName: plugintools.ExposedName("echo", "echo"),
|
||||
Description: "Echo", InputSchema: []byte(`{"type":"object"}`), Surfaces: []string{plugintools.SurfaceKanban},
|
||||
}
|
||||
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{Generation: "g", Revision: 1, Tools: []plugintools.Definition{definition}}))
|
||||
callTool(t, s, definition.ExposedName, map[string]interface{}{})
|
||||
|
||||
entries := observed.FilterMessage("MCP tool returned error").All()
|
||||
require.Len(t, entries, 1)
|
||||
_, loggedResult := entries[0].ContextMap()["result"]
|
||||
require.False(t, loggedResult, "plugin results must not be attached to logs")
|
||||
}
|
||||
|
||||
type staticPluginBackend struct{}
|
||||
|
||||
func (staticPluginBackend) RequestPayload(_ context.Context, _ string, _, result interface{}) error {
|
||||
response, ok := result.(*struct {
|
||||
Text string `json:"text"`
|
||||
StructuredContent map[string]any `json:"structured_content,omitempty"`
|
||||
IsError bool `json:"is_error"`
|
||||
})
|
||||
if ok {
|
||||
response.Text = "ok"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPluginToolInvocationProfileAccessIsRaceFree(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
s := New(staticPluginBackend{}, "session-1", "task-1", 10005, log, "", false, ModeTask)
|
||||
definition := plugintools.Definition{
|
||||
PluginID: "echo", LocalName: "echo", ExposedName: plugintools.ExposedName("echo", "echo"),
|
||||
Description: "Echo", InputSchema: []byte(`{"type":"object"}`),
|
||||
Surfaces: []string{plugintools.SurfaceKanban, plugintools.SurfaceOffice},
|
||||
}
|
||||
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{Generation: "g", Revision: 1, Tools: []plugintools.Definition{definition}}))
|
||||
tool := s.mcpServer.ListTools()[definition.ExposedName]
|
||||
require.NotNil(t, tool)
|
||||
req := mcplib.CallToolRequest{Params: mcplib.CallToolParams{Name: definition.ExposedName, Arguments: map[string]any{}}}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 100; i++ {
|
||||
s.SetProfile(mcpprofile.New(mcpprofile.SurfaceKanbanTask, nil, nil))
|
||||
s.SetProfile(mcpprofile.New(mcpprofile.SurfaceOfficeTask, nil, nil))
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 100; i++ {
|
||||
_, _ = tool.Handler(context.Background(), req)
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestSetPluginToolsIgnoresStaleRevision(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
backend := NewChannelBackendClient(log)
|
||||
t.Cleanup(backend.Close)
|
||||
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
|
||||
definition := plugintools.Definition{
|
||||
PluginID: "plugin", LocalName: "current", ExposedName: plugintools.ExposedName("plugin", "current"),
|
||||
Description: "Current", InputSchema: []byte(`{"type":"object"}`),
|
||||
Surfaces: []string{plugintools.SurfaceKanban},
|
||||
}
|
||||
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{Generation: "g", Revision: 2, Tools: []plugintools.Definition{definition}}))
|
||||
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{Generation: "g", Revision: 1}))
|
||||
_, ok := s.mcpServer.ListTools()[definition.ExposedName]
|
||||
if !ok {
|
||||
t.Fatal("stale revision replaced the current registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPluginToolsIgnoresDivergentSnapshotAtCurrentRevision(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
backend := NewChannelBackendClient(log)
|
||||
t.Cleanup(backend.Close)
|
||||
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
|
||||
current := plugintools.Definition{
|
||||
PluginID: "plugin", LocalName: "current", ExposedName: plugintools.ExposedName("plugin", "current"),
|
||||
Description: "Current", InputSchema: []byte(`{"type":"object"}`),
|
||||
Surfaces: []string{plugintools.SurfaceKanban},
|
||||
}
|
||||
replacement := plugintools.Definition{
|
||||
PluginID: "plugin", LocalName: "replacement", ExposedName: plugintools.ExposedName("plugin", "replacement"),
|
||||
Description: "Replacement", InputSchema: []byte(`{"type":"object"}`),
|
||||
Surfaces: []string{plugintools.SurfaceKanban},
|
||||
}
|
||||
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{
|
||||
Generation: "g", Revision: 2, Tools: []plugintools.Definition{current},
|
||||
}))
|
||||
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{
|
||||
Generation: "g", Revision: 2, Tools: []plugintools.Definition{replacement},
|
||||
}))
|
||||
|
||||
tools := s.mcpServer.ListTools()
|
||||
require.Contains(t, tools, current.ExposedName)
|
||||
require.NotContains(t, tools, replacement.ExposedName)
|
||||
}
|
||||
|
||||
func TestSetPluginToolsPreservesCatalogAcrossSurfaceChange(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
backend := NewChannelBackendClient(log)
|
||||
t.Cleanup(backend.Close)
|
||||
s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask)
|
||||
kanban := plugintools.Definition{
|
||||
PluginID: "plugin", LocalName: "kanban", ExposedName: plugintools.ExposedName("plugin", "kanban"),
|
||||
Description: "Kanban", InputSchema: []byte(`{"type":"object"}`), Surfaces: []string{plugintools.SurfaceKanban},
|
||||
}
|
||||
office := plugintools.Definition{
|
||||
PluginID: "plugin", LocalName: "office", ExposedName: plugintools.ExposedName("plugin", "office"),
|
||||
Description: "Office", InputSchema: []byte(`{"type":"object"}`), Surfaces: []string{plugintools.SurfaceOffice},
|
||||
}
|
||||
require.NoError(t, s.SetPluginTools(plugintools.Snapshot{Generation: "g", Revision: 1, Tools: []plugintools.Definition{kanban, office}}))
|
||||
s.SetProfile(mcpprofile.New(mcpprofile.SurfaceOfficeTask, nil, nil))
|
||||
|
||||
tools := s.mcpServer.ListTools()
|
||||
if _, ok := tools[office.ExposedName]; !ok {
|
||||
t.Fatal("office tool was lost when the MCP surface changed")
|
||||
}
|
||||
if _, ok := tools[kanban.ExposedName]; ok {
|
||||
t.Fatal("kanban-only tool remained on the office surface")
|
||||
}
|
||||
}
|
||||
|
||||
// getRegisteredToolNames returns the names of all tools registered on the MCP server.
|
||||
func getRegisteredToolNames(s *Server) []string {
|
||||
toolsMap := s.mcpServer.ListTools()
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package toolschema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
jsonschema "github.com/santhosh-tekuri/jsonschema/v6"
|
||||
)
|
||||
|
||||
// Compile compiles a JSON Schema document using the same draft as Kandev's
|
||||
// MCP boundary. Callers can use the returned schema for both install-time and
|
||||
// invocation-time validation.
|
||||
func Compile(name string, document map[string]any) (*jsonschema.Schema, error) {
|
||||
if len(document) == 0 {
|
||||
return nil, fmt.Errorf("schema must not be empty")
|
||||
}
|
||||
if document["type"] != "object" {
|
||||
return nil, fmt.Errorf("schema root type must be object")
|
||||
}
|
||||
compiler := jsonschema.NewCompiler()
|
||||
compiler.DefaultDraft(jsonschema.Draft7)
|
||||
resourceURL := "https://kandev.local/mcp/schemas/" + url.PathEscape(name)
|
||||
if err := compiler.AddResource(resourceURL, document); err != nil {
|
||||
return nil, fmt.Errorf("add schema resource: %w", err)
|
||||
}
|
||||
schema, err := compiler.Compile(resourceURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compile schema: %w", err)
|
||||
}
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
func Marshal(document map[string]any) ([]byte, error) {
|
||||
return json.Marshal(document)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package toolschema
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCompileRequiresObjectRoot(t *testing.T) {
|
||||
if _, err := Compile("test", map[string]any{"type": "string"}); err == nil {
|
||||
t.Fatal("Compile() expected non-object root error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileAcceptsObjectSchema(t *testing.T) {
|
||||
if _, err := Compile("test", map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"name": map[string]any{"type": "string"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("Compile() unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/mcp/plugintools"
|
||||
"github.com/kandev/kandev/internal/mcp/toolschema"
|
||||
"github.com/kandev/kandev/internal/plugins/manifest"
|
||||
"github.com/kandev/kandev/internal/plugins/store"
|
||||
"github.com/kandev/kandev/pkg/pluginsdk"
|
||||
jsonschema "github.com/santhosh-tekuri/jsonschema/v6"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const maxAgentToolResultBytes = 1 << 20
|
||||
|
||||
type AgentToolInvocationContext struct {
|
||||
InvocationID string
|
||||
TaskID string
|
||||
SessionID string
|
||||
WorkspaceID string
|
||||
Surface string
|
||||
}
|
||||
|
||||
// AgentToolCatalog returns the current active-plugin tool snapshot. The
|
||||
// revision changes only when the effective tool set changes; generation is
|
||||
// stable for the lifetime of the Service instance.
|
||||
func (s *Service) AgentToolCatalog() (plugintools.Snapshot, error) {
|
||||
tools, err := buildAgentToolDefinitions(s.registry.List())
|
||||
if err != nil {
|
||||
return plugintools.Snapshot{}, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.agentToolGeneration == "" {
|
||||
s.agentToolGeneration = fmt.Sprintf("service-%d", time.Now().UnixNano())
|
||||
}
|
||||
current := plugintools.Snapshot{Generation: s.agentToolGeneration, Tools: tools}
|
||||
previous := s.agentToolSnapshot
|
||||
previous.Revision = 0
|
||||
if !s.agentToolSnapshotReady || !plugintools.Equal(previous, current) {
|
||||
s.agentToolRevision++
|
||||
current.Revision = s.agentToolRevision
|
||||
s.agentToolSnapshot = plugintools.Normalize(current)
|
||||
s.agentToolSnapshotReady = true
|
||||
}
|
||||
return plugintools.Normalize(s.agentToolSnapshot), nil
|
||||
}
|
||||
|
||||
func buildAgentToolDefinitions(records []*store.Record) ([]plugintools.Definition, error) {
|
||||
seen := make(map[string]string)
|
||||
var definitions []plugintools.Definition
|
||||
for _, record := range records {
|
||||
if record.Status != StatusActive {
|
||||
continue
|
||||
}
|
||||
for _, tool := range record.AgentTools {
|
||||
input, err := json.Marshal(tool.InputSchema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin %q tool %q input schema: %w", record.ID, tool.Name, err)
|
||||
}
|
||||
var output json.RawMessage
|
||||
if len(tool.OutputSchema) > 0 {
|
||||
output, err = json.Marshal(tool.OutputSchema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin %q tool %q output schema: %w", record.ID, tool.Name, err)
|
||||
}
|
||||
}
|
||||
exposed := plugintools.ExposedName(record.ID, tool.Name)
|
||||
if previous, ok := seen[exposed]; ok && previous != record.ID {
|
||||
return nil, fmt.Errorf("agent tool name collision: %q from %q and %q", exposed, previous, record.ID)
|
||||
}
|
||||
seen[exposed] = record.ID
|
||||
definitions = append(definitions, plugintools.Definition{
|
||||
PluginID: record.ID,
|
||||
PluginDisplayName: record.DisplayName,
|
||||
LocalName: tool.Name,
|
||||
ExposedName: exposed,
|
||||
Description: tool.Description,
|
||||
Surfaces: append([]string(nil), tool.Surfaces...),
|
||||
InputSchema: input,
|
||||
OutputSchema: output,
|
||||
ReadOnlyHint: boolValue(tool.Annotations.ReadOnlyHint, false),
|
||||
DestructiveHint: boolValue(tool.Annotations.DestructiveHint, true),
|
||||
IdempotentHint: boolValue(tool.Annotations.IdempotentHint, false),
|
||||
OpenWorldHint: boolValue(tool.Annotations.OpenWorldHint, true),
|
||||
})
|
||||
}
|
||||
}
|
||||
return plugintools.Normalize(plugintools.Snapshot{Tools: definitions}).Tools, nil
|
||||
}
|
||||
|
||||
func (s *Service) validateAgentToolInstall(candidate *manifest.Manifest) error {
|
||||
if candidate == nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]string)
|
||||
for _, record := range s.registry.List() {
|
||||
if record.ID == candidate.ID {
|
||||
continue
|
||||
}
|
||||
for _, tool := range record.AgentTools {
|
||||
seen[plugintools.ExposedName(record.ID, tool.Name)] = record.ID
|
||||
}
|
||||
}
|
||||
for _, tool := range candidate.AgentTools {
|
||||
exposed := plugintools.ExposedName(candidate.ID, tool.Name)
|
||||
if previous, ok := seen[exposed]; ok && previous != candidate.ID {
|
||||
return fmt.Errorf("agent tool name collision: %q from %q and %q", exposed, previous, candidate.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolValue(value *bool, fallback bool) bool {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
// InvokeAgentTool validates the declaration and arguments, then forwards one
|
||||
// bounded call to the plugin's gRPC subprocess. It deliberately does not
|
||||
// retry because tools may have side effects.
|
||||
func (s *Service) InvokeAgentTool(ctx context.Context, pluginID, localName string, arguments map[string]any, invocation AgentToolInvocationContext) (result *pluginsdk.AgentToolResult, err error) {
|
||||
started := time.Now()
|
||||
defer func() {
|
||||
if s.log == nil {
|
||||
return
|
||||
}
|
||||
outcome := "success"
|
||||
if err != nil {
|
||||
outcome = "error"
|
||||
} else if result != nil && result.IsError {
|
||||
outcome = "tool_error"
|
||||
}
|
||||
s.log.Info("plugin agent tool invocation",
|
||||
zap.String("invocation_id", invocation.InvocationID),
|
||||
zap.String("plugin_id", pluginID),
|
||||
zap.String("local_name", localName),
|
||||
zap.String("task_id", invocation.TaskID),
|
||||
zap.String("session_id", invocation.SessionID),
|
||||
zap.Duration("duration", time.Since(started)),
|
||||
zap.String("outcome", outcome))
|
||||
}()
|
||||
|
||||
record, err := s.Get(pluginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record.Status != StatusActive {
|
||||
return nil, fmt.Errorf("plugins: plugin %q is not active", pluginID)
|
||||
}
|
||||
declaration, err := findAgentTool(record.AgentTools, localName, invocation.Surface)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arguments, err = validateAgentToolArguments(pluginID, localName, declaration, arguments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remote, ok := s.pluginRemote(pluginID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugins: plugin %q is not running", pluginID)
|
||||
}
|
||||
result, err = invokeRemoteAgentTool(ctx, remote, localName, arguments, invocation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return validateAgentToolResult(pluginID, localName, declaration, result)
|
||||
}
|
||||
|
||||
func findAgentTool(tools []manifest.AgentTool, localName, surface string) (*manifest.AgentTool, error) {
|
||||
for i := range tools {
|
||||
if tools[i].Name != localName {
|
||||
continue
|
||||
}
|
||||
if !containsString(tools[i].Surfaces, surface) {
|
||||
return nil, fmt.Errorf("plugins: agent tool %q is not available on surface %q", localName, surface)
|
||||
}
|
||||
return &tools[i], nil
|
||||
}
|
||||
return nil, fmt.Errorf("plugins: agent tool %q is not declared", localName)
|
||||
}
|
||||
|
||||
func validateAgentToolArguments(pluginID, localName string, declaration *manifest.AgentTool, arguments map[string]any) (map[string]any, error) {
|
||||
if arguments == nil {
|
||||
arguments = map[string]any{}
|
||||
}
|
||||
inputSchema, err := compileInvocationSchema(pluginID+"/"+localName, declaration.InputSchema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugins: invalid input schema: %w", err)
|
||||
}
|
||||
if err := inputSchema.Validate(arguments); err != nil {
|
||||
return nil, fmt.Errorf("plugins: invalid arguments for %q", localName)
|
||||
}
|
||||
return arguments, nil
|
||||
}
|
||||
|
||||
func invokeRemoteAgentTool(ctx context.Context, remote interface {
|
||||
InvokeAgentTool(context.Context, *pluginsdk.AgentToolRequest) (*pluginsdk.AgentToolResult, error)
|
||||
}, localName string, arguments map[string]any, invocation AgentToolInvocationContext) (*pluginsdk.AgentToolResult, error) {
|
||||
callCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
return remote.InvokeAgentTool(callCtx, &pluginsdk.AgentToolRequest{
|
||||
InvocationID: invocation.InvocationID,
|
||||
Name: localName,
|
||||
Arguments: arguments,
|
||||
Context: pluginsdk.AgentToolContext{
|
||||
TaskID: invocation.TaskID, SessionID: invocation.SessionID,
|
||||
WorkspaceID: invocation.WorkspaceID, Surface: invocation.Surface,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func validateAgentToolResult(pluginID, localName string, declaration *manifest.AgentTool, result *pluginsdk.AgentToolResult) (*pluginsdk.AgentToolResult, error) {
|
||||
if result == nil || strings.TrimSpace(result.Text) == "" {
|
||||
return nil, errors.New("plugins: agent tool returned empty text")
|
||||
}
|
||||
encoded, err := json.Marshal(result.StructuredContent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugins: encode agent tool result: %w", err)
|
||||
}
|
||||
if len(result.Text)+len(encoded) > maxAgentToolResultBytes {
|
||||
return nil, errors.New("plugins: agent tool result exceeds 1 MiB")
|
||||
}
|
||||
if len(declaration.OutputSchema) > 0 && result.StructuredContent != nil {
|
||||
outputSchema, err := toolschema.Compile(pluginID+"/"+localName+"/output", declaration.OutputSchema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugins: invalid output schema: %w", err)
|
||||
}
|
||||
if err := outputSchema.Validate(result.StructuredContent); err != nil {
|
||||
return nil, errors.New("plugins: agent tool result does not match output schema")
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func compileInvocationSchema(name string, document map[string]any) (*jsonschema.Schema, error) {
|
||||
copy := make(map[string]any, len(document)+1)
|
||||
for key, value := range document {
|
||||
copy[key] = value
|
||||
}
|
||||
copy["additionalProperties"] = false
|
||||
return toolschema.Compile(name, copy)
|
||||
}
|
||||
|
||||
func containsString(values []string, wanted string) bool {
|
||||
for _, value := range values {
|
||||
if value == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/mcp/plugintools"
|
||||
"github.com/kandev/kandev/internal/plugins/manifest"
|
||||
"github.com/kandev/kandev/internal/plugins/store"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func TestAgentToolCatalogIncludesActiveTools(t *testing.T) {
|
||||
svc, _, _ := newTestService(t)
|
||||
readOnly := true
|
||||
destructive := false
|
||||
svc.registry.Add(&store.Record{
|
||||
Manifest: manifest.Manifest{
|
||||
ID: "task-tags.v1",
|
||||
DisplayName: "Task tags",
|
||||
AgentTools: []manifest.AgentTool{{
|
||||
Name: "add_tag",
|
||||
Description: "Add a tag to a task",
|
||||
Surfaces: []string{manifest.AgentToolSurfaceKanban},
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"tag": map[string]any{"type": "string"}},
|
||||
"required": []any{"tag"},
|
||||
},
|
||||
Annotations: manifest.AgentToolAnnotations{ReadOnlyHint: &readOnly, DestructiveHint: &destructive},
|
||||
}},
|
||||
},
|
||||
Status: store.StatusActive,
|
||||
})
|
||||
svc.registry.Add(&store.Record{
|
||||
Manifest: manifest.Manifest{
|
||||
ID: "disabled.v1",
|
||||
AgentTools: []manifest.AgentTool{{
|
||||
Name: "hidden",
|
||||
Description: "Hidden tool",
|
||||
Surfaces: []string{manifest.AgentToolSurfaceKanban},
|
||||
InputSchema: map[string]any{"type": "object"},
|
||||
}},
|
||||
},
|
||||
Status: store.StatusDisabled,
|
||||
})
|
||||
|
||||
snapshot, err := svc.AgentToolCatalog()
|
||||
if err != nil {
|
||||
t.Fatalf("AgentToolCatalog() error: %v", err)
|
||||
}
|
||||
if snapshot.Generation == "" || snapshot.Revision == 0 {
|
||||
t.Fatalf("snapshot identity = %#v, want generation and revision", snapshot)
|
||||
}
|
||||
if len(snapshot.Tools) != 1 {
|
||||
t.Fatalf("snapshot tools = %d, want 1", len(snapshot.Tools))
|
||||
}
|
||||
tool := snapshot.Tools[0]
|
||||
if tool.ExposedName != plugintools.ExposedName("task-tags.v1", "add_tag") {
|
||||
t.Fatalf("exposed name = %q", tool.ExposedName)
|
||||
}
|
||||
if !tool.ReadOnlyHint || tool.DestructiveHint {
|
||||
t.Fatalf("annotations = %#v, want read-only true and destructive false", tool)
|
||||
}
|
||||
again, err := svc.AgentToolCatalog()
|
||||
if err != nil {
|
||||
t.Fatalf("second AgentToolCatalog() error: %v", err)
|
||||
}
|
||||
if again.Revision != snapshot.Revision {
|
||||
t.Fatalf("revision changed without catalog change: %d -> %d", snapshot.Revision, again.Revision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentToolCatalogUsesConservativeAnnotationDefaults(t *testing.T) {
|
||||
svc, _, _ := newTestService(t)
|
||||
svc.registry.Add(&store.Record{
|
||||
Manifest: manifest.Manifest{
|
||||
ID: "defaults", AgentTools: []manifest.AgentTool{{
|
||||
Name: "echo", Description: "Echo input.",
|
||||
Surfaces: []string{manifest.AgentToolSurfaceKanban},
|
||||
InputSchema: map[string]any{"type": "object"},
|
||||
}},
|
||||
},
|
||||
Status: store.StatusActive,
|
||||
})
|
||||
|
||||
snapshot, err := svc.AgentToolCatalog()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, snapshot.Tools, 1)
|
||||
tool := snapshot.Tools[0]
|
||||
require.False(t, tool.ReadOnlyHint)
|
||||
require.True(t, tool.DestructiveHint)
|
||||
require.False(t, tool.IdempotentHint)
|
||||
require.True(t, tool.OpenWorldHint)
|
||||
}
|
||||
|
||||
func TestInvokeAgentToolLogsOnlyBoundMetadata(t *testing.T) {
|
||||
core, observed := observer.New(zap.InfoLevel)
|
||||
log, err := logger.NewFromZap(zap.New(core))
|
||||
require.NoError(t, err)
|
||||
svc := NewService(store.NewFSStore(t.TempDir()), NewRegistry(), nil, log)
|
||||
|
||||
_, err = svc.InvokeAgentTool(context.Background(), "missing", "echo", map[string]any{
|
||||
"secret": "do-not-log",
|
||||
}, AgentToolInvocationContext{
|
||||
InvocationID: "invoke-1", TaskID: "task-1", SessionID: "session-1",
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
entries := observed.FilterMessage("plugin agent tool invocation").All()
|
||||
require.Len(t, entries, 1)
|
||||
fields := entries[0].ContextMap()
|
||||
require.Equal(t, "invoke-1", fields["invocation_id"])
|
||||
require.Equal(t, "missing", fields["plugin_id"])
|
||||
require.Equal(t, "echo", fields["local_name"])
|
||||
require.Equal(t, "task-1", fields["task_id"])
|
||||
require.Equal(t, "session-1", fields["session_id"])
|
||||
require.Equal(t, "error", fields["outcome"])
|
||||
require.NotContains(t, fields, "arguments")
|
||||
require.NotContains(t, fields, "result")
|
||||
}
|
||||
@@ -47,6 +47,7 @@ type Manifest struct {
|
||||
AuthProviders []AuthProvider `yaml:"auth_providers,omitempty" json:"auth_providers,omitempty"`
|
||||
|
||||
ConfigSchema map[string]any `yaml:"config_schema,omitempty" json:"config_schema,omitempty"`
|
||||
AgentTools []AgentTool `yaml:"agent_tools,omitempty" json:"agent_tools,omitempty"`
|
||||
|
||||
UI UISection `yaml:"ui,omitempty" json:"ui,omitempty"`
|
||||
|
||||
@@ -54,6 +55,31 @@ type Manifest struct {
|
||||
MinKandevVersion string `yaml:"min_kandev_version,omitempty" json:"min_kandev_version,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
AgentToolSurfaceKanban = "kanban-task"
|
||||
AgentToolSurfaceOffice = "office-task"
|
||||
)
|
||||
|
||||
// AgentTool is an MCP tool a plugin contributes to matching task sessions.
|
||||
// The exposed MCP name is derived by the host and is not author-controlled.
|
||||
type AgentTool struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Surfaces []string `yaml:"surfaces" json:"surfaces"`
|
||||
InputSchema map[string]any `yaml:"input_schema" json:"input_schema"`
|
||||
OutputSchema map[string]any `yaml:"output_schema,omitempty" json:"output_schema,omitempty"`
|
||||
Annotations AgentToolAnnotations `yaml:"annotations,omitempty" json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
// AgentToolAnnotations are MCP hints. Nil values preserve omission so the
|
||||
// runtime descriptor can apply conservative defaults explicitly.
|
||||
type AgentToolAnnotations struct {
|
||||
ReadOnlyHint *bool `yaml:"read_only_hint,omitempty" json:"read_only_hint,omitempty"`
|
||||
DestructiveHint *bool `yaml:"destructive_hint,omitempty" json:"destructive_hint,omitempty"`
|
||||
IdempotentHint *bool `yaml:"idempotent_hint,omitempty" json:"idempotent_hint,omitempty"`
|
||||
OpenWorldHint *bool `yaml:"open_world_hint,omitempty" json:"open_world_hint,omitempty"`
|
||||
}
|
||||
|
||||
// Runtime declares that a plugin ships a kandev-managed binary rather than
|
||||
// registering an already-running remote service. Type is currently only
|
||||
// ever "binary"; kandev spawns the matching Executables entry as a
|
||||
|
||||
@@ -91,6 +91,96 @@ func TestValidate_ValidManifestPasses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_AgentToolDeclarationIsRetained(t *testing.T) {
|
||||
yaml := validManifestYAML + `
|
||||
agent_tools:
|
||||
- name: add_tag
|
||||
description: Add a tag to the current task.
|
||||
surfaces: [kanban-task]
|
||||
input_schema:
|
||||
type: object
|
||||
properties:
|
||||
tag_id: {type: string}
|
||||
required: [tag_id]
|
||||
`
|
||||
m, err := Parse([]byte(yaml))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() unexpected error: %v", err)
|
||||
}
|
||||
if len(m.AgentTools) != 1 {
|
||||
t.Fatalf("AgentTools = %v, want one declaration", m.AgentTools)
|
||||
}
|
||||
tool := m.AgentTools[0]
|
||||
if tool.Name != "add_tag" {
|
||||
t.Fatalf("AgentTools[0].Name = %q, want add_tag", tool.Name)
|
||||
}
|
||||
if tool.Description != "Add a tag to the current task." {
|
||||
t.Fatalf("AgentTools[0].Description = %q", tool.Description)
|
||||
}
|
||||
if len(tool.Surfaces) != 1 || tool.Surfaces[0] != AgentToolSurfaceKanban {
|
||||
t.Fatalf("AgentTools[0].Surfaces = %v, want [%s]", tool.Surfaces, AgentToolSurfaceKanban)
|
||||
}
|
||||
if tool.InputSchema["type"] != "object" {
|
||||
t.Fatalf("AgentTools[0].InputSchema.type = %v, want object", tool.InputSchema["type"])
|
||||
}
|
||||
properties, ok := tool.InputSchema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("AgentTools[0].InputSchema.properties = %T, want map[string]any", tool.InputSchema["properties"])
|
||||
}
|
||||
tagID, ok := properties["tag_id"].(map[string]any)
|
||||
if !ok || tagID["type"] != "string" {
|
||||
t.Fatalf("AgentTools[0].InputSchema.properties.tag_id = %v, want string schema", properties["tag_id"])
|
||||
}
|
||||
required, ok := tool.InputSchema["required"].([]any)
|
||||
if !ok || len(required) != 1 || required[0] != "tag_id" {
|
||||
t.Fatalf("AgentTools[0].InputSchema.required = %v, want [tag_id]", tool.InputSchema["required"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_AgentToolContract(t *testing.T) {
|
||||
m := managedManifest(t)
|
||||
readOnly := true
|
||||
destructive := false
|
||||
m.AgentTools = []AgentTool{{
|
||||
Name: "read_tag",
|
||||
Description: "Read the current task tag.",
|
||||
Surfaces: []string{AgentToolSurfaceKanban},
|
||||
InputSchema: map[string]any{"type": "object"},
|
||||
Annotations: AgentToolAnnotations{
|
||||
ReadOnlyHint: &readOnly, DestructiveHint: &destructive,
|
||||
},
|
||||
}}
|
||||
if err := m.Validate(); err != nil {
|
||||
t.Fatalf("Validate() unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RejectsAgentToolsOnLegacyRemotePlugin(t *testing.T) {
|
||||
m := validManifest(t)
|
||||
m.AgentTools = []AgentTool{{
|
||||
Name: "echo", Description: "Echo input.",
|
||||
Surfaces: []string{AgentToolSurfaceKanban},
|
||||
InputSchema: map[string]any{"type": "object"},
|
||||
}}
|
||||
err := m.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "agent_tools require runtime.type binary") {
|
||||
t.Fatalf("Validate() error = %v, want managed-runtime requirement", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RejectsInvalidAgentTool(t *testing.T) {
|
||||
m := validManifest(t)
|
||||
m.AgentTools = []AgentTool{{
|
||||
Name: "Bad-Name",
|
||||
Description: "",
|
||||
Surfaces: []string{"configuration"},
|
||||
InputSchema: map[string]any{"type": "string"},
|
||||
}}
|
||||
if err := m.Validate(); err == nil {
|
||||
t.Fatal("Validate() expected agent tool errors, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// validManifest returns a freshly parsed copy of the valid fixture so each
|
||||
// test can mutate it independently without affecting others.
|
||||
func validManifest(t *testing.T) *Manifest {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/kandev/kandev/internal/mcp/toolschema"
|
||||
)
|
||||
|
||||
// idPattern matches the required plugin id shape: lowercase alphanumerics,
|
||||
@@ -130,9 +133,106 @@ func (m *Manifest) Validate() error {
|
||||
errs = append(errs, m.validateUIBundle()...)
|
||||
errs = append(errs, m.validateUIKeybindings()...)
|
||||
errs = append(errs, m.validateWebhooks()...)
|
||||
errs = append(errs, m.validateAgentTools()...)
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
var agentToolNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_]{0,31}$`)
|
||||
|
||||
func (m *Manifest) validateAgentTools() []error {
|
||||
if len(m.AgentTools) == 0 {
|
||||
return nil
|
||||
}
|
||||
var errs []error
|
||||
if !m.IsManaged() {
|
||||
errs = append(errs, errors.New("agent_tools require runtime.type binary"))
|
||||
}
|
||||
if len(m.AgentTools) > 16 {
|
||||
errs = append(errs, errors.New("agent_tools must declare at most 16 tools"))
|
||||
}
|
||||
seen := make(map[string]struct{}, len(m.AgentTools))
|
||||
for i, tool := range m.AgentTools {
|
||||
prefix := fmt.Sprintf("agent_tools[%d]", i)
|
||||
errs = append(errs, validateAgentTool(prefix, tool, seen)...)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateAgentTool(prefix string, tool AgentTool, seen map[string]struct{}) []error {
|
||||
var errs []error
|
||||
errs = append(errs, validateAgentToolIdentity(prefix, tool.Name, seen)...)
|
||||
if strings.TrimSpace(tool.Description) == "" || len(tool.Description) > 1024 {
|
||||
errs = append(errs, fmt.Errorf("%s.description must be 1-1024 bytes", prefix))
|
||||
}
|
||||
errs = append(errs, validateAgentToolSurfaces(prefix, tool.Surfaces)...)
|
||||
errs = append(errs, validateAgentToolSchemas(prefix, tool)...)
|
||||
if tool.Annotations.ReadOnlyHint != nil && tool.Annotations.DestructiveHint != nil &&
|
||||
*tool.Annotations.ReadOnlyHint && *tool.Annotations.DestructiveHint {
|
||||
errs = append(errs, fmt.Errorf("%s.annotations cannot set both read_only_hint and destructive_hint", prefix))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateAgentToolIdentity(prefix, name string, seen map[string]struct{}) []error {
|
||||
var errs []error
|
||||
if !agentToolNamePattern.MatchString(name) {
|
||||
errs = append(errs, fmt.Errorf("%s.name must match %s", prefix, agentToolNamePattern.String()))
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
errs = append(errs, fmt.Errorf("%s.name duplicates %q", prefix, name))
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateAgentToolSurfaces(prefix string, surfaces []string) []error {
|
||||
if len(surfaces) == 0 {
|
||||
return []error{fmt.Errorf("%s.surfaces must not be empty", prefix)}
|
||||
}
|
||||
var errs []error
|
||||
seen := map[string]struct{}{}
|
||||
for _, surface := range surfaces {
|
||||
if surface != AgentToolSurfaceKanban && surface != AgentToolSurfaceOffice {
|
||||
errs = append(errs, fmt.Errorf("%s.surfaces contains unsupported surface %q", prefix, surface))
|
||||
}
|
||||
if _, ok := seen[surface]; ok {
|
||||
errs = append(errs, fmt.Errorf("%s.surfaces duplicates %q", prefix, surface))
|
||||
}
|
||||
seen[surface] = struct{}{}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateAgentToolSchemas(prefix string, tool AgentTool) []error {
|
||||
var errs []error
|
||||
if err := validateAgentToolSchema(prefix+".input_schema", tool.InputSchema); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if len(tool.OutputSchema) > 0 {
|
||||
if err := validateAgentToolSchema(prefix+".output_schema", tool.OutputSchema); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateAgentToolSchema(name string, schema map[string]any) error {
|
||||
if len(schema) == 0 {
|
||||
return fmt.Errorf("%s must be a non-empty object schema", name)
|
||||
}
|
||||
data, err := json.Marshal(schema)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s must be valid JSON: %w", name, err)
|
||||
}
|
||||
if len(data) > 64*1024 {
|
||||
return fmt.Errorf("%s must be at most 65536 bytes", name)
|
||||
}
|
||||
if _, err := toolschema.Compile(name, schema); err != nil {
|
||||
return fmt.Errorf("%s is invalid: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dotConfigSuffix mirrors store.dotConfigSuffix: an id ending in ".config"
|
||||
// would make its "<id>.yml" record filename collide with FSStore's
|
||||
// "<id>.config.yml" operator-config naming convention (store.isRecordFile),
|
||||
|
||||
@@ -14,8 +14,10 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/mcp/plugintools"
|
||||
"github.com/kandev/kandev/internal/plugins/manifest"
|
||||
"github.com/kandev/kandev/internal/plugins/marketplace"
|
||||
"github.com/kandev/kandev/internal/plugins/pkgtar"
|
||||
@@ -75,6 +77,10 @@ type Service struct {
|
||||
// cannot deadlock against it.
|
||||
lifecycleLocks *keyedMutex
|
||||
|
||||
// agentToolInstallMu makes exposed-name collision validation and registry
|
||||
// insertion one atomic catalog mutation across different plugin IDs.
|
||||
agentToolInstallMu sync.Mutex
|
||||
|
||||
pluginsDir string
|
||||
store store.Store
|
||||
registry *Registry
|
||||
@@ -84,9 +90,14 @@ type Service struct {
|
||||
eventBus bus.EventBus
|
||||
log *logger.Logger
|
||||
|
||||
deliverer Deliverer
|
||||
runtime PluginRuntime
|
||||
secrets SecretVault
|
||||
deliverer Deliverer
|
||||
agentToolCatalogListener AgentToolCatalogListener
|
||||
agentToolGeneration string
|
||||
agentToolRevision uint64
|
||||
agentToolSnapshot plugintools.Snapshot
|
||||
agentToolSnapshotReady bool
|
||||
runtime PluginRuntime
|
||||
secrets SecretVault
|
||||
|
||||
// Host data API (ADR 0043) service-layer dependencies, wired via
|
||||
// SetDataSources and handed to every pluginHost hostForPlugin builds.
|
||||
@@ -144,12 +155,13 @@ type Service struct {
|
||||
// directly for tests that want a fake store.Store/PluginRuntime.
|
||||
func NewService(pluginStore store.Store, registry *Registry, eventBus bus.EventBus, log *logger.Logger) *Service {
|
||||
return &Service{
|
||||
store: pluginStore,
|
||||
registry: registry,
|
||||
eventBus: eventBus,
|
||||
log: log,
|
||||
httpClient: &http.Client{},
|
||||
lifecycleLocks: newKeyedMutex(),
|
||||
store: pluginStore,
|
||||
registry: registry,
|
||||
eventBus: eventBus,
|
||||
log: log,
|
||||
httpClient: &http.Client{},
|
||||
lifecycleLocks: newKeyedMutex(),
|
||||
agentToolGeneration: uuid.NewString(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +200,22 @@ func (s *Service) SetDeliverer(d Deliverer) {
|
||||
s.deliverer = d
|
||||
}
|
||||
|
||||
// SetAgentToolCatalogListener attaches the dynamic MCP registry bridge.
|
||||
func (s *Service) SetAgentToolCatalogListener(listener AgentToolCatalogListener) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.agentToolCatalogListener = listener
|
||||
}
|
||||
|
||||
func (s *Service) notifyAgentToolCatalogChanged() {
|
||||
s.mu.Lock()
|
||||
listener := s.agentToolCatalogListener
|
||||
s.mu.Unlock()
|
||||
if listener != nil {
|
||||
listener.NotifyAgentToolCatalogChanged()
|
||||
}
|
||||
}
|
||||
|
||||
// Deliverer returns the currently attached event-delivery subsystem, or nil
|
||||
// if none has been attached yet.
|
||||
func (s *Service) Deliverer() Deliverer {
|
||||
@@ -743,6 +771,17 @@ func (s *Service) Install(ctx context.Context, r io.Reader) (*store.Record, erro
|
||||
_ = os.RemoveAll(result.InstallPath)
|
||||
return nil, err
|
||||
}
|
||||
s.agentToolInstallMu.Lock()
|
||||
catalogLocked := true
|
||||
defer func() {
|
||||
if catalogLocked {
|
||||
s.agentToolInstallMu.Unlock()
|
||||
}
|
||||
}()
|
||||
if err := s.validateAgentToolInstall(result.Manifest); err != nil {
|
||||
_ = os.RemoveAll(result.InstallPath)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The plugin id is only known once pkgtar.Install has parsed the
|
||||
// package's manifest, so the per-plugin lock is acquired here rather
|
||||
@@ -780,9 +819,12 @@ func (s *Service) Install(ctx context.Context, r io.Reader) (*store.Record, erro
|
||||
return nil, fmt.Errorf("plugins: persist installed record: %w", err)
|
||||
}
|
||||
s.registry.Add(rec)
|
||||
s.agentToolInstallMu.Unlock()
|
||||
catalogLocked = false
|
||||
|
||||
activateErr := s.activate(rec)
|
||||
s.notifyDeliverer()
|
||||
s.notifyAgentToolCatalogChanged()
|
||||
|
||||
installed, getErr := s.Get(rec.ID)
|
||||
if getErr != nil {
|
||||
@@ -949,6 +991,7 @@ func (s *Service) Uninstall(ctx context.Context, id string) error {
|
||||
s.registry.Remove(id)
|
||||
s.deletePluginState(id)
|
||||
s.notifyDeliverer()
|
||||
s.notifyAgentToolCatalogChanged()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1033,6 +1076,7 @@ func (s *Service) Enable(id string) error {
|
||||
return err
|
||||
}
|
||||
s.notifyDeliverer()
|
||||
s.notifyAgentToolCatalogChanged()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1058,6 +1102,7 @@ func (s *Service) Disable(id string) error {
|
||||
return err
|
||||
}
|
||||
s.notifyDeliverer()
|
||||
s.notifyAgentToolCatalogChanged()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1160,6 +1205,7 @@ func (s *Service) handleStatusChange(id string, healthy bool, reason error) {
|
||||
zap.String("plugin_id", id), zap.Bool("healthy", healthy), zap.Error(err))
|
||||
} else {
|
||||
s.notifyDeliverer()
|
||||
s.notifyAgentToolCatalogChanged()
|
||||
if healthy {
|
||||
if d := s.Deliverer(); d != nil {
|
||||
d.Flush(id)
|
||||
@@ -1217,6 +1263,7 @@ func (s *Service) StartActivePlugins(ctx context.Context) {
|
||||
// reconcile it after an active plugin fails to spawn. Otherwise
|
||||
// its worker would continue treating the plugin as active.
|
||||
s.notifyDeliverer()
|
||||
s.notifyAgentToolCatalogChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,99 @@ runtime:
|
||||
return &buf
|
||||
}
|
||||
|
||||
func testAgentToolPackage(t *testing.T, id, toolName string) *bytes.Buffer {
|
||||
t.Helper()
|
||||
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
|
||||
manifestYAML := fmt.Sprintf(`
|
||||
id: %s
|
||||
api_version: 1
|
||||
version: 1.0.0
|
||||
display_name: Test Plugin
|
||||
runtime:
|
||||
type: binary
|
||||
executables:
|
||||
%s: server/plugin
|
||||
agent_tools:
|
||||
- name: %s
|
||||
description: Test tool
|
||||
surfaces: [kanban-task]
|
||||
input_schema:
|
||||
type: object
|
||||
`, id, platformKey, toolName)
|
||||
var buf bytes.Buffer
|
||||
if err := pkgtartest.WritePackage(&buf, map[string][]byte{
|
||||
"manifest.yaml": []byte(manifestYAML),
|
||||
"server/plugin": []byte("#!/bin/sh\necho fake\n"),
|
||||
}); err != nil {
|
||||
t.Fatalf("WritePackage: %v", err)
|
||||
}
|
||||
return &buf
|
||||
}
|
||||
|
||||
type installBarrierStore struct {
|
||||
store.Store
|
||||
firstSave chan struct{}
|
||||
secondSave chan struct{}
|
||||
releaseFirst chan struct{}
|
||||
saves int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *installBarrierStore) Save(record *store.Record) error {
|
||||
s.mu.Lock()
|
||||
s.saves++
|
||||
call := s.saves
|
||||
s.mu.Unlock()
|
||||
switch call {
|
||||
case 1:
|
||||
close(s.firstSave)
|
||||
<-s.releaseFirst
|
||||
case 2:
|
||||
close(s.secondSave)
|
||||
}
|
||||
return s.Store.Save(record)
|
||||
}
|
||||
|
||||
func TestServiceInstallSerializesAgentToolCollisionValidation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
barrier := &installBarrierStore{
|
||||
Store: store.NewFSStore(dir), firstSave: make(chan struct{}),
|
||||
secondSave: make(chan struct{}), releaseFirst: make(chan struct{}),
|
||||
}
|
||||
svc := NewService(barrier, NewRegistry(), nil, testLogger(t))
|
||||
svc.SetPluginsDir(dir)
|
||||
svc.SetRuntime(newFakeRuntime())
|
||||
firstPackage := testAgentToolPackage(t, "plugin-a", "echo")
|
||||
secondPackage := testAgentToolPackage(t, "plugin_a", "echo")
|
||||
|
||||
errs := make(chan error, 2)
|
||||
go func() {
|
||||
_, err := svc.Install(context.Background(), firstPackage)
|
||||
errs <- err
|
||||
}()
|
||||
<-barrier.firstSave
|
||||
go func() {
|
||||
_, err := svc.Install(context.Background(), secondPackage)
|
||||
errs <- err
|
||||
}()
|
||||
|
||||
secondCrossedBoundary := false
|
||||
select {
|
||||
case <-barrier.secondSave:
|
||||
secondCrossedBoundary = true
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
close(barrier.releaseFirst)
|
||||
firstErr, secondErr := <-errs, <-errs
|
||||
|
||||
if secondCrossedBoundary {
|
||||
t.Error("second install persisted before the first catalog mutation completed")
|
||||
}
|
||||
if (firstErr == nil) == (secondErr == nil) {
|
||||
t.Fatalf("install errors = %v, %v; want exactly one collision", firstErr, secondErr)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestService wires a Service against a real FSStore rooted at a temp
|
||||
// plugins dir, a fresh Registry, and a fakeRuntime — mirroring what Provide
|
||||
// does, minus the real runtime.Manager.
|
||||
|
||||
@@ -137,3 +137,10 @@ type Deliverer interface {
|
||||
// OnStatusChange callback.
|
||||
Flush(pluginID string)
|
||||
}
|
||||
|
||||
// AgentToolCatalogListener is notified after plugin lifecycle changes that
|
||||
// may alter the set of tools exposed to agent MCP sessions. Implementations
|
||||
// must return quickly and perform any rebuild asynchronously.
|
||||
type AgentToolCatalogListener interface {
|
||||
NotifyAgentToolCatalogChanged()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package pluginsdk
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// Plugin is the interface a plugin author implements. It is delivered RPCs
|
||||
@@ -19,6 +22,13 @@ type Plugin interface {
|
||||
HandleWebhook(ctx context.Context, req *WebhookRequest) (*WebhookResponse, error)
|
||||
}
|
||||
|
||||
// AgentToolPlugin is an optional extension implemented by plugins that
|
||||
// declare agent_tools in their manifest. Keeping it separate from Plugin
|
||||
// preserves source compatibility for existing plugins.
|
||||
type AgentToolPlugin interface {
|
||||
InvokeAgentTool(context.Context, *AgentToolRequest) (*AgentToolResult, error)
|
||||
}
|
||||
|
||||
// HostSetter is implemented by Plugin values that want Serve to inject the
|
||||
// Host once the broker connection back to kandev is established (see the
|
||||
// "Host injection" section of the serve.go file header). UnimplementedPlugin
|
||||
@@ -60,6 +70,10 @@ func (*UnimplementedPlugin) HandleWebhook(context.Context, *WebhookRequest) (*We
|
||||
return &WebhookResponse{Status: 404}, nil
|
||||
}
|
||||
|
||||
func (*UnimplementedPlugin) InvokeAgentTool(context.Context, *AgentToolRequest) (*AgentToolResult, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "agent tool invocation is not implemented")
|
||||
}
|
||||
|
||||
// SetHost stores the Host injected by Serve. Call Host() from your
|
||||
// overridden methods to reach kandev.
|
||||
func (p *UnimplementedPlugin) SetHost(h Host) {
|
||||
|
||||
@@ -37,6 +37,8 @@ import (
|
||||
hcplugin "github.com/hashicorp/go-plugin"
|
||||
pluginv1 "github.com/kandev/kandev/proto/kandev/plugin/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// Handshake is the go-plugin handshake shared by kandev and every plugin
|
||||
@@ -191,6 +193,20 @@ func (r *RemotePlugin) HandleWebhook(ctx context.Context, req *WebhookRequest) (
|
||||
return webhookResponseFromProto(resp), nil
|
||||
}
|
||||
|
||||
// InvokeAgentTool calls the optional plugin agent-tool RPC. The caller owns
|
||||
// timeout and retry policy because tool calls may have side effects.
|
||||
func (r *RemotePlugin) InvokeAgentTool(ctx context.Context, req *AgentToolRequest) (*AgentToolResult, error) {
|
||||
proto, err := req.toProto()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := r.client.InvokeAgentTool(ctx, proto)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return agentToolResultFromProto(resp)
|
||||
}
|
||||
|
||||
// grpcPluginServer adapts the author-facing Plugin interface to the
|
||||
// generated pluginv1.PluginServer interface. Registered inside the plugin
|
||||
// subprocess by GRPCPlugin.GRPCServer.
|
||||
@@ -218,6 +234,25 @@ func (s *grpcPluginServer) HandleWebhook(ctx context.Context, req *pluginv1.Webh
|
||||
return resp.toProto(), nil
|
||||
}
|
||||
|
||||
func (s *grpcPluginServer) InvokeAgentTool(ctx context.Context, req *pluginv1.AgentToolRequest) (*pluginv1.AgentToolResponse, error) {
|
||||
impl, ok := s.impl.(AgentToolPlugin)
|
||||
if !ok {
|
||||
return nil, status.Error(codes.Unimplemented, "agent tool invocation is not implemented")
|
||||
}
|
||||
converted, err := agentToolRequestFromProto(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := impl.InvokeAgentTool(ctx, converted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
return nil, status.Error(codes.Internal, "plugin returned a nil agent tool result")
|
||||
}
|
||||
return result.toProto()
|
||||
}
|
||||
|
||||
var _ pluginv1.PluginServer = (*grpcPluginServer)(nil)
|
||||
|
||||
// Option configures Serve.
|
||||
|
||||
@@ -6,7 +6,10 @@ import (
|
||||
"time"
|
||||
|
||||
hcplugin "github.com/hashicorp/go-plugin"
|
||||
pluginv1 "github.com/kandev/kandev/proto/kandev/plugin/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// fakeAuthorPlugin is a minimal author-facing Plugin used to exercise the
|
||||
@@ -27,6 +30,28 @@ func (p *fakeAuthorPlugin) HandleWebhook(_ context.Context, req *WebhookRequest)
|
||||
return &WebhookResponse{Status: 200, Body: append([]byte("got:"), req.Body...)}, nil
|
||||
}
|
||||
|
||||
func (p *fakeAuthorPlugin) InvokeAgentTool(_ context.Context, req *AgentToolRequest) (*AgentToolResult, error) {
|
||||
return &AgentToolResult{
|
||||
Text: "called:" + req.Context.TaskID,
|
||||
StructuredContent: map[string]any{
|
||||
"name": req.Name, "value": req.Arguments["value"],
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type nilAgentToolPlugin struct{ UnimplementedPlugin }
|
||||
|
||||
func (*nilAgentToolPlugin) InvokeAgentTool(context.Context, *AgentToolRequest) (*AgentToolResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestGRPCPluginServerRejectsNilAgentToolResult(t *testing.T) {
|
||||
server := &grpcPluginServer{impl: &nilAgentToolPlugin{}}
|
||||
_, err := server.InvokeAgentTool(context.Background(), &pluginv1.AgentToolRequest{Name: "echo"})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, codes.Internal, status.Code(err))
|
||||
}
|
||||
|
||||
// TestServe_EndToEnd exercises the same GRPCPlugin wiring Serve() (plugin
|
||||
// side) and kandev's runtime manager (host side, via plugin.NewClient)
|
||||
// use, over a real go-plugin gRPC connection + broker
|
||||
@@ -80,6 +105,16 @@ func TestServe_EndToEnd(t *testing.T) {
|
||||
require.Equal(t, []byte("got:hi"), resp.Body)
|
||||
})
|
||||
|
||||
t.Run("InvokeAgentTool", func(t *testing.T) {
|
||||
resp, err := remote.InvokeAgentTool(context.Background(), &AgentToolRequest{
|
||||
InvocationID: "inv-1", Name: "echo", Arguments: map[string]any{"value": "ok"},
|
||||
Context: AgentToolContext{TaskID: "task-1", SessionID: "session-1", WorkspaceID: "workspace-1", Surface: "kanban-task"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "called:task-1", resp.Text)
|
||||
require.Equal(t, "echo", resp.StructuredContent["name"])
|
||||
})
|
||||
|
||||
t.Run("HostBrokerRoundTrip", func(t *testing.T) {
|
||||
require.Eventually(t, func() bool {
|
||||
return author.Host() != nil
|
||||
|
||||
@@ -99,6 +99,71 @@ type WebhookResponse struct {
|
||||
Body []byte
|
||||
}
|
||||
|
||||
type AgentToolContext struct {
|
||||
TaskID string
|
||||
SessionID string
|
||||
WorkspaceID string
|
||||
Surface string
|
||||
}
|
||||
|
||||
type AgentToolRequest struct {
|
||||
InvocationID string
|
||||
Name string
|
||||
Arguments map[string]any
|
||||
Context AgentToolContext
|
||||
}
|
||||
|
||||
type AgentToolResult struct {
|
||||
Text string
|
||||
StructuredContent map[string]any
|
||||
IsError bool
|
||||
}
|
||||
|
||||
func (r *AgentToolRequest) toProto() (*pluginv1.AgentToolRequest, error) {
|
||||
arguments, err := mapToStruct(r.Arguments)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pluginsdk: agent tool arguments: %w", err)
|
||||
}
|
||||
return &pluginv1.AgentToolRequest{
|
||||
InvocationId: r.InvocationID,
|
||||
Name: r.Name,
|
||||
Arguments: arguments,
|
||||
Context: &pluginv1.AgentToolContext{
|
||||
TaskId: r.Context.TaskID, SessionId: r.Context.SessionID,
|
||||
WorkspaceId: r.Context.WorkspaceID, Surface: r.Context.Surface,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func agentToolRequestFromProto(p *pluginv1.AgentToolRequest) (*AgentToolRequest, error) {
|
||||
arguments, err := structToMap(p.GetArguments())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pluginsdk: agent tool arguments: %w", err)
|
||||
}
|
||||
ctx := p.GetContext()
|
||||
return &AgentToolRequest{
|
||||
InvocationID: p.GetInvocationId(), Name: p.GetName(), Arguments: arguments,
|
||||
Context: AgentToolContext{TaskID: ctx.GetTaskId(), SessionID: ctx.GetSessionId(),
|
||||
WorkspaceID: ctx.GetWorkspaceId(), Surface: ctx.GetSurface()},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *AgentToolResult) toProto() (*pluginv1.AgentToolResponse, error) {
|
||||
structured, err := mapToStruct(r.StructuredContent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pluginsdk: agent tool result: %w", err)
|
||||
}
|
||||
return &pluginv1.AgentToolResponse{Text: r.Text, StructuredContent: structured, IsError: r.IsError}, nil
|
||||
}
|
||||
|
||||
func agentToolResultFromProto(p *pluginv1.AgentToolResponse) (*AgentToolResult, error) {
|
||||
structured, err := structToMap(p.GetStructuredContent())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pluginsdk: agent tool result: %w", err)
|
||||
}
|
||||
return &AgentToolResult{Text: p.GetText(), StructuredContent: structured, IsError: p.GetIsError()}, nil
|
||||
}
|
||||
|
||||
func (r *WebhookResponse) toProto() *pluginv1.WebhookResponse {
|
||||
return &pluginv1.WebhookResponse{
|
||||
Status: r.Status,
|
||||
|
||||
@@ -404,6 +404,8 @@ const (
|
||||
ActionMCPListTaskDocuments = "mcp.list_task_documents"
|
||||
ActionMCPGetTaskDocument = "mcp.get_task_document"
|
||||
ActionMCPWriteTaskDocument = "mcp.write_task_document"
|
||||
ActionMCPListPluginTools = "mcp.list_plugin_tools"
|
||||
ActionMCPInvokePluginTool = "mcp.invoke_plugin_tool"
|
||||
|
||||
// Config-mode MCP actions (agent-native configuration)
|
||||
ActionMCPCreateWorkflow = "mcp.create_workflow"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ option go_package = "github.com/kandev/kandev/proto/kandev/plugin/v1;pluginv1";
|
||||
service Plugin {
|
||||
rpc DeliverEvent(Event) returns (EventAck);
|
||||
rpc HandleWebhook(WebhookRequest) returns (WebhookResponse);
|
||||
rpc InvokeAgentTool(AgentToolRequest) returns (AgentToolResponse);
|
||||
}
|
||||
|
||||
// Implemented by KANDEV (served back over the go-plugin broker).
|
||||
@@ -111,6 +112,24 @@ message Event {
|
||||
}
|
||||
message EventAck {}
|
||||
|
||||
message AgentToolRequest {
|
||||
string invocation_id = 1;
|
||||
string name = 2;
|
||||
google.protobuf.Struct arguments = 3;
|
||||
AgentToolContext context = 4;
|
||||
}
|
||||
message AgentToolContext {
|
||||
string task_id = 1;
|
||||
string session_id = 2;
|
||||
string workspace_id = 3;
|
||||
string surface = 4;
|
||||
}
|
||||
message AgentToolResponse {
|
||||
string text = 1;
|
||||
google.protobuf.Struct structured_content = 2;
|
||||
bool is_error = 3;
|
||||
}
|
||||
|
||||
message WebhookRequest {
|
||||
string webhook_key = 1;
|
||||
string method = 2;
|
||||
|
||||
@@ -19,8 +19,9 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
Plugin_DeliverEvent_FullMethodName = "/kandev.plugin.v1.Plugin/DeliverEvent"
|
||||
Plugin_HandleWebhook_FullMethodName = "/kandev.plugin.v1.Plugin/HandleWebhook"
|
||||
Plugin_DeliverEvent_FullMethodName = "/kandev.plugin.v1.Plugin/DeliverEvent"
|
||||
Plugin_HandleWebhook_FullMethodName = "/kandev.plugin.v1.Plugin/HandleWebhook"
|
||||
Plugin_InvokeAgentTool_FullMethodName = "/kandev.plugin.v1.Plugin/InvokeAgentTool"
|
||||
)
|
||||
|
||||
// PluginClient is the client API for Plugin service.
|
||||
@@ -31,6 +32,7 @@ const (
|
||||
type PluginClient interface {
|
||||
DeliverEvent(ctx context.Context, in *Event, opts ...grpc.CallOption) (*EventAck, error)
|
||||
HandleWebhook(ctx context.Context, in *WebhookRequest, opts ...grpc.CallOption) (*WebhookResponse, error)
|
||||
InvokeAgentTool(ctx context.Context, in *AgentToolRequest, opts ...grpc.CallOption) (*AgentToolResponse, error)
|
||||
}
|
||||
|
||||
type pluginClient struct {
|
||||
@@ -61,6 +63,16 @@ func (c *pluginClient) HandleWebhook(ctx context.Context, in *WebhookRequest, op
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *pluginClient) InvokeAgentTool(ctx context.Context, in *AgentToolRequest, opts ...grpc.CallOption) (*AgentToolResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AgentToolResponse)
|
||||
err := c.cc.Invoke(ctx, Plugin_InvokeAgentTool_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PluginServer is the server API for Plugin service.
|
||||
// All implementations must embed UnimplementedPluginServer
|
||||
// for forward compatibility.
|
||||
@@ -69,6 +81,7 @@ func (c *pluginClient) HandleWebhook(ctx context.Context, in *WebhookRequest, op
|
||||
type PluginServer interface {
|
||||
DeliverEvent(context.Context, *Event) (*EventAck, error)
|
||||
HandleWebhook(context.Context, *WebhookRequest) (*WebhookResponse, error)
|
||||
InvokeAgentTool(context.Context, *AgentToolRequest) (*AgentToolResponse, error)
|
||||
mustEmbedUnimplementedPluginServer()
|
||||
}
|
||||
|
||||
@@ -85,6 +98,9 @@ func (UnimplementedPluginServer) DeliverEvent(context.Context, *Event) (*EventAc
|
||||
func (UnimplementedPluginServer) HandleWebhook(context.Context, *WebhookRequest) (*WebhookResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method HandleWebhook not implemented")
|
||||
}
|
||||
func (UnimplementedPluginServer) InvokeAgentTool(context.Context, *AgentToolRequest) (*AgentToolResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method InvokeAgentTool not implemented")
|
||||
}
|
||||
func (UnimplementedPluginServer) mustEmbedUnimplementedPluginServer() {}
|
||||
func (UnimplementedPluginServer) testEmbeddedByValue() {}
|
||||
|
||||
@@ -142,6 +158,24 @@ func _Plugin_HandleWebhook_Handler(srv interface{}, ctx context.Context, dec fun
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Plugin_InvokeAgentTool_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AgentToolRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(PluginServer).InvokeAgentTool(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: Plugin_InvokeAgentTool_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(PluginServer).InvokeAgentTool(ctx, req.(*AgentToolRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// Plugin_ServiceDesc is the grpc.ServiceDesc for Plugin service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -157,6 +191,10 @@ var Plugin_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "HandleWebhook",
|
||||
Handler: _Plugin_HandleWebhook_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "InvokeAgentTool",
|
||||
Handler: _Plugin_InvokeAgentTool_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "kandev/plugin/v1/plugin.proto",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# ADR-2026-08-11-plugin-tools-through-kandev-mcp: Route Plugin Agent Tools Through Kandev MCP
|
||||
|
||||
**Status:** accepted
|
||||
**Date:** 2026-08-11
|
||||
**Area:** backend, agentctl, protocol, plugins, security
|
||||
|
||||
## Context
|
||||
|
||||
Kandev plugins can receive events, handle webhooks, use capability-gated Host
|
||||
APIs, and invoke a selected utility agent, but task agents cannot invoke
|
||||
plugin-owned operations. The first plugin buildout briefly had a parallel
|
||||
`tools[]` plus `InvokeTool` path, but it never reached agent tool discovery and
|
||||
was removed because MCP is already Kandev's agent-tool protocol. Requiring each
|
||||
plugin to expose a separate MCP server would duplicate installation, transport,
|
||||
credentials, task context, and lifecycle policy.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Plugin-contributed agent tools are declared in the installed plugin manifest
|
||||
and invoked through the existing supervised plugin gRPC process.
|
||||
2. Agentctl exposes those declarations as proxy tools on Kandev's existing MCP
|
||||
server. It never connects agents directly to a plugin-owned MCP endpoint.
|
||||
3. The backend owns the complete active plugin-tool catalog and the applicability
|
||||
decision. Agentctl receives immutable runtime descriptors and filters them by
|
||||
the backend-owned MCP surface; an agent cannot request or register tools.
|
||||
4. Plugin tool names use the readable provider-safe
|
||||
`kandev_<slugged-plugin-id>_<local-name>` namespace. Slugs replace
|
||||
punctuation with underscores; names exceeding the provider limit truncate
|
||||
the slug and append a short stable hash suffix. Local names contain only
|
||||
lowercase alphanumerics and underscores; install and upgrade validation
|
||||
reject invalid or colliding final names.
|
||||
5. The existing atomic `SetTools` registry replacement is the live-update
|
||||
mechanism. Catalog changes send `notifications/tools/list_changed` without
|
||||
restarting the agent, agentctl, or task session.
|
||||
6. Catalog snapshots carry a backend-process generation and monotonic revision.
|
||||
Agentctl ignores an older revision from the same generation so delayed
|
||||
refreshes cannot restore stale tools.
|
||||
7. Discovery is never authorization. The backend revalidates current plugin
|
||||
state, declaration, surface, bound task/session identity, and input schema on
|
||||
every invocation before dispatching the plugin RPC.
|
||||
8. Plugin tool invocation adds no Host capability. Existing manifest capability
|
||||
gates remain the authority for data, state, secrets, authentication, and
|
||||
utility-agent access.
|
||||
9. Tool calls are cancelable, bounded, single-attempt operations. Kandev does
|
||||
not retry a failed call because it cannot know whether a plugin performed a
|
||||
side effect before returning or disconnecting.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Plugin authors get one installable package and one lifecycle for UI, events,
|
||||
Host APIs, and agent tools.
|
||||
- Task agents can gain and lose plugin tools during a running MCP connection on
|
||||
clients that honor `tools/list_changed`; other clients converge on reconnect.
|
||||
- Backend and agentctl gain a versioned dynamic descriptor contract and a new
|
||||
backend dispatch action, while plugin execution continues over the existing
|
||||
go-plugin/gRPC channel.
|
||||
- A stale client may display a removed tool briefly, but backend revalidation
|
||||
makes stale discovery fail closed.
|
||||
- Tool definitions add agent context and installed plugin code remains trusted,
|
||||
privileged code. Conservative limits and annotations reduce, but do not
|
||||
eliminate, that operational risk.
|
||||
- The initial version is instance-wide and task-surface-scoped. Finer workspace
|
||||
or profile enablement requires a later product contract rather than ad hoc
|
||||
filtering in agentctl.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Require every plugin to run its own MCP server
|
||||
|
||||
Rejected because it duplicates runtime supervision and credentials, depends on
|
||||
executor network reachability, requires manual profile configuration, and
|
||||
cannot reliably follow plugin enable/disable or Kandev task identity.
|
||||
|
||||
### Restore the original parallel plugin tool HTTP/RPC API
|
||||
|
||||
Rejected because agents would need a second discovery and invocation protocol,
|
||||
and Kandev would have to reproduce MCP schemas, notifications, results, and
|
||||
client compatibility behavior.
|
||||
|
||||
### Let plugins register arbitrary tools directly with agentctl
|
||||
|
||||
Rejected because plugin processes do not own task/session authorization or MCP
|
||||
surface policy. Direct registration also makes stale registrations and name
|
||||
collisions harder to revoke safely.
|
||||
|
||||
### Restart agents whenever the catalog changes
|
||||
|
||||
Rejected because mcp-go already supports atomic tool replacement and MCP defines
|
||||
`tools/list_changed`. Restarting destroys conversational continuity and turns a
|
||||
plugin lifecycle event into an unrelated agent lifecycle failure.
|
||||
|
||||
### Expose one generic `invoke_plugin` MCP tool
|
||||
|
||||
Rejected because agents would lose per-operation descriptions, schemas,
|
||||
annotations, and selective discovery. It would also move validation into prompt
|
||||
conventions rather than the MCP contract.
|
||||
@@ -146,6 +146,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
|
||||
| 2026-08-09-exclusive-runtime-state-ownership | [Lock Runtime State Before Backend Startup](2026-08-09-exclusive-runtime-state-ownership.md) | accepted | backend, infra | 2026-08-09 |
|
||||
| 2026-08-10-duration-aware-e2e-sharding | [Duration-aware E2E sharding uses rolling `main` timings](2026-08-10-duration-aware-e2e-sharding.md) | accepted | infra, workflow | 2026-08-10 |
|
||||
| 2026-08-10-debug-launcher-profile-selection | [Keep Debug Launches on the Production Profile](2026-08-10-debug-launcher-profile-selection.md) | accepted | backend, frontend, cli | 2026-08-10 |
|
||||
| 2026-08-11-plugin-tools-through-kandev-mcp | [Route Plugin Agent Tools Through Kandev MCP](2026-08-11-plugin-tools-through-kandev-mcp.md) | accepted | backend, agentctl, protocol, plugins, security | 2026-08-11 |
|
||||
| 2026-08-10-remote-contribution-head-drift | [Separate Current Contribution and Local Checkout Histories](2026-08-10-remote-contribution-head-drift.md) | accepted (remote-action policy superseded) | backend, frontend, protocol, GitHub, GitLab | 2026-08-10 |
|
||||
| 2026-08-12-local-first-contribution-replacement | [Keep Remote Contribution Work Local-First](2026-08-12-local-first-contribution-replacement.md) | accepted | backend, frontend, protocol, security, GitHub, GitLab | 2026-08-12 |
|
||||
| 2026-08-11-user-owned-status-bar-visibility | [Make Status Bar Visibility a Portable User Preference](2026-08-11-user-owned-status-bar-visibility.md) | accepted | backend, frontend, protocol | 2026-08-11 |
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
---
|
||||
spec: docs/specs/plugins/agent-tools.md
|
||||
created: 2026-08-11
|
||||
status: in_progress
|
||||
---
|
||||
|
||||
# Implementation Plan: Plugin-Contributed Agent Tools
|
||||
|
||||
## Overview
|
||||
|
||||
Add a bounded manifest and SDK contract first, then implement the authoritative
|
||||
plugin catalog and gRPC invocation path. Extend the typed MCP profile with
|
||||
dynamic plugin descriptors, teach agentctl to atomically register proxy tools,
|
||||
and wire launch plus live catalog refresh through the existing runtime. Finish
|
||||
with an end-to-end managed-plugin fixture and public authoring documentation.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
### Manifest, transport, and SDK contracts
|
||||
|
||||
- Extend `apps/backend/internal/plugins/manifest/manifest.go` with
|
||||
`AgentTools []AgentTool`, `AgentToolAnnotations`, surface constants, and the
|
||||
exact YAML/JSON fields from the spec.
|
||||
- Extend `apps/backend/internal/plugins/manifest/validate.go` with per-plugin
|
||||
count, name, surface, description, exposed-name length, annotation, schema
|
||||
size, object-root, and JSON Schema compilation checks. Extract the reusable
|
||||
compiler from `internal/mcp/server/tool_argument_validation.go` into
|
||||
`internal/mcp/toolschema` so install-time and invocation-time validation have
|
||||
one interpretation.
|
||||
- Derive readable exposed names in `internal/mcp/plugintools` from a slugged
|
||||
plugin ID plus the validated local name. Keep the complete canonical name
|
||||
compatible with restrictive agent tool-name grammars, using a short stable
|
||||
hash suffix only when the readable form exceeds the length limit.
|
||||
- Add `apps/backend/internal/mcp/plugintools/types.go` for the normalized runtime
|
||||
descriptor and catalog snapshot (`generation`, `revision`, sorted tools).
|
||||
Keep this package independent of plugin storage and agentctl.
|
||||
- Add `InvokeAgentTool` plus request, bound context, and result messages to
|
||||
`apps/backend/proto/kandev/plugin/v1/plugin.proto`; regenerate protobuf and
|
||||
gRPC stubs with `make -C apps/backend proto`.
|
||||
- Extend `apps/backend/pkg/pluginsdk/plugin.go`, `types.go`, and `serve.go` with
|
||||
an optional `AgentToolPlugin` interface. Keep the base `Plugin` interface
|
||||
unchanged, make the gRPC adapter return `Unimplemented` when the optional
|
||||
interface is absent, and expose a thin context-respecting `RemotePlugin`
|
||||
client.
|
||||
|
||||
### Authoritative catalog and invocation
|
||||
|
||||
- Add catalog derivation to `apps/backend/internal/plugins/service.go` (or a
|
||||
focused `agent_tools.go`): return a canonical snapshot from installed records,
|
||||
include only `StatusActive`, reserve names across installed declarations, and
|
||||
increment a process-generation/revision identity only when effective content
|
||||
changes.
|
||||
- Validate cross-plugin exposed-name collisions before install/upgrade commits
|
||||
in the existing service transaction path so a failed replacement preserves
|
||||
the previous version and catalog.
|
||||
- Add `Service.InvokeAgentTool(ctx, pluginID, localName, arguments, boundContext)`
|
||||
beside `InvokeWebhook`. Re-read the current record, require `StatusActive`,
|
||||
find the exact declaration, validate surface/input, enforce the 30-second
|
||||
deadline and 1 MiB result limit, call `RemotePlugin.InvokeAgentTool` once, and
|
||||
validate output before returning a transport-neutral result.
|
||||
- Publish a nonblocking catalog-change signal after install, upgrade, enable,
|
||||
disable, error, recovery, and uninstall persistence succeeds. Do not invoke
|
||||
refresh callbacks while the plugin service mutex or runtime supervision
|
||||
callback is blocked.
|
||||
- Add structured invocation logs containing identifiers, duration, and outcome
|
||||
only; never log arguments or results.
|
||||
|
||||
### Dynamic MCP registry in agentctl
|
||||
|
||||
- Extend `internal/mcp/profile.Context` with the canonical plugin-tool snapshot;
|
||||
normalization deep-copies and sorts descriptors and includes them in profile
|
||||
equality.
|
||||
- Add `Server.SetPluginTools(snapshot)` and incorporate a `plugin-tools` group
|
||||
into `profileToolGroups`. Filter definitions to `kanban-task` and
|
||||
`office-task`, construct tools with `mcp.NewToolWithRawSchema`, attach output
|
||||
schema and annotations, and bind each handler closure to plugin ID/local name.
|
||||
- The dynamic handler forwards only arguments plus server-bound task/session
|
||||
identity through a new `mcp.invoke_plugin_tool` WebSocket action. It maps the
|
||||
backend's fallback text, structured content, and `is_error` response to one
|
||||
`mcp.CallToolResult`.
|
||||
- Add `PUT /api/v1/mcp/plugin-tools` and the matching runtime agentctl client.
|
||||
Accept a new generation, ignore stale same-generation revisions, skip
|
||||
equivalent effective registries, and rely on one `SetTools` call to emit one
|
||||
list-changed notification.
|
||||
- Preserve existing shared top-level argument validation for dynamic schemas.
|
||||
A malformed runtime descriptor must fail closed without replacing the last
|
||||
valid registry.
|
||||
|
||||
### Backend dispatch and lifecycle propagation
|
||||
|
||||
- Add `ActionMCPInvokePluginTool` to `apps/backend/pkg/websocket/actions.go` and
|
||||
register a handler in `apps/backend/internal/mcp/handlers` with a narrow
|
||||
plugin-tool service interface.
|
||||
- The handler takes plugin/tool identity from the agentctl closure, task/session
|
||||
identity from the bound MCP server payload, and workspace/surface from
|
||||
persisted backend state. Apply `mcp/scope` identity and task-session pair
|
||||
authorization before plugin lookup; repeat schema and declaration checks in
|
||||
the plugin service.
|
||||
- Inject the current catalog snapshot when
|
||||
`orchestrator/executor.resolveTaskSessionMCPProfile` builds launch/resume
|
||||
profiles. Keep configuration and external surfaces empty.
|
||||
- Add a lifecycle method that replaces plugin tools on every live agentctl
|
||||
execution. Wire a backendapp catalog refresher/coalescer to the plugin service
|
||||
change signal; serialize snapshots, continue past per-execution failures, and
|
||||
leave persisted plugin state authoritative.
|
||||
- Ensure launch/resume and the next live change repair a failed refresh. Do not
|
||||
restart an agent solely because a client may ignore list-changed.
|
||||
|
||||
### Managed fixture and full-path proof
|
||||
|
||||
- Extend the runtime fixture plugin to declare and implement an echo/context
|
||||
agent tool and a controlled error/timeout case.
|
||||
- Add an integration test that starts the managed fixture, creates the task MCP
|
||||
server over the real backend request bridge, lists the dynamic tool, invokes
|
||||
it, and verifies bound context and result mapping.
|
||||
- Cover live enable/disable or active/error/recovery replacement with an
|
||||
initialized MCP client and assert exactly one list-changed notification per
|
||||
effective change, no notification for a stale/equivalent snapshot, and
|
||||
fail-closed invocation after removal.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** Manifest limits, surface rules, schema compilation, conservative
|
||||
annotation defaults, deterministic names, and collision rejection.
|
||||
**File:** `internal/plugins/manifest/manifest_test.go`,
|
||||
`internal/plugins/service_test.go`. **How:** table-driven unit tests plus a
|
||||
failed-upgrade preservation test.
|
||||
- **What:** SDK/protobuf invocation conversion and source-compatible
|
||||
`UnimplementedPlugin`. **File:** `pkg/pluginsdk/plugin_test.go`,
|
||||
`pkg/pluginsdk/serve_test.go`. **How:** in-memory go-plugin gRPC round trip.
|
||||
- **What:** Active-only canonical catalog revisions and bounded, non-retried
|
||||
invocation with schema/result enforcement. **File:**
|
||||
`internal/plugins/agent_tools_test.go`. **How:** fake runtime plus controlled
|
||||
remote plugin tests.
|
||||
- **What:** Surface-filtered atomic registry replacement, annotations, stale
|
||||
revision rejection, schema enforcement, and one list-changed notification.
|
||||
**File:** `internal/mcp/server/plugin_tools_test.go`. **How:** initialized
|
||||
mcp-go session with a recording backend client.
|
||||
- **What:** Agentctl live control transport preserves built-in surface,
|
||||
capabilities, and providers while replacing plugin descriptors. **File:**
|
||||
`internal/agentctl/server/api/mcp_plugin_tools_test.go`,
|
||||
`internal/agent/runtime/agentctl/client_mcp_plugin_tools_test.go`. **How:** HTTP
|
||||
handler/client contract tests.
|
||||
- **What:** Backend invocation rejects stale discovery, mismatched task/session,
|
||||
unsupported surface, and invalid input before entering the plugin. **File:**
|
||||
`internal/mcp/handlers/plugin_tools_test.go`. **How:** scoped dispatcher tests
|
||||
with a recording plugin-tool service.
|
||||
- **What:** Launch/resume carry the complete snapshot and live changes reach all
|
||||
active executions without one failure blocking others. **File:**
|
||||
`internal/orchestrator/executor/executor_mcp_mode_test.go`,
|
||||
`internal/agent/runtime/lifecycle/manager_mcp_plugin_tools_test.go`,
|
||||
`internal/backendapp/plugin_tool_refresher_test.go`. **How:** resolver,
|
||||
lifecycle client, and coalescing refresher tests.
|
||||
- **What:** Managed plugin to MCP end-to-end discovery, invocation, bound
|
||||
context, output mapping, and hot removal. **File:** a focused integration test
|
||||
under `internal/backendapp` or `internal/plugins/runtime`. **How:** real fixture
|
||||
subprocess and MCP JSON-RPC transport with bounded timeouts and teardown.
|
||||
|
||||
## Verification Results
|
||||
|
||||
- Implemented and verified manifest/SDK, plugin catalog/invocation, dynamic MCP
|
||||
registry, backend dispatch, and the managed fixture package.
|
||||
- Focused race suites pass for `internal/plugins`, `internal/mcp/server`,
|
||||
`internal/mcp/handlers`, `internal/agent/runtime/lifecycle`,
|
||||
`internal/agent/runtime/agentctl`, `internal/agentctl/server/api`,
|
||||
`internal/backendapp`, and `pkg/pluginsdk`.
|
||||
- `go test ./...` reaches unrelated existing filesystem/task-service failures
|
||||
in this environment; no plugin/MCP package failures were reported.
|
||||
- A live backend on port `18101` has the fixture package installed and active.
|
||||
|
||||
## Implementation Waves And Parallel Candidates
|
||||
|
||||
Wave 1:
|
||||
|
||||
- [x] [Task 01: Manifest and SDK contract](task-01-manifest-sdk-contract.md)
|
||||
|
||||
Wave 2 (parallel candidates after Task 01; user authorization required):
|
||||
|
||||
- [x] [Task 02: Plugin catalog and invocation](task-02-plugin-catalog-invocation.md)
|
||||
- [x] [Task 03: Agentctl dynamic MCP registry](task-03-agentctl-dynamic-registry.md)
|
||||
|
||||
Wave 3:
|
||||
|
||||
- [x] [Task 04: Backend dispatch and runtime propagation](task-04-runtime-propagation-backend-dispatch.md)
|
||||
|
||||
Wave 4:
|
||||
|
||||
- [ ] [Task 05: Managed fixture end-to-end proof](task-05-end-to-end-fixture.md)
|
||||
|
||||
Wave 5:
|
||||
|
||||
- [x] [Task 06: Plugin agent-tool documentation](task-06-authoring-docs.md)
|
||||
|
||||
The default implementation order is sequential in the primary conversation.
|
||||
Wave labels do not authorize subagents.
|
||||
|
||||
## Open Risks
|
||||
|
||||
- MCP clients vary in whether they honor `tools/list_changed`; conformance must
|
||||
be checked against supported clients, but backend authorization cannot depend
|
||||
on refresh behavior.
|
||||
- Plugin lifecycle callbacks run on supervision paths that must not block.
|
||||
Catalog propagation needs a serialized, nonblocking coordinator.
|
||||
- The public manifest/protobuf/SDK contract is additive but durable. Generated
|
||||
stubs, authoring docs, fixture packages, and minimum Kandev version guidance
|
||||
must land together.
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
id: "01-manifest-sdk-contract"
|
||||
title: "Manifest and SDK contract"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/plugins/agent-tools.md"
|
||||
adr: "../../decisions/2026-08-11-plugin-tools-through-kandev-mcp.md"
|
||||
---
|
||||
|
||||
# Task 01: Manifest and SDK contract
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The manifest and normalized runtime descriptor represent every field and
|
||||
limit in the spec, reject invalid schemas/surfaces/names, and derive bounded
|
||||
deterministic exposed names.
|
||||
- The plugin protobuf and Go SDK support one context-respecting
|
||||
`InvokeAgentTool` RPC with fallback text, optional structured content, and
|
||||
`is_error`; the optional `AgentToolPlugin` interface leaves the base `Plugin`
|
||||
interface and existing implementations source-compatible.
|
||||
- Protobuf stubs are regenerated, not edited manually, and focused manifest and
|
||||
SDK round-trip tests pass.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
make -C apps/backend proto && cd apps/backend && go test -race ./internal/plugins/manifest ./internal/mcp/plugintools ./pkg/pluginsdk
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/plugins/manifest/manifest.go`
|
||||
- `apps/backend/internal/plugins/manifest/validate.go`
|
||||
- `apps/backend/internal/plugins/manifest/manifest_test.go`
|
||||
- `apps/backend/internal/mcp/plugintools/types.go`
|
||||
- `apps/backend/internal/mcp/plugintools/types_test.go`
|
||||
- `apps/backend/internal/mcp/toolschema/schema.go`
|
||||
- `apps/backend/internal/mcp/toolschema/schema_test.go`
|
||||
- `apps/backend/proto/kandev/plugin/v1/plugin.proto`
|
||||
- `apps/backend/proto/kandev/plugin/v1/plugin.pb.go`
|
||||
- `apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go`
|
||||
- `apps/backend/pkg/pluginsdk/plugin.go`
|
||||
- `apps/backend/pkg/pluginsdk/types.go`
|
||||
- `apps/backend/pkg/pluginsdk/serve.go`
|
||||
- `apps/backend/pkg/pluginsdk/plugin_test.go`
|
||||
- `apps/backend/pkg/pluginsdk/serve_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. This task establishes shared manifest, protobuf, generated, and SDK
|
||||
contracts consumed by every later task.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections `What`, `Manifest Contract`, and `Plugin SDK Contract`
|
||||
- ADR decisions 1, 4, 8, and 9
|
||||
- Existing `manifest.Validate`, protobuf `service Plugin`, pluginsdk optional
|
||||
interface/adapter patterns, and MCP JSON Schema validation behavior
|
||||
|
||||
## Risks
|
||||
|
||||
- A protobuf field or SDK signature mistake becomes a public compatibility
|
||||
burden. Keep request context backend-authored and result content bounded.
|
||||
- JSON Schema must have one interpretation at install and invocation time.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report RED tests, exact public fields and defaults, generated files, commands and
|
||||
results, compatibility notes, and task/plan status updates.
|
||||
|
||||
## Results
|
||||
|
||||
- RED: `TestParse_AgentToolDeclarationIsRetained` failed because the existing
|
||||
manifest parser had no `AgentTools` field.
|
||||
- Added manifest declarations and validation for names, surfaces, schemas,
|
||||
limits, and annotation consistency; added normalized readable provider-safe
|
||||
names and catalog snapshots under `internal/mcp/plugintools`.
|
||||
- Added shared JSON Schema compilation under `internal/mcp/toolschema`.
|
||||
- Added optional `pluginsdk.AgentToolPlugin` and gRPC request/response
|
||||
conversion. The base `Plugin` interface remains unchanged for compatibility.
|
||||
- Added `InvokeAgentTool` to the plugin protobuf and regenerated stubs with
|
||||
`make proto`.
|
||||
- Verification: `make proto` (pass); `go test -race ./internal/plugins/manifest ./internal/mcp/toolschema ./internal/mcp/plugintools ./pkg/pluginsdk` (pass); focused SDK round trip `go test -race -run 'TestServe_EndToEnd/InvokeAgentTool' ./pkg/pluginsdk` (pass).
|
||||
- Remaining risk: backend catalog invocation and agentctl registry are not yet
|
||||
wired; task 02 begins that work.
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
id: "02-plugin-catalog-invocation"
|
||||
title: "Plugin catalog and invocation"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-manifest-sdk-contract"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/plugins/agent-tools.md"
|
||||
adr: "../../decisions/2026-08-11-plugin-tools-through-kandev-mcp.md"
|
||||
---
|
||||
|
||||
# Task 02: Plugin catalog and invocation
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The plugin service produces sorted, active-only, generation/revision catalog
|
||||
snapshots and rejects install/upgrade collisions without replacing the prior
|
||||
installation.
|
||||
- `InvokeAgentTool` revalidates current state, declaration, surface, input, and
|
||||
result; enforces deadline/size bounds; calls the plugin once; and logs only
|
||||
safe metadata.
|
||||
- Every effective lifecycle change emits a nonblocking catalog-change signal;
|
||||
equivalent changes do not advance the revision or emit duplicate work.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test -race ./internal/plugins/...
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/plugins/agent_tools.go`
|
||||
- `apps/backend/internal/plugins/agent_tools_test.go`
|
||||
- `apps/backend/internal/plugins/invoke.go`
|
||||
- `apps/backend/internal/plugins/service.go`
|
||||
- `apps/backend/internal/plugins/service_test.go`
|
||||
- `apps/backend/internal/plugins/types.go`
|
||||
- `apps/backend/internal/plugins/provider.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Parallel-safe with Task 03 after Task 01. This task owns plugin service/catalog
|
||||
files; Task 03 owns MCP server and agentctl files.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections `Runtime Catalog`, `Permissions`, `Failure Modes`, and
|
||||
`Persistence Guarantees`
|
||||
- Existing `InvokeWebhook`, install transaction, status transition, runtime
|
||||
supervision callback, and `notifyDeliverer` patterns
|
||||
|
||||
## Risks
|
||||
|
||||
- Runtime health callbacks must not block while catalog refresh reaches remote
|
||||
agentctl instances.
|
||||
- Tool calls are not retry-safe; no existing event-delivery retry helper may be
|
||||
reused for invocation.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report catalog ordering/revision rules, collision transaction behavior,
|
||||
invocation bounds, safe logging fields, commands and results, and task/plan
|
||||
status updates.
|
||||
|
||||
## Results
|
||||
|
||||
- Added the active-only catalog builder with stable generation/revision and
|
||||
readable plugin-ID-derived exposed names, with bounded hash fallback for long
|
||||
IDs.
|
||||
- Added install collision checks, lifecycle refresh signaling, argument and
|
||||
result schema validation, 30-second deadlines, and the 1 MiB result cap.
|
||||
- Verification: `go test -race ./internal/plugins/...` passed.
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
id: "03-agentctl-dynamic-registry"
|
||||
title: "Agentctl dynamic MCP registry"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-manifest-sdk-contract"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/plugins/agent-tools.md"
|
||||
adr: "../../decisions/2026-08-11-plugin-tools-through-kandev-mcp.md"
|
||||
---
|
||||
|
||||
# Task 03: Agentctl dynamic MCP registry
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Typed MCP profiles carry plugin catalog snapshots and agentctl registers only
|
||||
declarations matching Kanban or Office surfaces with exact schemas,
|
||||
annotations, and bound proxy handlers.
|
||||
- Live control accepts new generations/newer revisions, ignores stale or
|
||||
equivalent snapshots, replaces tools atomically, and emits exactly one
|
||||
`tools/list_changed` notification per effective registry change.
|
||||
- Dynamic calls pass only arguments plus agentctl-bound task/session identity to
|
||||
the backend; malformed descriptors fail without replacing the last valid
|
||||
registry.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test -race ./internal/mcp/profile ./internal/mcp/server ./internal/agentctl/server/api ./internal/agent/runtime/agentctl
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/mcp/profile/profile.go`
|
||||
- `apps/backend/internal/mcp/profile/profile_test.go`
|
||||
- `apps/backend/internal/mcp/server/server.go`
|
||||
- `apps/backend/internal/mcp/server/plugin_tools_test.go`
|
||||
- `apps/backend/internal/mcp/server/tool_argument_validation.go`
|
||||
- `apps/backend/internal/mcp/toolschema/schema.go`
|
||||
- `apps/backend/internal/agentctl/server/api/server.go`
|
||||
- `apps/backend/internal/agentctl/server/api/mcp_plugin_tools_test.go`
|
||||
- `apps/backend/internal/agent/runtime/agentctl/client.go`
|
||||
- `apps/backend/internal/agent/runtime/agentctl/client_mcp_plugin_tools_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Parallel-safe with Task 02 after Task 01. This task owns MCP server and agentctl
|
||||
transport files; Task 02 owns plugin service/catalog files.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections `What` and `Runtime Catalog`
|
||||
- ADR decisions 2, 3, 5, and 6
|
||||
- Existing `SetProfile`, `SetProviders`, isolated `assembleTools`, shared schema
|
||||
compiler from Task 01, and initialized-session notification tests
|
||||
|
||||
## Risks
|
||||
|
||||
- Registry replacement must preserve built-in surface/capability/provider tools.
|
||||
- Some MCP clients ignore list-changed; server correctness must not depend on a
|
||||
client refresh.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report RED notification/stale-revision evidence, registry composition rules,
|
||||
wire payload, commands and results, and task/plan status updates.
|
||||
|
||||
## Results
|
||||
|
||||
- Added atomic `SetPluginTools` replacement, exact raw-schema registration,
|
||||
MCP annotations, proxy handlers, and lazy catalog synchronization before
|
||||
`tools/list` so changes apply without restarting agentctl.
|
||||
- Replacements use mcp-go `SetTools`, which emits one `tools/list_changed`
|
||||
notification for initialized clients. Same-generation stale revisions are
|
||||
ignored.
|
||||
- Verification: `go test -race ./internal/mcp/profile ./internal/mcp/server
|
||||
./internal/agentctl/server/api ./internal/agent/runtime/agentctl` passed.
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
id: "04-runtime-propagation-backend-dispatch"
|
||||
title: "Runtime propagation and backend dispatch"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on: ["02-plugin-catalog-invocation", "03-agentctl-dynamic-registry"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/plugins/agent-tools.md"
|
||||
adr: "../../decisions/2026-08-11-plugin-tools-through-kandev-mcp.md"
|
||||
---
|
||||
|
||||
# Task 04: Runtime propagation and backend dispatch
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Launch and resume profiles include the current catalog, while Configuration
|
||||
and External surfaces expose no plugin tools.
|
||||
- A scoped backend MCP action verifies the bound task/session pair and current
|
||||
plugin declaration before one plugin RPC, and maps valid text/structured/error
|
||||
results back to agentctl.
|
||||
- A serialized backendapp refresher pushes each effective snapshot to every
|
||||
active execution, continues after individual failures, and leaves later
|
||||
launch/resume or refresh able to repair stale runtime state.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test -race ./internal/mcp/handlers ./internal/mcp/scope ./internal/orchestrator/executor ./internal/agent/runtime/lifecycle ./internal/backendapp
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/pkg/websocket/actions.go`
|
||||
- `apps/backend/internal/mcp/handlers/handlers.go`
|
||||
- `apps/backend/internal/mcp/handlers/plugin_tools.go`
|
||||
- `apps/backend/internal/mcp/handlers/plugin_tools_test.go`
|
||||
- `apps/backend/internal/orchestrator/executor/executor.go`
|
||||
- `apps/backend/internal/orchestrator/executor/executor_execute.go`
|
||||
- `apps/backend/internal/orchestrator/executor/executor_mcp_mode_test.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/manager_launch.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/manager_mcp_plugin_tools_test.go`
|
||||
- `apps/backend/internal/backendapp/plugin_tool_refresher.go`
|
||||
- `apps/backend/internal/backendapp/plugin_tool_refresher_test.go`
|
||||
- `apps/backend/internal/backendapp/main.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Tasks 02 and 03.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. This task joins shared plugin, MCP, orchestrator, lifecycle, and
|
||||
backendapp contracts.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec `Permissions`, `Failure Modes`, and hot-refresh scenarios
|
||||
- Root/backend auth scoping guidance for in-session MCP
|
||||
- Existing provider live refresher, `resolveTaskSessionMCPProfile`, lifecycle
|
||||
`SetMcpProvidersForSession`, and MCP dispatcher patterns
|
||||
|
||||
## Risks
|
||||
|
||||
- A handler that trusts agent-supplied task/session IDs creates a cross-user
|
||||
authorization defect. Use only server-bound identity and authorize first.
|
||||
- Out-of-order or blocking catalog propagation can restore stale tools or stall
|
||||
plugin supervision.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the exact identity/authorization chain, refresh serialization and failure
|
||||
behavior, launch/resume wiring, commands and results, and task/plan status
|
||||
updates.
|
||||
|
||||
## Results
|
||||
|
||||
- Added `mcp.list_plugin_tools` and `mcp.invoke_plugin_tool` actions with task /
|
||||
session binding checks, surface filtering, and service dispatch.
|
||||
- Wired the plugin service into backend MCP handler registration and preserved
|
||||
existing identity scoping.
|
||||
- Added the agentctl plugin-tool control endpoint, lifecycle fan-out, and a
|
||||
coalescing backendapp refresher wired to plugin lifecycle notifications.
|
||||
- Agentctl retains the complete revisioned catalog and applies surface filters
|
||||
while rebuilding the active MCP profile, so live surface changes do not lose
|
||||
declarations.
|
||||
- Verification: `go test -race ./internal/mcp/handlers ./internal/mcp/scope
|
||||
./internal/orchestrator/executor ./internal/agent/runtime/lifecycle
|
||||
./internal/backendapp` passed.
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
id: "05-end-to-end-fixture"
|
||||
title: "Managed fixture end-to-end proof"
|
||||
status: in_progress
|
||||
wave: 4
|
||||
depends_on: ["04-runtime-propagation-backend-dispatch"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/plugins/agent-tools.md"
|
||||
adr: "../../decisions/2026-08-11-plugin-tools-through-kandev-mcp.md"
|
||||
---
|
||||
|
||||
# Task 05: Managed fixture end-to-end proof
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The managed fixture declares and implements a tool that returns its arguments
|
||||
and immutable Kandev-bound context, plus controlled error/timeout behavior.
|
||||
- A real subprocess plus MCP JSON-RPC integration test proves discovery,
|
||||
invocation, schema rejection before RPC, structured result mapping, and
|
||||
cancellation/no-retry behavior.
|
||||
- Live removal/recovery tests prove one list-changed notification per effective
|
||||
change, stale snapshot rejection, fail-closed calls after removal, and clean
|
||||
subprocess/server teardown.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test -race -run 'Test.*PluginAgentTool' ./internal/plugins/runtime ./internal/mcp/server ./internal/mcp/handlers ./internal/backendapp
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/plugins/runtime/testdata/fixtureplugin/main.go`
|
||||
- `apps/backend/internal/plugins/runtime/manager_test.go`
|
||||
- `apps/backend/internal/mcp/server/plugin_tools_integration_test.go`
|
||||
- `apps/backend/internal/backendapp/plugin_agent_tools_integration_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 04.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. It exercises the complete implementation and may adjust only test
|
||||
seams, not redefine public contracts.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Every spec scenario
|
||||
- Existing managed runtime fixture, MCP external integration test, initialized
|
||||
MCP session, and plugin runtime teardown patterns
|
||||
|
||||
## Risks
|
||||
|
||||
- Subprocess tests can leak or flake if they use sleeps. Use bounded contexts,
|
||||
readiness signals, explicit cleanup, and deterministic invocation counters.
|
||||
- The integration must prove no retry rather than only asserting one successful
|
||||
response.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report full-path evidence, subprocess artifacts and cleanup, invocation counts,
|
||||
notification counts, exact commands/results, and task/plan status updates.
|
||||
|
||||
## Results
|
||||
|
||||
- Extended `cmd/plugin-fixture` with the manifest-declared `test_echo` tool,
|
||||
which returns arguments and bound task/surface context over the SDK RPC.
|
||||
- Built `.build/kandev-plugin-e2e-1.0.0.tar.gz`, sideloaded it into a running
|
||||
backend, and verified the installed record is active with `test_echo`.
|
||||
- Verification: `go test ./cmd/plugin-fixture` and `make e2e-plugin-package`
|
||||
passed. The SDK gRPC round-trip test also passes in `pkg/pluginsdk`.
|
||||
- Full MCP JSON-RPC subprocess integration and lifecycle hot-removal coverage
|
||||
remain future work.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
id: "06-authoring-docs"
|
||||
title: "Plugin agent-tool documentation"
|
||||
status: done
|
||||
wave: 5
|
||||
depends_on: ["05-end-to-end-fixture"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/plugins/agent-tools.md"
|
||||
adr: "../../decisions/2026-08-11-plugin-tools-through-kandev-mcp.md"
|
||||
---
|
||||
|
||||
# Task 06: Plugin agent-tool documentation
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Public manifest and authoring guides document declarations, SDK method,
|
||||
derived names, surfaces, defaults, limits, bound context, capabilities,
|
||||
timeout/no-retry behavior, and a complete minimal example.
|
||||
- Automation/MCP and feature-status docs explain live discovery, client
|
||||
list-changed compatibility, reconnect fallback, and that plugins use Kandev's
|
||||
MCP server rather than a separate profile server.
|
||||
- The core plugin spec no longer presents all plugin agent tools as unsupported;
|
||||
links and terminology agree across public docs, spec, ADR, and implementation.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
git diff --check && make -C apps/backend build
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `docs/public/plugins-manifest.md`
|
||||
- `docs/public/plugins-authoring.md`
|
||||
- `docs/public/automation-and-mcp.md`
|
||||
- `docs/public/feature-status.md`
|
||||
- `docs/specs/plugins/spec.md`
|
||||
- `docs/specs/plugins/agent-tools.md`
|
||||
- `apps/backend/AGENTS.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 05.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. Documentation must reflect the verified final contract.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Completed Tasks 01-05 and their recorded verification results
|
||||
- `/docs-maintainer` guidance
|
||||
- Canonical plugin authoring guide and manifest reference
|
||||
|
||||
## Risks
|
||||
|
||||
- Examples that omit conservative annotations, cancellation, or Host capability
|
||||
requirements could encourage unsafe plugins.
|
||||
- Do not describe hot reload as universal client behavior; distinguish server
|
||||
replacement from client refresh support and plugin binary upgrades.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report updated public contracts and examples, links checked, commands/results,
|
||||
remaining client-compatibility caveats, and task/plan status updates.
|
||||
|
||||
## Results
|
||||
|
||||
- Documented the agent-tool manifest fields, SDK handler, derived MCP names,
|
||||
supported task surfaces, conservative annotations, bound context, limits,
|
||||
timeout/no-retry behavior, and a complete echo-tool example.
|
||||
- Updated automation/MCP and feature-status documentation with live catalog
|
||||
replacement, `tools/list_changed` client compatibility, and reconnect
|
||||
fallback behavior.
|
||||
- Updated the core plugin spec, feature spec, ADR index, and backend authoring
|
||||
guidance so terminology and contract links agree.
|
||||
- Verification: `git diff --check && make -C apps/backend build` passed. The
|
||||
cross-platform build emitted only the expected unsigned Darwin binary
|
||||
warnings in this Linux environment.
|
||||
@@ -100,6 +100,52 @@ go-plugin/gRPC handshake and Host injection. The backend implements
|
||||
pluginsdk.Plugin (OnEvent and/or HandleWebhook) and embeds
|
||||
pluginsdk.UnimplementedPlugin for no-op defaults.
|
||||
|
||||
### Contribute an agent tool
|
||||
|
||||
A managed plugin can contribute task-aware MCP tools through the same supervised
|
||||
gRPC process. Add an `agent_tools` declaration to `manifest.yaml` and implement
|
||||
the optional `pluginsdk.AgentToolPlugin` interface:
|
||||
|
||||
```yaml
|
||||
agent_tools:
|
||||
- name: add_tag
|
||||
description: Add an existing tag to the current task.
|
||||
surfaces: [kanban-task]
|
||||
input_schema:
|
||||
type: object
|
||||
properties:
|
||||
tag_id: { type: string }
|
||||
required: [tag_id]
|
||||
additionalProperties: false
|
||||
annotations:
|
||||
read_only_hint: false
|
||||
destructive_hint: false
|
||||
idempotent_hint: true
|
||||
```
|
||||
|
||||
The host exposes the readable canonical name
|
||||
`kandev_<plugin-id-slug>_<local-name>` (for example,
|
||||
`kandev_task_tags_v1_add_tag`). Punctuation in the stable plugin ID becomes an
|
||||
underscore. Very long names receive a short stable hash suffix after the slug
|
||||
is truncated. Tool names are host-owned; the plugin-local `name` is used for
|
||||
the gRPC dispatch.
|
||||
|
||||
Tools may target `kanban-task`, `office-task`, or both. They are not exposed to
|
||||
configuration or external MCP clients. Kandev validates the input and optional
|
||||
output JSON Schemas, supplies task/session/workspace/surface context, enforces
|
||||
a 30-second deadline and 1 MiB result limit, and does not retry calls.
|
||||
|
||||
The optional SDK method is:
|
||||
|
||||
```go
|
||||
InvokeAgentTool(context.Context, *pluginsdk.AgentToolRequest) (*pluginsdk.AgentToolResult, error)
|
||||
```
|
||||
|
||||
The request includes immutable invocation, task, session, workspace, and
|
||||
surface context. Return required fallback text, optional structured content,
|
||||
and `IsError` when the operation failed. Declaring a tool does not grant Host
|
||||
API capabilities; use the existing `capabilities` fields for those permissions.
|
||||
|
||||
## Security and capabilities
|
||||
|
||||
Plugins are privileged installed code. The capability list gates Host RPCs; it
|
||||
|
||||
@@ -7,7 +7,7 @@ status: experimental
|
||||
# Plugin Manifest Reference
|
||||
|
||||
`manifest.yaml` is the authoritative description of a plugin; identity,
|
||||
runtime executables, capabilities, webhooks, config schema, and
|
||||
runtime executables, capabilities, webhooks, agent tools, config schema, and
|
||||
optional UI bundle. Kandev parses and validates it **before any plugin code
|
||||
runs**. See [Authoring a plugin](plugins-authoring.md) for the build
|
||||
workflow and [Plugins](plugins.md) for install/operate.
|
||||
@@ -88,6 +88,27 @@ config_schema:
|
||||
}
|
||||
required: ["bot_token", "default_channel", "utility_agent"]
|
||||
|
||||
agent_tools:
|
||||
- name: add_tag
|
||||
description: Add an existing tag to the current task.
|
||||
surfaces: [kanban-task]
|
||||
input_schema:
|
||||
type: object
|
||||
properties:
|
||||
tag_id: { type: string }
|
||||
required: [tag_id]
|
||||
additionalProperties: false
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
task_id: { type: string }
|
||||
required: [task_id]
|
||||
additionalProperties: false
|
||||
annotations:
|
||||
read_only_hint: false
|
||||
destructive_hint: false
|
||||
idempotent_hint: true
|
||||
|
||||
ui: # optional native frontend plugin
|
||||
bundle: "/ui/bundle.js" # root-relative
|
||||
styles: ["/ui/plugin.css"] # optional, root-relative
|
||||
@@ -133,6 +154,12 @@ ui: # optional native frontend plugin
|
||||
| `webhooks[].max_body_bytes` | no | int | Request-body limit for this key. Defaults to 4 MiB. Public webhooks cannot exceed 4 MiB; authenticated webhooks can request up to 16 MiB. Plugins using this field should declare the first supporting `min_kandev_version`. |
|
||||
| `auth_providers[]` | no | object[] | Login buttons this plugin contributes to the pre-auth login screen (needs `capabilities.auth`). Each is `{ id, display_name, initiate }`, where `initiate` names one of the plugin's `webhooks[].key` values: the button navigates to that webhook, which 302-redirects to the IdP. Surfaced anonymously in the boot payload as `auth.ssoProviders`. |
|
||||
| `config_schema` | no | object | JSON-Schema-like object driving the settings form at **Settings > Plugins > `<plugin>`** (`GET /api/plugins/{id}/config` and `PATCH /api/plugins/{id}`). See "Config schema validation and secret fields" below. |
|
||||
| `agent_tools` | no | object[] | Task-aware MCP tools implemented by the managed plugin's optional `AgentToolPlugin` SDK interface. Each declaration has a local `name`, `description`, `surfaces`, required `input_schema`, optional `output_schema`, and optional MCP annotation hints. At most 16 tools are allowed. |
|
||||
| `agent_tools[].name` | yes* | string | Plugin-local name matching `^[a-z0-9][a-z0-9_]{0,31}$`. Kandev derives the global MCP name; authors cannot choose it. |
|
||||
| `agent_tools[].surfaces` | yes* | string[] | One or both of `kanban-task` and `office-task`. Plugin tools are not exposed to `configuration` or `external` MCP surfaces. |
|
||||
| `agent_tools[].input_schema` | yes* | object | Compiled object-root JSON Schema, at most 64 KiB serialized. Kandev rejects unknown top-level arguments before invoking the plugin. |
|
||||
| `agent_tools[].output_schema` | no | object | Optional compiled object-root JSON Schema applied to structured plugin results. |
|
||||
| `agent_tools[].annotations` | no | object | MCP hints: `read_only_hint`, `destructive_hint`, `idempotent_hint`, and `open_world_hint`. Omitted values use conservative host defaults. |
|
||||
| `ui.bundle` | no | string | Root-relative path (must start with `/`, e.g. `/ui/bundle.js`) to the plugin's native UI ES module, served at `GET /api/plugins/{id}/bundle`. |
|
||||
| `ui.styles` | no | string[] | Root-relative CSS paths (each must start with `/`), served at `GET /api/plugins/{id}/ui/*` and injected as `<link>` tags on load. |
|
||||
| `ui.pages` | no | object[] | Optional declarative metadata accepted by the manifest model. The current frontend does not render these entries; a native bundle registers its supported routes/nav/slots at runtime, so most plugins omit `ui.pages`. |
|
||||
|
||||
@@ -46,6 +46,7 @@ Product-wide capabilities that are not tied to a single feature area.
|
||||
| [plugins](plugins/spec.md) | draft |
|
||||
| [plugins — authoring experience](plugins/authoring-experience.md) | draft |
|
||||
| [plugins — marketplace](plugins/marketplace.md) | building |
|
||||
| [plugins — agent tools](plugins/agent-tools.md) | draft |
|
||||
| [plugins — Voice extraction host prerequisites](plugins/voice-extraction-host.md) | draft |
|
||||
| [semantic-notifications](platform/notifications.md) | shipped |
|
||||
| [workspace-git-status](platform/workspace-git-status.md) | shipped |
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
---
|
||||
status: draft
|
||||
created: 2026-08-11
|
||||
owner: Kandev
|
||||
---
|
||||
|
||||
# Plugin-Contributed Agent Tools
|
||||
|
||||
## Why
|
||||
|
||||
Plugin authors can extend Kandev's UI and data behavior, but task agents cannot
|
||||
invoke plugin-owned operations. Authors currently have to ship and configure a
|
||||
separate MCP server, which duplicates plugin installation, lifecycle, trust,
|
||||
and task-context handling. Kandev needs one supported way for an installed
|
||||
plugin to contribute task-aware tools to agents.
|
||||
|
||||
Decision: [ADR-2026-08-11-plugin-tools-through-kandev-mcp](../../decisions/2026-08-11-plugin-tools-through-kandev-mcp.md).
|
||||
|
||||
Implementation plan: [Plugin-Contributed Agent Tools](../../plans/plugin-agent-tools/plan.md).
|
||||
|
||||
## What
|
||||
|
||||
- A managed plugin package (`runtime.type: binary`) may declare zero or more
|
||||
agent tools in its manifest. Each declaration has a plugin-local name,
|
||||
description, input JSON Schema, optional output JSON Schema, supported task
|
||||
surfaces, and optional MCP annotations. Legacy remote HTTP plugins cannot
|
||||
declare agent tools because they do not implement the managed gRPC runtime.
|
||||
- Only active plugins contribute tools. Registered, disabled, errored, and
|
||||
uninstalled plugins contribute none.
|
||||
- Plugin tools are exposed through the existing task-aware Kandev MCP server.
|
||||
Plugins do not run or register a second MCP server.
|
||||
- Kandev derives a readable provider-safe MCP name as
|
||||
`kandev_<plugin-id-slug>_<local-name>`, where the plugin ID slug replaces
|
||||
punctuation with underscores. The manifest does not choose an arbitrary
|
||||
global MCP name. If the readable name exceeds the provider limit, Kandev
|
||||
truncates the slug and appends a short stable hash suffix. Install
|
||||
validation still rejects a final-name collision.
|
||||
- A declared tool may target `kanban-task`, `office-task`, or both. Plugin tools
|
||||
are not exposed to `configuration` or `external` MCP surfaces in this version.
|
||||
- Installing, upgrading, enabling, disabling, degrading, recovering, or
|
||||
uninstalling a plugin recomputes the authoritative plugin-tool catalog.
|
||||
- Running MCP servers replace their effective tool registry atomically. A
|
||||
changed registry emits one `notifications/tools/list_changed` notification;
|
||||
the agent process, task session, and MCP server do not restart.
|
||||
- A client that does not honor `tools/list_changed` sees the new catalog after
|
||||
its next MCP reconnect or task-session restart. Kandev does not terminate a
|
||||
healthy agent process solely to force discovery refresh.
|
||||
- Tool invocation is a single request to the already supervised plugin process.
|
||||
Kandev supplies immutable invocation ID, task ID, session ID, workspace ID,
|
||||
and MCP surface context derived from the running session. Those values are not
|
||||
accepted from tool arguments.
|
||||
- The plugin returns required fallback text, optional structured JSON content,
|
||||
and an `is_error` value. Structured content must conform to the declared
|
||||
output schema when one is present.
|
||||
- Tool calls have a 30-second deadline, propagate cancellation, are never
|
||||
automatically retried, and return at most 1 MiB across fallback text and
|
||||
structured content.
|
||||
- Manifest validation limits each plugin to 16 tools, local names to 32
|
||||
characters, descriptions to 1,024 bytes, and each serialized schema to
|
||||
64 KiB. Input and output schemas must compile and have an object root.
|
||||
- MCP annotations are hints, not authorization. Omitted annotations use
|
||||
conservative defaults: `readOnlyHint=false`, `destructiveHint=true`,
|
||||
`idempotentHint=false`, and `openWorldHint=true`.
|
||||
- Declaring an agent tool grants only discovery and invocation of that RPC. It
|
||||
grants no additional Host API capability; existing `api_read`, `api_write`,
|
||||
`state`, `secrets`, `agent_invoke`, and `auth` gates remain authoritative.
|
||||
- Discovery is not an authorization boundary. Every invocation revalidates the
|
||||
active plugin record, exact declared tool, allowed surface, backend-bound
|
||||
task/session pair, caller scope, and input schema before calling the plugin.
|
||||
- Plugin tool calls are recorded in structured logs with invocation ID, plugin
|
||||
ID, local tool name, task ID, session ID, duration, and outcome. Arguments,
|
||||
structured output, secret values, and plugin configuration are not logged.
|
||||
|
||||
## Manifest Contract
|
||||
|
||||
```yaml
|
||||
agent_tools:
|
||||
- name: add_tag
|
||||
description: Add an existing tag to the current task.
|
||||
surfaces: [kanban-task]
|
||||
input_schema:
|
||||
type: object
|
||||
properties:
|
||||
tag_id:
|
||||
type: string
|
||||
required: [tag_id]
|
||||
additionalProperties: false
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
task_id:
|
||||
type: string
|
||||
tag_id:
|
||||
type: string
|
||||
required: [task_id, tag_id]
|
||||
additionalProperties: false
|
||||
annotations:
|
||||
read_only_hint: false
|
||||
destructive_hint: false
|
||||
idempotent_hint: true
|
||||
open_world_hint: false
|
||||
```
|
||||
|
||||
`name` must match `^[a-z0-9][a-z0-9_]{0,31}$`. `surfaces` must contain one
|
||||
or both supported task surfaces without duplicates. `input_schema` is required;
|
||||
`output_schema` and `annotations` are optional. Unknown fields continue to
|
||||
follow the manifest's existing compatibility rules.
|
||||
|
||||
An install or upgrade fails before changing the active installation when its
|
||||
declarations are invalid, when two declarations produce the same exposed name,
|
||||
or when a declaration conflicts with a tool name reserved by another installed
|
||||
plugin version. A failed package change leaves the previous installed version
|
||||
and its tools unchanged.
|
||||
|
||||
## Plugin SDK Contract
|
||||
|
||||
Plugins that contribute declared tools implement the optional `AgentToolPlugin`
|
||||
interface:
|
||||
|
||||
```go
|
||||
type AgentToolPlugin interface {
|
||||
InvokeAgentTool(context.Context, *AgentToolRequest) (*AgentToolResult, error)
|
||||
}
|
||||
```
|
||||
|
||||
The author-facing types are:
|
||||
|
||||
```go
|
||||
type AgentToolRequest struct {
|
||||
InvocationID string
|
||||
Name string
|
||||
Arguments map[string]any
|
||||
Context AgentToolContext
|
||||
}
|
||||
|
||||
type AgentToolContext struct {
|
||||
TaskID string
|
||||
SessionID string
|
||||
WorkspaceID string
|
||||
Surface string
|
||||
}
|
||||
|
||||
type AgentToolResult struct {
|
||||
Text string
|
||||
StructuredContent map[string]any
|
||||
IsError bool
|
||||
}
|
||||
```
|
||||
|
||||
The base `Plugin` interface remains unchanged. The SDK's gRPC adapter checks
|
||||
whether the implementation satisfies `AgentToolPlugin` and returns gRPC
|
||||
`Unimplemented` otherwise. `UnimplementedPlugin.InvokeAgentTool` also returns
|
||||
`Unimplemented`, so both embedding and non-embedding existing implementations
|
||||
remain source-compatible. Kandev calls the method only for a declaration present
|
||||
in the active manifest.
|
||||
|
||||
## Runtime Catalog
|
||||
|
||||
The runtime catalog is a sorted snapshot containing a process-generation ID,
|
||||
monotonic revision, and the complete set of active plugin tool descriptors.
|
||||
The generation ID changes on backend startup. A running agentctl accepts a new
|
||||
generation and, within one generation, ignores snapshots older than its applied
|
||||
revision. This prevents delayed refresh requests from restoring stale tools.
|
||||
|
||||
Launch and resume carry the complete current snapshot. Live plugin lifecycle
|
||||
changes push a replacement snapshot to every active execution. Agentctl filters
|
||||
the snapshot by its backend-owned MCP surface before constructing the effective
|
||||
registry.
|
||||
|
||||
Catalog snapshots are runtime state. The declarations and plugin lifecycle
|
||||
status remain persisted in the installed plugin record; no new database entity
|
||||
is introduced.
|
||||
|
||||
## Permissions
|
||||
|
||||
- Installing, upgrading, enabling, disabling, or uninstalling a plugin retains
|
||||
the existing operator/admin permission boundary.
|
||||
- Any task agent whose backend-owned surface matches an active declaration may
|
||||
discover the tool. There is no per-workspace or per-agent-profile enablement
|
||||
setting in this version.
|
||||
- Agentctl injects its bound task and session IDs into the backend request. An
|
||||
agent cannot select another task or session through arguments.
|
||||
- The backend applies the same in-session MCP identity scoping used by built-in
|
||||
task tools and verifies that the bound session belongs to the bound task.
|
||||
- Plugins remain privileged installed code. The tool declaration does not
|
||||
sandbox the plugin process or reduce capabilities already granted by its
|
||||
manifest.
|
||||
|
||||
## Failure Modes
|
||||
|
||||
- Invalid tool declarations reject install or upgrade without replacing the
|
||||
previous installed plugin.
|
||||
- A catalog refresh failure is logged per execution and does not roll back the
|
||||
plugin lifecycle change. Calls are still rejected against current backend
|
||||
state even if a client temporarily retains stale discovery. Launch, resume,
|
||||
or the next catalog change repairs the runtime catalog.
|
||||
- A call discovered immediately before disable, uninstall, or degradation
|
||||
fails closed when backend revalidation observes the new state.
|
||||
- Disabling, uninstalling, or replacing the plugin process cancels calls still
|
||||
executing in that process. Registry replacement alone does not cancel calls
|
||||
to unrelated plugins.
|
||||
- An unavailable or crashed plugin returns an MCP tool error. Kandev does not
|
||||
retry because the operation may have produced side effects before failure.
|
||||
- Invalid arguments are rejected before plugin invocation. Backend validation
|
||||
repeats agentctl's shared MCP schema validation as defense in depth.
|
||||
- A plugin result that is missing fallback text, exceeds the result limit, or
|
||||
violates its declared output schema becomes an MCP tool error.
|
||||
- Plugin panics or transport failures follow the existing supervised plugin
|
||||
error/recovery lifecycle; recovery republishes the tool catalog.
|
||||
|
||||
## Persistence Guarantees
|
||||
|
||||
- Tool declarations survive restart as part of the installed manifest record.
|
||||
- Plugin active/disabled/error status follows the existing plugin lifecycle
|
||||
persistence contract.
|
||||
- Runtime catalog generation, revision, and per-agentctl applied snapshots do
|
||||
not survive restart. They are rebuilt from installed plugin records.
|
||||
- In-flight calls are not resumed after backend, agentctl, agent, or plugin
|
||||
process restart.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** an active plugin declaring a valid Kanban tool, **WHEN** a Kanban
|
||||
task agent initializes MCP, **THEN** `tools/list` includes the derived tool
|
||||
name, description, schemas, and conservative or declared annotations.
|
||||
- **GIVEN** the same plugin tool targets only `kanban-task`, **WHEN** an Office,
|
||||
Configuration, or External MCP client lists tools, **THEN** that tool is not
|
||||
present.
|
||||
- **GIVEN** a running compatible task agent, **WHEN** an operator enables a
|
||||
plugin with a matching tool, **THEN** the MCP connection receives one
|
||||
`tools/list_changed` notification and the next `tools/list` includes it
|
||||
without restarting the agent process.
|
||||
- **GIVEN** a running task agent has discovered a plugin tool, **WHEN** the
|
||||
plugin is disabled before invocation, **THEN** the call fails without
|
||||
entering the plugin and the refreshed list excludes the tool.
|
||||
- **GIVEN** a valid call, **WHEN** the plugin executes it, **THEN** the plugin
|
||||
receives the exact arguments plus Kandev-bound invocation, task, session,
|
||||
workspace, and surface context and the agent receives fallback text and any
|
||||
valid structured content.
|
||||
- **GIVEN** an agent supplies another task or session ID as ordinary tool
|
||||
arguments, **WHEN** it invokes the tool, **THEN** those values do not replace
|
||||
the context bound by Kandev.
|
||||
- **GIVEN** invalid arguments, **WHEN** the agent invokes a plugin tool, **THEN**
|
||||
Kandev returns a schema-validation error and the plugin receives no RPC.
|
||||
- **GIVEN** a plugin call times out or its process crashes, **WHEN** the request
|
||||
completes, **THEN** the agent receives one MCP error and Kandev does not retry
|
||||
the call.
|
||||
- **GIVEN** two catalog snapshots arrive out of order, **WHEN** agentctl has
|
||||
already applied the newer revision from the same generation, **THEN** it
|
||||
ignores the older snapshot and does not restore removed tools.
|
||||
- **GIVEN** a client ignores `tools/list_changed`, **WHEN** the plugin catalog
|
||||
changes, **THEN** Kandev keeps the agent running and backend invocation still
|
||||
enforces the current catalog; reconnecting exposes the current list.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- A plugin-hosted MCP server or transparent federation of arbitrary MCP
|
||||
resources, prompts, roots, sampling, or elicitation.
|
||||
- Plugin tools on Configuration or External MCP surfaces.
|
||||
- Per-workspace, per-workflow, per-agent-profile, or per-tool enablement UI.
|
||||
- User approval workflows beyond annotations interpreted by the connected MCP
|
||||
client.
|
||||
- Streaming tool results, progress notifications, binary/image/audio content,
|
||||
embedded resources, or resumable calls.
|
||||
- Automatic retries or exactly-once execution guarantees.
|
||||
- Sandboxing installed plugin binaries or native UI bundles.
|
||||
@@ -964,12 +964,11 @@ complete.
|
||||
- **Mandatory package signing.** `checksums.txt.sig` verification is supported when
|
||||
present, but signing is optional in v1 — an unsigned package installs with a warning
|
||||
rather than being rejected. Requiring signatures is future work.
|
||||
- **Agent tools.** Plugins do not contribute tools to agents. An earlier
|
||||
`tools[]` manifest section with an `InvokeTool` RPC was built during the
|
||||
initial buildout but never wired into agent tool sets, and has been removed —
|
||||
it duplicated MCP, kandev's established mechanism for exposing tools to
|
||||
agents (`internal/mcp/`). If plugins ever contribute agent tools, they should
|
||||
feed through the MCP surface rather than a parallel invocation path.
|
||||
- **Agent tools in the base v1 runtime.** The original parallel `tools[]` plus
|
||||
`InvokeTool` path remains removed. Plugin-contributed agent tools are a
|
||||
separate extension specified in [Plugin-Contributed Agent Tools](agent-tools.md)
|
||||
and feed through Kandev's existing MCP surface rather than a second discovery
|
||||
or invocation protocol.
|
||||
- **Hot reload.** Upgrading a plugin requires a new install (new version directory);
|
||||
there is no in-place manifest or binary swap on a running process.
|
||||
- **Multi-instance plugins.** Each plugin ID maps to exactly one supervised subprocess.
|
||||
|
||||
Reference in New Issue
Block a user