feat: expose plugin agent tools through task MCP (#2552)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-12 20:25:13 +01:00
committed by GitHub
parent d616a1fcda
commit 4257307063
59 changed files with 4071 additions and 460 deletions
@@ -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}
+12
View File
@@ -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)
+1 -1
View File
@@ -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")
}
}
+4 -1
View File
@@ -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)
}
+222 -35
View File
@@ -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())),
+271 -2
View File
@@ -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),
+56 -9
View File
@@ -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.
+7
View File
@@ -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()
}
+14
View File
@@ -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) {
+35
View File
@@ -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.
+35
View File
@@ -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
+65
View File
@@ -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,
+2
View File
@@ -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.
+1
View File
@@ -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 |
+210
View File
@@ -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.
+46
View File
@@ -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
+28 -1
View File
@@ -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`. |
+1
View File
@@ -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 |
+266
View File
@@ -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.
+5 -6
View File
@@ -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.