diff --git a/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml b/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml index 03c96d062a..0828eb37a7 100644 --- a/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml +++ b/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml @@ -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" diff --git a/apps/backend/cmd/plugin-fixture/fixture_package_test.go b/apps/backend/cmd/plugin-fixture/fixture_package_test.go index 261e710571..466ddd9ac6 100644 --- a/apps/backend/cmd/plugin-fixture/fixture_package_test.go +++ b/apps/backend/cmd/plugin-fixture/fixture_package_test.go @@ -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) diff --git a/apps/backend/cmd/plugin-fixture/main_test.go b/apps/backend/cmd/plugin-fixture/main_test.go index 9ff4c3e338..3b63d3f3e4 100644 --- a/apps/backend/cmd/plugin-fixture/main_test.go +++ b/apps/backend/cmd/plugin-fixture/main_test.go @@ -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} diff --git a/apps/backend/cmd/plugin-fixture/plugin.go b/apps/backend/cmd/plugin-fixture/plugin.go index b57f423d2a..6bb03215e2 100644 --- a/apps/backend/cmd/plugin-fixture/plugin.go +++ b/apps/backend/cmd/plugin-fixture/plugin.go @@ -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. diff --git a/apps/backend/internal/agent/runtime/agentctl/client.go b/apps/backend/internal/agent/runtime/agentctl/client.go index f23bb05a26..a8984c51bf 100644 --- a/apps/backend/internal/agent/runtime/agentctl/client.go +++ b/apps/backend/internal/agent/runtime/agentctl/client.go @@ -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) diff --git a/apps/backend/internal/agent/runtime/agentctl/client_core_test.go b/apps/backend/internal/agent/runtime/agentctl/client_core_test.go index 5a6a385a4c..72e874bdaf 100644 --- a/apps/backend/internal/agent/runtime/agentctl/client_core_test.go +++ b/apps/backend/internal/agent/runtime/agentctl/client_core_test.go @@ -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"}`)) diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index 0bba16c639..9f4d5c7d23 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -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) { diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_mcp_providers_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_mcp_providers_test.go index 3a976c7e9a..46bf80378e 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_mcp_providers_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_mcp_providers_test.go @@ -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) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/mcp_identity.go b/apps/backend/internal/agent/runtime/lifecycle/mcp_identity.go index 1bc3e8b0f5..08ad6f95d6 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/mcp_identity.go +++ b/apps/backend/internal/agent/runtime/lifecycle/mcp_identity.go @@ -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, } } diff --git a/apps/backend/internal/agent/runtime/lifecycle/mcp_identity_test.go b/apps/backend/internal/agent/runtime/lifecycle/mcp_identity_test.go index 3bd4037f36..360861f3b0 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/mcp_identity_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/mcp_identity_test.go @@ -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) } } diff --git a/apps/backend/internal/agentctl/server/api/mcp_mode_test.go b/apps/backend/internal/agentctl/server/api/mcp_mode_test.go index fc45b19730..49699f6ed0 100644 --- a/apps/backend/internal/agentctl/server/api/mcp_mode_test.go +++ b/apps/backend/internal/agentctl/server/api/mcp_mode_test.go @@ -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) + } +} diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 0269073fe6..963fd4ef61 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -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"` diff --git a/apps/backend/internal/agentctl/types/streams/mcp_execution.go b/apps/backend/internal/agentctl/types/streams/mcp_execution.go new file mode 100644 index 0000000000..d05daae93b --- /dev/null +++ b/apps/backend/internal/agentctl/types/streams/mcp_execution.go @@ -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 != "" +} diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 905e03fc16..dc77f6c161 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -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) diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 90f478bdfd..0cb0439188 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -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, diff --git a/apps/backend/internal/backendapp/plugin_tool_refresher.go b/apps/backend/internal/backendapp/plugin_tool_refresher.go new file mode 100644 index 0000000000..8c064477c2 --- /dev/null +++ b/apps/backend/internal/backendapp/plugin_tool_refresher.go @@ -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)) + } + } + } +} diff --git a/apps/backend/internal/backendapp/plugin_tool_refresher_test.go b/apps/backend/internal/backendapp/plugin_tool_refresher_test.go new file mode 100644 index 0000000000..a9953fb7d7 --- /dev/null +++ b/apps/backend/internal/backendapp/plugin_tool_refresher_test.go @@ -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") + } +} diff --git a/apps/backend/internal/backendapp/plugins.go b/apps/backend/internal/backendapp/plugins.go index 80c2f49f0e..557f28016c 100644 --- a/apps/backend/internal/backendapp/plugins.go +++ b/apps/backend/internal/backendapp/plugins.go @@ -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) diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 2b479c327b..4970f7d56e 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -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) diff --git a/apps/backend/internal/mcp/handlers/plugin_tools.go b/apps/backend/internal/mcp/handlers/plugin_tools.go new file mode 100644 index 0000000000..8b747841c1 --- /dev/null +++ b/apps/backend/internal/mcp/handlers/plugin_tools.go @@ -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 +} diff --git a/apps/backend/internal/mcp/handlers/plugin_tools_test.go b/apps/backend/internal/mcp/handlers/plugin_tools_test.go new file mode 100644 index 0000000000..652f094f6c --- /dev/null +++ b/apps/backend/internal/mcp/handlers/plugin_tools_test.go @@ -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") +} diff --git a/apps/backend/internal/mcp/plugintools/types.go b/apps/backend/internal/mcp/plugintools/types.go new file mode 100644 index 0000000000..abc0f990c9 --- /dev/null +++ b/apps/backend/internal/mcp/plugintools/types.go @@ -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) +} diff --git a/apps/backend/internal/mcp/plugintools/types_test.go b/apps/backend/internal/mcp/plugintools/types_test.go new file mode 100644 index 0000000000..ac9546be8c --- /dev/null +++ b/apps/backend/internal/mcp/plugintools/types_test.go @@ -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) + } +} diff --git a/apps/backend/internal/mcp/server/backend_client.go b/apps/backend/internal/mcp/server/backend_client.go index 131bfd3f88..05c2c349e3 100644 --- a/apps/backend/internal/mcp/server/backend_client.go +++ b/apps/backend/internal/mcp/server/backend_client.go @@ -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 "" + } + 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() } diff --git a/apps/backend/internal/mcp/server/backend_client_test.go b/apps/backend/internal/mcp/server/backend_client_test.go new file mode 100644 index 0000000000..f2c27a1f33 --- /dev/null +++ b/apps/backend/internal/mcp/server/backend_client_test.go @@ -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) +} diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index a74bf4c34b..be556f7f99 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -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())), diff --git a/apps/backend/internal/mcp/server/server_test.go b/apps/backend/internal/mcp/server/server_test.go index a38600f0d0..ad4bbe7d38 100644 --- a/apps/backend/internal/mcp/server/server_test.go +++ b/apps/backend/internal/mcp/server/server_test.go @@ -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() diff --git a/apps/backend/internal/mcp/toolschema/schema.go b/apps/backend/internal/mcp/toolschema/schema.go new file mode 100644 index 0000000000..dcd4f9001f --- /dev/null +++ b/apps/backend/internal/mcp/toolschema/schema.go @@ -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) +} diff --git a/apps/backend/internal/mcp/toolschema/schema_test.go b/apps/backend/internal/mcp/toolschema/schema_test.go new file mode 100644 index 0000000000..dad6799e2c --- /dev/null +++ b/apps/backend/internal/mcp/toolschema/schema_test.go @@ -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) + } +} diff --git a/apps/backend/internal/plugins/agent_tools.go b/apps/backend/internal/plugins/agent_tools.go new file mode 100644 index 0000000000..d5cff0def4 --- /dev/null +++ b/apps/backend/internal/plugins/agent_tools.go @@ -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 +} diff --git a/apps/backend/internal/plugins/agent_tools_test.go b/apps/backend/internal/plugins/agent_tools_test.go new file mode 100644 index 0000000000..2616083354 --- /dev/null +++ b/apps/backend/internal/plugins/agent_tools_test.go @@ -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") +} diff --git a/apps/backend/internal/plugins/manifest/manifest.go b/apps/backend/internal/plugins/manifest/manifest.go index 5bf52d0350..23eb1b3ee5 100644 --- a/apps/backend/internal/plugins/manifest/manifest.go +++ b/apps/backend/internal/plugins/manifest/manifest.go @@ -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 diff --git a/apps/backend/internal/plugins/manifest/manifest_test.go b/apps/backend/internal/plugins/manifest/manifest_test.go index 51040dbd89..e2056410e7 100644 --- a/apps/backend/internal/plugins/manifest/manifest_test.go +++ b/apps/backend/internal/plugins/manifest/manifest_test.go @@ -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 { diff --git a/apps/backend/internal/plugins/manifest/validate.go b/apps/backend/internal/plugins/manifest/validate.go index 8ff8bbf5af..c55b16b767 100644 --- a/apps/backend/internal/plugins/manifest/validate.go +++ b/apps/backend/internal/plugins/manifest/validate.go @@ -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 ".yml" record filename collide with FSStore's // ".config.yml" operator-config naming convention (store.isRecordFile), diff --git a/apps/backend/internal/plugins/service.go b/apps/backend/internal/plugins/service.go index a8026cab2a..c04b57ae7f 100644 --- a/apps/backend/internal/plugins/service.go +++ b/apps/backend/internal/plugins/service.go @@ -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() } } } diff --git a/apps/backend/internal/plugins/service_test.go b/apps/backend/internal/plugins/service_test.go index 1134c8d7be..5689c6d8a1 100644 --- a/apps/backend/internal/plugins/service_test.go +++ b/apps/backend/internal/plugins/service_test.go @@ -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. diff --git a/apps/backend/internal/plugins/types.go b/apps/backend/internal/plugins/types.go index 6ac2c34927..d5c14e7729 100644 --- a/apps/backend/internal/plugins/types.go +++ b/apps/backend/internal/plugins/types.go @@ -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() +} diff --git a/apps/backend/pkg/pluginsdk/plugin.go b/apps/backend/pkg/pluginsdk/plugin.go index 40c25acd34..7c39dce378 100644 --- a/apps/backend/pkg/pluginsdk/plugin.go +++ b/apps/backend/pkg/pluginsdk/plugin.go @@ -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) { diff --git a/apps/backend/pkg/pluginsdk/serve.go b/apps/backend/pkg/pluginsdk/serve.go index d4d99406fa..76d969ff4c 100644 --- a/apps/backend/pkg/pluginsdk/serve.go +++ b/apps/backend/pkg/pluginsdk/serve.go @@ -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. diff --git a/apps/backend/pkg/pluginsdk/serve_test.go b/apps/backend/pkg/pluginsdk/serve_test.go index 6b201822ee..52b165c4e8 100644 --- a/apps/backend/pkg/pluginsdk/serve_test.go +++ b/apps/backend/pkg/pluginsdk/serve_test.go @@ -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 diff --git a/apps/backend/pkg/pluginsdk/types.go b/apps/backend/pkg/pluginsdk/types.go index 9847f57ebc..789074368d 100644 --- a/apps/backend/pkg/pluginsdk/types.go +++ b/apps/backend/pkg/pluginsdk/types.go @@ -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, diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 4022a34b08..d1e77d8679 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -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" diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go index 58b6b6f118..263d14f1a7 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go +++ b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go @@ -134,6 +134,202 @@ func (*EventAck) Descriptor() ([]byte, []int) { return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{1} } +type AgentToolRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InvocationId string `protobuf:"bytes,1,opt,name=invocation_id,json=invocationId,proto3" json:"invocation_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Arguments *structpb.Struct `protobuf:"bytes,3,opt,name=arguments,proto3" json:"arguments,omitempty"` + Context *AgentToolContext `protobuf:"bytes,4,opt,name=context,proto3" json:"context,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentToolRequest) Reset() { + *x = AgentToolRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentToolRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentToolRequest) ProtoMessage() {} + +func (x *AgentToolRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentToolRequest.ProtoReflect.Descriptor instead. +func (*AgentToolRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{2} +} + +func (x *AgentToolRequest) GetInvocationId() string { + if x != nil { + return x.InvocationId + } + return "" +} + +func (x *AgentToolRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AgentToolRequest) GetArguments() *structpb.Struct { + if x != nil { + return x.Arguments + } + return nil +} + +func (x *AgentToolRequest) GetContext() *AgentToolContext { + if x != nil { + return x.Context + } + return nil +} + +type AgentToolContext struct { + state protoimpl.MessageState `protogen:"open.v1"` + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + WorkspaceId string `protobuf:"bytes,3,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` + Surface string `protobuf:"bytes,4,opt,name=surface,proto3" json:"surface,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentToolContext) Reset() { + *x = AgentToolContext{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentToolContext) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentToolContext) ProtoMessage() {} + +func (x *AgentToolContext) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentToolContext.ProtoReflect.Descriptor instead. +func (*AgentToolContext) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{3} +} + +func (x *AgentToolContext) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *AgentToolContext) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *AgentToolContext) GetWorkspaceId() string { + if x != nil { + return x.WorkspaceId + } + return "" +} + +func (x *AgentToolContext) GetSurface() string { + if x != nil { + return x.Surface + } + return "" +} + +type AgentToolResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` + StructuredContent *structpb.Struct `protobuf:"bytes,2,opt,name=structured_content,json=structuredContent,proto3" json:"structured_content,omitempty"` + IsError bool `protobuf:"varint,3,opt,name=is_error,json=isError,proto3" json:"is_error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentToolResponse) Reset() { + *x = AgentToolResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentToolResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentToolResponse) ProtoMessage() {} + +func (x *AgentToolResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentToolResponse.ProtoReflect.Descriptor instead. +func (*AgentToolResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{4} +} + +func (x *AgentToolResponse) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +func (x *AgentToolResponse) GetStructuredContent() *structpb.Struct { + if x != nil { + return x.StructuredContent + } + return nil +} + +func (x *AgentToolResponse) GetIsError() bool { + if x != nil { + return x.IsError + } + return false +} + type WebhookRequest struct { state protoimpl.MessageState `protogen:"open.v1"` WebhookKey string `protobuf:"bytes,1,opt,name=webhook_key,json=webhookKey,proto3" json:"webhook_key,omitempty"` @@ -148,7 +344,7 @@ type WebhookRequest struct { func (x *WebhookRequest) Reset() { *x = WebhookRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[2] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -160,7 +356,7 @@ func (x *WebhookRequest) String() string { func (*WebhookRequest) ProtoMessage() {} func (x *WebhookRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[2] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -173,7 +369,7 @@ func (x *WebhookRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WebhookRequest.ProtoReflect.Descriptor instead. func (*WebhookRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{2} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{5} } func (x *WebhookRequest) GetWebhookKey() string { @@ -229,7 +425,7 @@ type WebhookResponse struct { func (x *WebhookResponse) Reset() { *x = WebhookResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[3] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -241,7 +437,7 @@ func (x *WebhookResponse) String() string { func (*WebhookResponse) ProtoMessage() {} func (x *WebhookResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[3] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -254,7 +450,7 @@ func (x *WebhookResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WebhookResponse.ProtoReflect.Descriptor instead. func (*WebhookResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{3} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{6} } func (x *WebhookResponse) GetStatus() int32 { @@ -289,7 +485,7 @@ type GetStateRequest struct { func (x *GetStateRequest) Reset() { *x = GetStateRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[4] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -301,7 +497,7 @@ func (x *GetStateRequest) String() string { func (*GetStateRequest) ProtoMessage() {} func (x *GetStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[4] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -314,7 +510,7 @@ func (x *GetStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetStateRequest.ProtoReflect.Descriptor instead. func (*GetStateRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{4} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{7} } func (x *GetStateRequest) GetScope() string { @@ -348,7 +544,7 @@ type GetStateResponse struct { func (x *GetStateResponse) Reset() { *x = GetStateResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[5] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -360,7 +556,7 @@ func (x *GetStateResponse) String() string { func (*GetStateResponse) ProtoMessage() {} func (x *GetStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[5] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -373,7 +569,7 @@ func (x *GetStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetStateResponse.ProtoReflect.Descriptor instead. func (*GetStateResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{5} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{8} } func (x *GetStateResponse) GetFound() bool { @@ -402,7 +598,7 @@ type SetStateRequest struct { func (x *SetStateRequest) Reset() { *x = SetStateRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[6] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -414,7 +610,7 @@ func (x *SetStateRequest) String() string { func (*SetStateRequest) ProtoMessage() {} func (x *SetStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[6] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -427,7 +623,7 @@ func (x *SetStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetStateRequest.ProtoReflect.Descriptor instead. func (*SetStateRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{6} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{9} } func (x *SetStateRequest) GetScope() string { @@ -466,7 +662,7 @@ type SetStateResponse struct { func (x *SetStateResponse) Reset() { *x = SetStateResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[7] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -478,7 +674,7 @@ func (x *SetStateResponse) String() string { func (*SetStateResponse) ProtoMessage() {} func (x *SetStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[7] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -491,7 +687,7 @@ func (x *SetStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetStateResponse.ProtoReflect.Descriptor instead. func (*SetStateResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{7} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{10} } type DeleteStateRequest struct { @@ -505,7 +701,7 @@ type DeleteStateRequest struct { func (x *DeleteStateRequest) Reset() { *x = DeleteStateRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[8] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -517,7 +713,7 @@ func (x *DeleteStateRequest) String() string { func (*DeleteStateRequest) ProtoMessage() {} func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[8] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -530,7 +726,7 @@ func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteStateRequest.ProtoReflect.Descriptor instead. func (*DeleteStateRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{8} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{11} } func (x *DeleteStateRequest) GetScope() string { @@ -562,7 +758,7 @@ type DeleteStateResponse struct { func (x *DeleteStateResponse) Reset() { *x = DeleteStateResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[9] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -574,7 +770,7 @@ func (x *DeleteStateResponse) String() string { func (*DeleteStateResponse) ProtoMessage() {} func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[9] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -587,7 +783,7 @@ func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteStateResponse.ProtoReflect.Descriptor instead. func (*DeleteStateResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{9} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{12} } type ListStateRequest struct { @@ -600,7 +796,7 @@ type ListStateRequest struct { func (x *ListStateRequest) Reset() { *x = ListStateRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[10] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -612,7 +808,7 @@ func (x *ListStateRequest) String() string { func (*ListStateRequest) ProtoMessage() {} func (x *ListStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[10] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -625,7 +821,7 @@ func (x *ListStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStateRequest.ProtoReflect.Descriptor instead. func (*ListStateRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{10} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{13} } func (x *ListStateRequest) GetScope() string { @@ -651,7 +847,7 @@ type ListStateResponse struct { func (x *ListStateResponse) Reset() { *x = ListStateResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[11] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -663,7 +859,7 @@ func (x *ListStateResponse) String() string { func (*ListStateResponse) ProtoMessage() {} func (x *ListStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[11] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -676,7 +872,7 @@ func (x *ListStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStateResponse.ProtoReflect.Descriptor instead. func (*ListStateResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{11} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{14} } func (x *ListStateResponse) GetEntries() []*StateEntry { @@ -697,7 +893,7 @@ type StateEntry struct { func (x *StateEntry) Reset() { *x = StateEntry{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[12] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -709,7 +905,7 @@ func (x *StateEntry) String() string { func (*StateEntry) ProtoMessage() {} func (x *StateEntry) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[12] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -722,7 +918,7 @@ func (x *StateEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use StateEntry.ProtoReflect.Descriptor instead. func (*StateEntry) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{12} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{15} } func (x *StateEntry) GetKey() string { @@ -754,7 +950,7 @@ type GetConfigRequest struct { func (x *GetConfigRequest) Reset() { *x = GetConfigRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[13] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -766,7 +962,7 @@ func (x *GetConfigRequest) String() string { func (*GetConfigRequest) ProtoMessage() {} func (x *GetConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[13] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -779,7 +975,7 @@ func (x *GetConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetConfigRequest.ProtoReflect.Descriptor instead. func (*GetConfigRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{13} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{16} } type GetConfigResponse struct { @@ -791,7 +987,7 @@ type GetConfigResponse struct { func (x *GetConfigResponse) Reset() { *x = GetConfigResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[14] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -803,7 +999,7 @@ func (x *GetConfigResponse) String() string { func (*GetConfigResponse) ProtoMessage() {} func (x *GetConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[14] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -816,7 +1012,7 @@ func (x *GetConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetConfigResponse.ProtoReflect.Descriptor instead. func (*GetConfigResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{14} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{17} } func (x *GetConfigResponse) GetConfig() *structpb.Struct { @@ -835,7 +1031,7 @@ type GetSecretRequest struct { func (x *GetSecretRequest) Reset() { *x = GetSecretRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[15] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -847,7 +1043,7 @@ func (x *GetSecretRequest) String() string { func (*GetSecretRequest) ProtoMessage() {} func (x *GetSecretRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[15] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -860,7 +1056,7 @@ func (x *GetSecretRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSecretRequest.ProtoReflect.Descriptor instead. func (*GetSecretRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{15} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{18} } func (x *GetSecretRequest) GetKey() string { @@ -880,7 +1076,7 @@ type GetSecretResponse struct { func (x *GetSecretResponse) Reset() { *x = GetSecretResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[16] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -892,7 +1088,7 @@ func (x *GetSecretResponse) String() string { func (*GetSecretResponse) ProtoMessage() {} func (x *GetSecretResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[16] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -905,7 +1101,7 @@ func (x *GetSecretResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSecretResponse.ProtoReflect.Descriptor instead. func (*GetSecretResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{16} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{19} } func (x *GetSecretResponse) GetFound() bool { @@ -932,7 +1128,7 @@ type SetSecretRequest struct { func (x *SetSecretRequest) Reset() { *x = SetSecretRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[17] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -944,7 +1140,7 @@ func (x *SetSecretRequest) String() string { func (*SetSecretRequest) ProtoMessage() {} func (x *SetSecretRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[17] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -957,7 +1153,7 @@ func (x *SetSecretRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetSecretRequest.ProtoReflect.Descriptor instead. func (*SetSecretRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{17} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{20} } func (x *SetSecretRequest) GetKey() string { @@ -982,7 +1178,7 @@ type SetSecretResponse struct { func (x *SetSecretResponse) Reset() { *x = SetSecretResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[18] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -994,7 +1190,7 @@ func (x *SetSecretResponse) String() string { func (*SetSecretResponse) ProtoMessage() {} func (x *SetSecretResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[18] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1007,7 +1203,7 @@ func (x *SetSecretResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetSecretResponse.ProtoReflect.Descriptor instead. func (*SetSecretResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{18} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{21} } type DeleteSecretRequest struct { @@ -1019,7 +1215,7 @@ type DeleteSecretRequest struct { func (x *DeleteSecretRequest) Reset() { *x = DeleteSecretRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[19] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1031,7 +1227,7 @@ func (x *DeleteSecretRequest) String() string { func (*DeleteSecretRequest) ProtoMessage() {} func (x *DeleteSecretRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[19] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1044,7 +1240,7 @@ func (x *DeleteSecretRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSecretRequest.ProtoReflect.Descriptor instead. func (*DeleteSecretRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{19} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{22} } func (x *DeleteSecretRequest) GetKey() string { @@ -1062,7 +1258,7 @@ type DeleteSecretResponse struct { func (x *DeleteSecretResponse) Reset() { *x = DeleteSecretResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[20] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1074,7 +1270,7 @@ func (x *DeleteSecretResponse) String() string { func (*DeleteSecretResponse) ProtoMessage() {} func (x *DeleteSecretResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[20] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1087,7 +1283,7 @@ func (x *DeleteSecretResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSecretResponse.ProtoReflect.Descriptor instead. func (*DeleteSecretResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{20} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{23} } type RevealSecretRequest struct { @@ -1099,7 +1295,7 @@ type RevealSecretRequest struct { func (x *RevealSecretRequest) Reset() { *x = RevealSecretRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[21] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1111,7 +1307,7 @@ func (x *RevealSecretRequest) String() string { func (*RevealSecretRequest) ProtoMessage() {} func (x *RevealSecretRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[21] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1124,7 +1320,7 @@ func (x *RevealSecretRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevealSecretRequest.ProtoReflect.Descriptor instead. func (*RevealSecretRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{21} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{24} } func (x *RevealSecretRequest) GetRef() string { @@ -1143,7 +1339,7 @@ type RevealSecretResponse struct { func (x *RevealSecretResponse) Reset() { *x = RevealSecretResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[22] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1155,7 +1351,7 @@ func (x *RevealSecretResponse) String() string { func (*RevealSecretResponse) ProtoMessage() {} func (x *RevealSecretResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[22] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1168,7 +1364,7 @@ func (x *RevealSecretResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevealSecretResponse.ProtoReflect.Descriptor instead. func (*RevealSecretResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{22} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{25} } func (x *RevealSecretResponse) GetValue() string { @@ -1188,7 +1384,7 @@ type EmitEventRequest struct { func (x *EmitEventRequest) Reset() { *x = EmitEventRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[23] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1200,7 +1396,7 @@ func (x *EmitEventRequest) String() string { func (*EmitEventRequest) ProtoMessage() {} func (x *EmitEventRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[23] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1213,7 +1409,7 @@ func (x *EmitEventRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EmitEventRequest.ProtoReflect.Descriptor instead. func (*EmitEventRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{23} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{26} } func (x *EmitEventRequest) GetEventName() string { @@ -1238,7 +1434,7 @@ type EmitEventResponse struct { func (x *EmitEventResponse) Reset() { *x = EmitEventResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[24] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1250,7 +1446,7 @@ func (x *EmitEventResponse) String() string { func (*EmitEventResponse) ProtoMessage() {} func (x *EmitEventResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[24] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1263,7 +1459,7 @@ func (x *EmitEventResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EmitEventResponse.ProtoReflect.Descriptor instead. func (*EmitEventResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{24} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{27} } // ── Host data API (ADR 0043) ───────────────────────────────────────────────── @@ -1279,7 +1475,7 @@ type Page struct { func (x *Page) Reset() { *x = Page{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[25] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1291,7 +1487,7 @@ func (x *Page) String() string { func (*Page) ProtoMessage() {} func (x *Page) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[25] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1304,7 +1500,7 @@ func (x *Page) ProtoReflect() protoreflect.Message { // Deprecated: Use Page.ProtoReflect.Descriptor instead. func (*Page) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{25} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{28} } func (x *Page) GetLimit() int32 { @@ -1331,7 +1527,7 @@ type PageInfo struct { func (x *PageInfo) Reset() { *x = PageInfo{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[26] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1343,7 +1539,7 @@ func (x *PageInfo) String() string { func (*PageInfo) ProtoMessage() {} func (x *PageInfo) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[26] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1356,7 +1552,7 @@ func (x *PageInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use PageInfo.ProtoReflect.Descriptor instead. func (*PageInfo) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{26} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{29} } func (x *PageInfo) GetNextCursor() string { @@ -1399,7 +1595,7 @@ type Task struct { func (x *Task) Reset() { *x = Task{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[27] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1411,7 +1607,7 @@ func (x *Task) String() string { func (*Task) ProtoMessage() {} func (x *Task) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[27] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1424,7 +1620,7 @@ func (x *Task) ProtoReflect() protoreflect.Message { // Deprecated: Use Task.ProtoReflect.Descriptor instead. func (*Task) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{27} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{30} } func (x *Task) GetId() string { @@ -1558,7 +1754,7 @@ type TaskRepository struct { func (x *TaskRepository) Reset() { *x = TaskRepository{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[28] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1570,7 +1766,7 @@ func (x *TaskRepository) String() string { func (*TaskRepository) ProtoMessage() {} func (x *TaskRepository) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[28] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1583,7 +1779,7 @@ func (x *TaskRepository) ProtoReflect() protoreflect.Message { // Deprecated: Use TaskRepository.ProtoReflect.Descriptor instead. func (*TaskRepository) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{28} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{31} } func (x *TaskRepository) GetId() string { @@ -1627,7 +1823,7 @@ type TaskFilter struct { func (x *TaskFilter) Reset() { *x = TaskFilter{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[29] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1639,7 +1835,7 @@ func (x *TaskFilter) String() string { func (*TaskFilter) ProtoMessage() {} func (x *TaskFilter) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[29] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1652,7 +1848,7 @@ func (x *TaskFilter) ProtoReflect() protoreflect.Message { // Deprecated: Use TaskFilter.ProtoReflect.Descriptor instead. func (*TaskFilter) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{29} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{32} } func (x *TaskFilter) GetWorkspaceIds() []string { @@ -1700,7 +1896,7 @@ type ListTasksRequest struct { func (x *ListTasksRequest) Reset() { *x = ListTasksRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[30] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1712,7 +1908,7 @@ func (x *ListTasksRequest) String() string { func (*ListTasksRequest) ProtoMessage() {} func (x *ListTasksRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[30] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1725,7 +1921,7 @@ func (x *ListTasksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListTasksRequest.ProtoReflect.Descriptor instead. func (*ListTasksRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{30} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{33} } func (x *ListTasksRequest) GetFilter() *TaskFilter { @@ -1752,7 +1948,7 @@ type ListTasksResponse struct { func (x *ListTasksResponse) Reset() { *x = ListTasksResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[31] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1764,7 +1960,7 @@ func (x *ListTasksResponse) String() string { func (*ListTasksResponse) ProtoMessage() {} func (x *ListTasksResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[31] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1777,7 +1973,7 @@ func (x *ListTasksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListTasksResponse.ProtoReflect.Descriptor instead. func (*ListTasksResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{31} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{34} } func (x *ListTasksResponse) GetTasks() []*Task { @@ -1803,7 +1999,7 @@ type GetTaskRequest struct { func (x *GetTaskRequest) Reset() { *x = GetTaskRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[32] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1815,7 +2011,7 @@ func (x *GetTaskRequest) String() string { func (*GetTaskRequest) ProtoMessage() {} func (x *GetTaskRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[32] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1828,7 +2024,7 @@ func (x *GetTaskRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetTaskRequest.ProtoReflect.Descriptor instead. func (*GetTaskRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{32} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{35} } func (x *GetTaskRequest) GetId() string { @@ -1847,7 +2043,7 @@ type GetTaskResponse struct { func (x *GetTaskResponse) Reset() { *x = GetTaskResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[33] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1859,7 +2055,7 @@ func (x *GetTaskResponse) String() string { func (*GetTaskResponse) ProtoMessage() {} func (x *GetTaskResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[33] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1872,7 +2068,7 @@ func (x *GetTaskResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetTaskResponse.ProtoReflect.Descriptor instead. func (*GetTaskResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{33} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{36} } func (x *GetTaskResponse) GetTask() *Task { @@ -1899,7 +2095,7 @@ type Workspace struct { func (x *Workspace) Reset() { *x = Workspace{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[34] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1911,7 +2107,7 @@ func (x *Workspace) String() string { func (*Workspace) ProtoMessage() {} func (x *Workspace) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[34] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1924,7 +2120,7 @@ func (x *Workspace) ProtoReflect() protoreflect.Message { // Deprecated: Use Workspace.ProtoReflect.Descriptor instead. func (*Workspace) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{34} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{37} } func (x *Workspace) GetId() string { @@ -1992,7 +2188,7 @@ type ListWorkspacesRequest struct { func (x *ListWorkspacesRequest) Reset() { *x = ListWorkspacesRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[35] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2004,7 +2200,7 @@ func (x *ListWorkspacesRequest) String() string { func (*ListWorkspacesRequest) ProtoMessage() {} func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[35] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2017,7 +2213,7 @@ func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesRequest.ProtoReflect.Descriptor instead. func (*ListWorkspacesRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{35} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{38} } func (x *ListWorkspacesRequest) GetPage() *Page { @@ -2037,7 +2233,7 @@ type ListWorkspacesResponse struct { func (x *ListWorkspacesResponse) Reset() { *x = ListWorkspacesResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[36] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2049,7 +2245,7 @@ func (x *ListWorkspacesResponse) String() string { func (*ListWorkspacesResponse) ProtoMessage() {} func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[36] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2062,7 +2258,7 @@ func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesResponse.ProtoReflect.Descriptor instead. func (*ListWorkspacesResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{36} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{39} } func (x *ListWorkspacesResponse) GetWorkspaces() []*Workspace { @@ -2094,7 +2290,7 @@ type Workflow struct { func (x *Workflow) Reset() { *x = Workflow{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[37] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2106,7 +2302,7 @@ func (x *Workflow) String() string { func (*Workflow) ProtoMessage() {} func (x *Workflow) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[37] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2119,7 +2315,7 @@ func (x *Workflow) ProtoReflect() protoreflect.Message { // Deprecated: Use Workflow.ProtoReflect.Descriptor instead. func (*Workflow) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{37} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{40} } func (x *Workflow) GetId() string { @@ -2181,7 +2377,7 @@ type ListWorkflowsRequest struct { func (x *ListWorkflowsRequest) Reset() { *x = ListWorkflowsRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[38] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2193,7 +2389,7 @@ func (x *ListWorkflowsRequest) String() string { func (*ListWorkflowsRequest) ProtoMessage() {} func (x *ListWorkflowsRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[38] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2206,7 +2402,7 @@ func (x *ListWorkflowsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkflowsRequest.ProtoReflect.Descriptor instead. func (*ListWorkflowsRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{38} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{41} } func (x *ListWorkflowsRequest) GetWorkspaceId() string { @@ -2233,7 +2429,7 @@ type ListWorkflowsResponse struct { func (x *ListWorkflowsResponse) Reset() { *x = ListWorkflowsResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[39] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2245,7 +2441,7 @@ func (x *ListWorkflowsResponse) String() string { func (*ListWorkflowsResponse) ProtoMessage() {} func (x *ListWorkflowsResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[39] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2258,7 +2454,7 @@ func (x *ListWorkflowsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkflowsResponse.ProtoReflect.Descriptor instead. func (*ListWorkflowsResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{39} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{42} } func (x *ListWorkflowsResponse) GetWorkflows() []*Workflow { @@ -2288,7 +2484,7 @@ type WorkflowStep struct { func (x *WorkflowStep) Reset() { *x = WorkflowStep{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[40] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2300,7 +2496,7 @@ func (x *WorkflowStep) String() string { func (*WorkflowStep) ProtoMessage() {} func (x *WorkflowStep) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[40] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2313,7 +2509,7 @@ func (x *WorkflowStep) ProtoReflect() protoreflect.Message { // Deprecated: Use WorkflowStep.ProtoReflect.Descriptor instead. func (*WorkflowStep) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{40} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{43} } func (x *WorkflowStep) GetId() string { @@ -2360,7 +2556,7 @@ type ListWorkflowStepsRequest struct { func (x *ListWorkflowStepsRequest) Reset() { *x = ListWorkflowStepsRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[41] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2372,7 +2568,7 @@ func (x *ListWorkflowStepsRequest) String() string { func (*ListWorkflowStepsRequest) ProtoMessage() {} func (x *ListWorkflowStepsRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[41] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2385,7 +2581,7 @@ func (x *ListWorkflowStepsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkflowStepsRequest.ProtoReflect.Descriptor instead. func (*ListWorkflowStepsRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{41} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{44} } func (x *ListWorkflowStepsRequest) GetWorkflowId() string { @@ -2404,7 +2600,7 @@ type ListWorkflowStepsResponse struct { func (x *ListWorkflowStepsResponse) Reset() { *x = ListWorkflowStepsResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[42] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2416,7 +2612,7 @@ func (x *ListWorkflowStepsResponse) String() string { func (*ListWorkflowStepsResponse) ProtoMessage() {} func (x *ListWorkflowStepsResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[42] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2429,7 +2625,7 @@ func (x *ListWorkflowStepsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkflowStepsResponse.ProtoReflect.Descriptor instead. func (*ListWorkflowStepsResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{42} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{45} } func (x *ListWorkflowStepsResponse) GetSteps() []*WorkflowStep { @@ -2454,7 +2650,7 @@ type AgentProfile struct { func (x *AgentProfile) Reset() { *x = AgentProfile{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[43] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2466,7 +2662,7 @@ func (x *AgentProfile) String() string { func (*AgentProfile) ProtoMessage() {} func (x *AgentProfile) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[43] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2479,7 +2675,7 @@ func (x *AgentProfile) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentProfile.ProtoReflect.Descriptor instead. func (*AgentProfile) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{43} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{46} } func (x *AgentProfile) GetId() string { @@ -2533,7 +2729,7 @@ type ListAgentProfilesRequest struct { func (x *ListAgentProfilesRequest) Reset() { *x = ListAgentProfilesRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[44] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2545,7 +2741,7 @@ func (x *ListAgentProfilesRequest) String() string { func (*ListAgentProfilesRequest) ProtoMessage() {} func (x *ListAgentProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[44] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2558,7 +2754,7 @@ func (x *ListAgentProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListAgentProfilesRequest.ProtoReflect.Descriptor instead. func (*ListAgentProfilesRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{44} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{47} } func (x *ListAgentProfilesRequest) GetPage() *Page { @@ -2578,7 +2774,7 @@ type ListAgentProfilesResponse struct { func (x *ListAgentProfilesResponse) Reset() { *x = ListAgentProfilesResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[45] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2590,7 +2786,7 @@ func (x *ListAgentProfilesResponse) String() string { func (*ListAgentProfilesResponse) ProtoMessage() {} func (x *ListAgentProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[45] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2603,7 +2799,7 @@ func (x *ListAgentProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListAgentProfilesResponse.ProtoReflect.Descriptor instead. func (*ListAgentProfilesResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{45} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{48} } func (x *ListAgentProfilesResponse) GetProfiles() []*AgentProfile { @@ -2632,7 +2828,7 @@ type Repository struct { func (x *Repository) Reset() { *x = Repository{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[46] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2644,7 +2840,7 @@ func (x *Repository) String() string { func (*Repository) ProtoMessage() {} func (x *Repository) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[46] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2657,7 +2853,7 @@ func (x *Repository) ProtoReflect() protoreflect.Message { // Deprecated: Use Repository.ProtoReflect.Descriptor instead. func (*Repository) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{46} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{49} } func (x *Repository) GetId() string { @@ -2698,7 +2894,7 @@ type ListRepositoriesRequest struct { func (x *ListRepositoriesRequest) Reset() { *x = ListRepositoriesRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[47] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2710,7 +2906,7 @@ func (x *ListRepositoriesRequest) String() string { func (*ListRepositoriesRequest) ProtoMessage() {} func (x *ListRepositoriesRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[47] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2723,7 +2919,7 @@ func (x *ListRepositoriesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRepositoriesRequest.ProtoReflect.Descriptor instead. func (*ListRepositoriesRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{47} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{50} } func (x *ListRepositoriesRequest) GetWorkspaceId() string { @@ -2750,7 +2946,7 @@ type ListRepositoriesResponse struct { func (x *ListRepositoriesResponse) Reset() { *x = ListRepositoriesResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[48] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2762,7 +2958,7 @@ func (x *ListRepositoriesResponse) String() string { func (*ListRepositoriesResponse) ProtoMessage() {} func (x *ListRepositoriesResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[48] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2775,7 +2971,7 @@ func (x *ListRepositoriesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRepositoriesResponse.ProtoReflect.Descriptor instead. func (*ListRepositoriesResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{48} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{51} } func (x *ListRepositoriesResponse) GetRepositories() []*Repository { @@ -2820,7 +3016,7 @@ type Session struct { func (x *Session) Reset() { *x = Session{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[49] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2832,7 +3028,7 @@ func (x *Session) String() string { func (*Session) ProtoMessage() {} func (x *Session) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[49] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2845,7 +3041,7 @@ func (x *Session) ProtoReflect() protoreflect.Message { // Deprecated: Use Session.ProtoReflect.Descriptor instead. func (*Session) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{49} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{52} } func (x *Session) GetId() string { @@ -2929,7 +3125,7 @@ type SessionFilter struct { func (x *SessionFilter) Reset() { *x = SessionFilter{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[50] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2941,7 +3137,7 @@ func (x *SessionFilter) String() string { func (*SessionFilter) ProtoMessage() {} func (x *SessionFilter) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[50] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2954,7 +3150,7 @@ func (x *SessionFilter) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionFilter.ProtoReflect.Descriptor instead. func (*SessionFilter) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{50} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{53} } func (x *SessionFilter) GetTaskIds() []string { @@ -2988,7 +3184,7 @@ type ListSessionsRequest struct { func (x *ListSessionsRequest) Reset() { *x = ListSessionsRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[51] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3000,7 +3196,7 @@ func (x *ListSessionsRequest) String() string { func (*ListSessionsRequest) ProtoMessage() {} func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[51] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3013,7 +3209,7 @@ func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionsRequest.ProtoReflect.Descriptor instead. func (*ListSessionsRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{51} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{54} } func (x *ListSessionsRequest) GetFilter() *SessionFilter { @@ -3040,7 +3236,7 @@ type ListSessionsResponse struct { func (x *ListSessionsResponse) Reset() { *x = ListSessionsResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[52] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3052,7 +3248,7 @@ func (x *ListSessionsResponse) String() string { func (*ListSessionsResponse) ProtoMessage() {} func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[52] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3065,7 +3261,7 @@ func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionsResponse.ProtoReflect.Descriptor instead. func (*ListSessionsResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{52} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{55} } func (x *ListSessionsResponse) GetSessions() []*Session { @@ -3098,7 +3294,7 @@ type SessionCodeStats struct { func (x *SessionCodeStats) Reset() { *x = SessionCodeStats{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[53] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3110,7 +3306,7 @@ func (x *SessionCodeStats) String() string { func (*SessionCodeStats) ProtoMessage() {} func (x *SessionCodeStats) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[53] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3123,7 +3319,7 @@ func (x *SessionCodeStats) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionCodeStats.ProtoReflect.Descriptor instead. func (*SessionCodeStats) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{53} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{56} } func (x *SessionCodeStats) GetSessionId() string { @@ -3171,7 +3367,7 @@ type ListSessionCodeStatsRequest struct { func (x *ListSessionCodeStatsRequest) Reset() { *x = ListSessionCodeStatsRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[54] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3183,7 +3379,7 @@ func (x *ListSessionCodeStatsRequest) String() string { func (*ListSessionCodeStatsRequest) ProtoMessage() {} func (x *ListSessionCodeStatsRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[54] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3196,7 +3392,7 @@ func (x *ListSessionCodeStatsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionCodeStatsRequest.ProtoReflect.Descriptor instead. func (*ListSessionCodeStatsRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{54} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{57} } func (x *ListSessionCodeStatsRequest) GetFilter() *SessionFilter { @@ -3223,7 +3419,7 @@ type ListSessionCodeStatsResponse struct { func (x *ListSessionCodeStatsResponse) Reset() { *x = ListSessionCodeStatsResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[55] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3235,7 +3431,7 @@ func (x *ListSessionCodeStatsResponse) String() string { func (*ListSessionCodeStatsResponse) ProtoMessage() {} func (x *ListSessionCodeStatsResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[55] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3248,7 +3444,7 @@ func (x *ListSessionCodeStatsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionCodeStatsResponse.ProtoReflect.Descriptor instead. func (*ListSessionCodeStatsResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{55} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{58} } func (x *ListSessionCodeStatsResponse) GetStats() []*SessionCodeStats { @@ -3287,7 +3483,7 @@ type Message struct { func (x *Message) Reset() { *x = Message{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[56] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3299,7 +3495,7 @@ func (x *Message) String() string { func (*Message) ProtoMessage() {} func (x *Message) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[56] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3312,7 +3508,7 @@ func (x *Message) ProtoReflect() protoreflect.Message { // Deprecated: Use Message.ProtoReflect.Descriptor instead. func (*Message) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{56} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{59} } func (x *Message) GetId() string { @@ -3384,7 +3580,7 @@ type MessageFilter struct { func (x *MessageFilter) Reset() { *x = MessageFilter{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[57] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3396,7 +3592,7 @@ func (x *MessageFilter) String() string { func (*MessageFilter) ProtoMessage() {} func (x *MessageFilter) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[57] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3409,7 +3605,7 @@ func (x *MessageFilter) ProtoReflect() protoreflect.Message { // Deprecated: Use MessageFilter.ProtoReflect.Descriptor instead. func (*MessageFilter) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{57} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{60} } func (x *MessageFilter) GetSessionIds() []string { @@ -3457,7 +3653,7 @@ type ListMessagesRequest struct { func (x *ListMessagesRequest) Reset() { *x = ListMessagesRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[58] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3469,7 +3665,7 @@ func (x *ListMessagesRequest) String() string { func (*ListMessagesRequest) ProtoMessage() {} func (x *ListMessagesRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[58] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3482,7 +3678,7 @@ func (x *ListMessagesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListMessagesRequest.ProtoReflect.Descriptor instead. func (*ListMessagesRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{58} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{61} } func (x *ListMessagesRequest) GetFilter() *MessageFilter { @@ -3509,7 +3705,7 @@ type ListMessagesResponse struct { func (x *ListMessagesResponse) Reset() { *x = ListMessagesResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[59] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3521,7 +3717,7 @@ func (x *ListMessagesResponse) String() string { func (*ListMessagesResponse) ProtoMessage() {} func (x *ListMessagesResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[59] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3534,7 +3730,7 @@ func (x *ListMessagesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListMessagesResponse.ProtoReflect.Descriptor instead. func (*ListMessagesResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{59} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{62} } func (x *ListMessagesResponse) GetMessages() []*Message { @@ -3564,7 +3760,7 @@ type InvokeUtilityAgentRequest struct { func (x *InvokeUtilityAgentRequest) Reset() { *x = InvokeUtilityAgentRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[60] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3576,7 +3772,7 @@ func (x *InvokeUtilityAgentRequest) String() string { func (*InvokeUtilityAgentRequest) ProtoMessage() {} func (x *InvokeUtilityAgentRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[60] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3589,7 +3785,7 @@ func (x *InvokeUtilityAgentRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InvokeUtilityAgentRequest.ProtoReflect.Descriptor instead. func (*InvokeUtilityAgentRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{60} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{63} } func (x *InvokeUtilityAgentRequest) GetPrompt() string { @@ -3608,7 +3804,7 @@ type InvokeUtilityAgentResponse struct { func (x *InvokeUtilityAgentResponse) Reset() { *x = InvokeUtilityAgentResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[61] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3620,7 +3816,7 @@ func (x *InvokeUtilityAgentResponse) String() string { func (*InvokeUtilityAgentResponse) ProtoMessage() {} func (x *InvokeUtilityAgentResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[61] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3633,7 +3829,7 @@ func (x *InvokeUtilityAgentResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InvokeUtilityAgentResponse.ProtoReflect.Descriptor instead. func (*InvokeUtilityAgentResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{61} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{64} } func (x *InvokeUtilityAgentResponse) GetText() string { @@ -3659,7 +3855,7 @@ type CreateTaskRequest struct { func (x *CreateTaskRequest) Reset() { *x = CreateTaskRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[62] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3671,7 +3867,7 @@ func (x *CreateTaskRequest) String() string { func (*CreateTaskRequest) ProtoMessage() {} func (x *CreateTaskRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[62] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3684,7 +3880,7 @@ func (x *CreateTaskRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateTaskRequest.ProtoReflect.Descriptor instead. func (*CreateTaskRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{62} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{65} } func (x *CreateTaskRequest) GetWorkspaceId() string { @@ -3745,7 +3941,7 @@ type CreateTaskResponse struct { func (x *CreateTaskResponse) Reset() { *x = CreateTaskResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[63] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3757,7 +3953,7 @@ func (x *CreateTaskResponse) String() string { func (*CreateTaskResponse) ProtoMessage() {} func (x *CreateTaskResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[63] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3770,7 +3966,7 @@ func (x *CreateTaskResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateTaskResponse.ProtoReflect.Descriptor instead. func (*CreateTaskResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{63} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{66} } func (x *CreateTaskResponse) GetTask() *Task { @@ -3793,7 +3989,7 @@ type UpdateTaskRequest struct { func (x *UpdateTaskRequest) Reset() { *x = UpdateTaskRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[64] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3805,7 +4001,7 @@ func (x *UpdateTaskRequest) String() string { func (*UpdateTaskRequest) ProtoMessage() {} func (x *UpdateTaskRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[64] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3818,7 +4014,7 @@ func (x *UpdateTaskRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateTaskRequest.ProtoReflect.Descriptor instead. func (*UpdateTaskRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{64} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{67} } func (x *UpdateTaskRequest) GetId() string { @@ -3865,7 +4061,7 @@ type UpdateTaskResponse struct { func (x *UpdateTaskResponse) Reset() { *x = UpdateTaskResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[65] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3877,7 +4073,7 @@ func (x *UpdateTaskResponse) String() string { func (*UpdateTaskResponse) ProtoMessage() {} func (x *UpdateTaskResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[65] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3890,7 +4086,7 @@ func (x *UpdateTaskResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateTaskResponse.ProtoReflect.Descriptor instead. func (*UpdateTaskResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{65} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{68} } func (x *UpdateTaskResponse) GetTask() *Task { @@ -3917,7 +4113,7 @@ type SendMessageRequest struct { func (x *SendMessageRequest) Reset() { *x = SendMessageRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[66] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3929,7 +4125,7 @@ func (x *SendMessageRequest) String() string { func (*SendMessageRequest) ProtoMessage() {} func (x *SendMessageRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[66] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3942,7 +4138,7 @@ func (x *SendMessageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SendMessageRequest.ProtoReflect.Descriptor instead. func (*SendMessageRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{66} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{69} } func (x *SendMessageRequest) GetTaskId() string { @@ -3976,7 +4172,7 @@ type SendMessageResponse struct { func (x *SendMessageResponse) Reset() { *x = SendMessageResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[67] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3988,7 +4184,7 @@ func (x *SendMessageResponse) String() string { func (*SendMessageResponse) ProtoMessage() {} func (x *SendMessageResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[67] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4001,7 +4197,7 @@ func (x *SendMessageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SendMessageResponse.ProtoReflect.Descriptor instead. func (*SendMessageResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{67} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{70} } func (x *SendMessageResponse) GetSessionId() string { @@ -4032,7 +4228,22 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "\fworkspace_id\x18\x04 \x01(\tR\vworkspaceId\x121\n" + "\apayload\x18\x05 \x01(\v2\x17.google.protobuf.StructR\apayload\"\n" + "\n" + - "\bEventAck\"\x8c\x02\n" + + "\bEventAck\"\xc0\x01\n" + + "\x10AgentToolRequest\x12#\n" + + "\rinvocation_id\x18\x01 \x01(\tR\finvocationId\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x125\n" + + "\targuments\x18\x03 \x01(\v2\x17.google.protobuf.StructR\targuments\x12<\n" + + "\acontext\x18\x04 \x01(\v2\".kandev.plugin.v1.AgentToolContextR\acontext\"\x87\x01\n" + + "\x10AgentToolContext\x12\x17\n" + + "\atask_id\x18\x01 \x01(\tR\x06taskId\x12\x1d\n" + + "\n" + + "session_id\x18\x02 \x01(\tR\tsessionId\x12!\n" + + "\fworkspace_id\x18\x03 \x01(\tR\vworkspaceId\x12\x18\n" + + "\asurface\x18\x04 \x01(\tR\asurface\"\x8a\x01\n" + + "\x11AgentToolResponse\x12\x12\n" + + "\x04text\x18\x01 \x01(\tR\x04text\x12F\n" + + "\x12structured_content\x18\x02 \x01(\v2\x17.google.protobuf.StructR\x11structuredContent\x12\x19\n" + + "\bis_error\x18\x03 \x01(\bR\aisError\"\x8c\x02\n" + "\x0eWebhookRequest\x12\x1f\n" + "\vwebhook_key\x18\x01 \x01(\tR\n" + "webhookKey\x12\x16\n" + @@ -4346,10 +4557,11 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "\x13SendMessageResponse\x12\x1d\n" + "\n" + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x16\n" + - "\x06status\x18\x02 \x01(\tR\x06status2\xa3\x01\n" + + "\x06status\x18\x02 \x01(\tR\x06status2\xff\x01\n" + "\x06Plugin\x12C\n" + "\fDeliverEvent\x12\x17.kandev.plugin.v1.Event\x1a\x1a.kandev.plugin.v1.EventAck\x12T\n" + - "\rHandleWebhook\x12 .kandev.plugin.v1.WebhookRequest\x1a!.kandev.plugin.v1.WebhookResponse2\xdc\x11\n" + + "\rHandleWebhook\x12 .kandev.plugin.v1.WebhookRequest\x1a!.kandev.plugin.v1.WebhookResponse\x12Z\n" + + "\x0fInvokeAgentTool\x12\".kandev.plugin.v1.AgentToolRequest\x1a#.kandev.plugin.v1.AgentToolResponse2\xdc\x11\n" + "\x04Host\x12Q\n" + "\bGetState\x12!.kandev.plugin.v1.GetStateRequest\x1a\".kandev.plugin.v1.GetStateResponse\x12Q\n" + "\bSetState\x12!.kandev.plugin.v1.SetStateRequest\x1a\".kandev.plugin.v1.SetStateResponse\x12Z\n" + @@ -4390,181 +4602,189 @@ func file_kandev_plugin_v1_plugin_proto_rawDescGZIP() []byte { return file_kandev_plugin_v1_plugin_proto_rawDescData } -var file_kandev_plugin_v1_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 70) +var file_kandev_plugin_v1_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 73) var file_kandev_plugin_v1_plugin_proto_goTypes = []any{ (*Event)(nil), // 0: kandev.plugin.v1.Event (*EventAck)(nil), // 1: kandev.plugin.v1.EventAck - (*WebhookRequest)(nil), // 2: kandev.plugin.v1.WebhookRequest - (*WebhookResponse)(nil), // 3: kandev.plugin.v1.WebhookResponse - (*GetStateRequest)(nil), // 4: kandev.plugin.v1.GetStateRequest - (*GetStateResponse)(nil), // 5: kandev.plugin.v1.GetStateResponse - (*SetStateRequest)(nil), // 6: kandev.plugin.v1.SetStateRequest - (*SetStateResponse)(nil), // 7: kandev.plugin.v1.SetStateResponse - (*DeleteStateRequest)(nil), // 8: kandev.plugin.v1.DeleteStateRequest - (*DeleteStateResponse)(nil), // 9: kandev.plugin.v1.DeleteStateResponse - (*ListStateRequest)(nil), // 10: kandev.plugin.v1.ListStateRequest - (*ListStateResponse)(nil), // 11: kandev.plugin.v1.ListStateResponse - (*StateEntry)(nil), // 12: kandev.plugin.v1.StateEntry - (*GetConfigRequest)(nil), // 13: kandev.plugin.v1.GetConfigRequest - (*GetConfigResponse)(nil), // 14: kandev.plugin.v1.GetConfigResponse - (*GetSecretRequest)(nil), // 15: kandev.plugin.v1.GetSecretRequest - (*GetSecretResponse)(nil), // 16: kandev.plugin.v1.GetSecretResponse - (*SetSecretRequest)(nil), // 17: kandev.plugin.v1.SetSecretRequest - (*SetSecretResponse)(nil), // 18: kandev.plugin.v1.SetSecretResponse - (*DeleteSecretRequest)(nil), // 19: kandev.plugin.v1.DeleteSecretRequest - (*DeleteSecretResponse)(nil), // 20: kandev.plugin.v1.DeleteSecretResponse - (*RevealSecretRequest)(nil), // 21: kandev.plugin.v1.RevealSecretRequest - (*RevealSecretResponse)(nil), // 22: kandev.plugin.v1.RevealSecretResponse - (*EmitEventRequest)(nil), // 23: kandev.plugin.v1.EmitEventRequest - (*EmitEventResponse)(nil), // 24: kandev.plugin.v1.EmitEventResponse - (*Page)(nil), // 25: kandev.plugin.v1.Page - (*PageInfo)(nil), // 26: kandev.plugin.v1.PageInfo - (*Task)(nil), // 27: kandev.plugin.v1.Task - (*TaskRepository)(nil), // 28: kandev.plugin.v1.TaskRepository - (*TaskFilter)(nil), // 29: kandev.plugin.v1.TaskFilter - (*ListTasksRequest)(nil), // 30: kandev.plugin.v1.ListTasksRequest - (*ListTasksResponse)(nil), // 31: kandev.plugin.v1.ListTasksResponse - (*GetTaskRequest)(nil), // 32: kandev.plugin.v1.GetTaskRequest - (*GetTaskResponse)(nil), // 33: kandev.plugin.v1.GetTaskResponse - (*Workspace)(nil), // 34: kandev.plugin.v1.Workspace - (*ListWorkspacesRequest)(nil), // 35: kandev.plugin.v1.ListWorkspacesRequest - (*ListWorkspacesResponse)(nil), // 36: kandev.plugin.v1.ListWorkspacesResponse - (*Workflow)(nil), // 37: kandev.plugin.v1.Workflow - (*ListWorkflowsRequest)(nil), // 38: kandev.plugin.v1.ListWorkflowsRequest - (*ListWorkflowsResponse)(nil), // 39: kandev.plugin.v1.ListWorkflowsResponse - (*WorkflowStep)(nil), // 40: kandev.plugin.v1.WorkflowStep - (*ListWorkflowStepsRequest)(nil), // 41: kandev.plugin.v1.ListWorkflowStepsRequest - (*ListWorkflowStepsResponse)(nil), // 42: kandev.plugin.v1.ListWorkflowStepsResponse - (*AgentProfile)(nil), // 43: kandev.plugin.v1.AgentProfile - (*ListAgentProfilesRequest)(nil), // 44: kandev.plugin.v1.ListAgentProfilesRequest - (*ListAgentProfilesResponse)(nil), // 45: kandev.plugin.v1.ListAgentProfilesResponse - (*Repository)(nil), // 46: kandev.plugin.v1.Repository - (*ListRepositoriesRequest)(nil), // 47: kandev.plugin.v1.ListRepositoriesRequest - (*ListRepositoriesResponse)(nil), // 48: kandev.plugin.v1.ListRepositoriesResponse - (*Session)(nil), // 49: kandev.plugin.v1.Session - (*SessionFilter)(nil), // 50: kandev.plugin.v1.SessionFilter - (*ListSessionsRequest)(nil), // 51: kandev.plugin.v1.ListSessionsRequest - (*ListSessionsResponse)(nil), // 52: kandev.plugin.v1.ListSessionsResponse - (*SessionCodeStats)(nil), // 53: kandev.plugin.v1.SessionCodeStats - (*ListSessionCodeStatsRequest)(nil), // 54: kandev.plugin.v1.ListSessionCodeStatsRequest - (*ListSessionCodeStatsResponse)(nil), // 55: kandev.plugin.v1.ListSessionCodeStatsResponse - (*Message)(nil), // 56: kandev.plugin.v1.Message - (*MessageFilter)(nil), // 57: kandev.plugin.v1.MessageFilter - (*ListMessagesRequest)(nil), // 58: kandev.plugin.v1.ListMessagesRequest - (*ListMessagesResponse)(nil), // 59: kandev.plugin.v1.ListMessagesResponse - (*InvokeUtilityAgentRequest)(nil), // 60: kandev.plugin.v1.InvokeUtilityAgentRequest - (*InvokeUtilityAgentResponse)(nil), // 61: kandev.plugin.v1.InvokeUtilityAgentResponse - (*CreateTaskRequest)(nil), // 62: kandev.plugin.v1.CreateTaskRequest - (*CreateTaskResponse)(nil), // 63: kandev.plugin.v1.CreateTaskResponse - (*UpdateTaskRequest)(nil), // 64: kandev.plugin.v1.UpdateTaskRequest - (*UpdateTaskResponse)(nil), // 65: kandev.plugin.v1.UpdateTaskResponse - (*SendMessageRequest)(nil), // 66: kandev.plugin.v1.SendMessageRequest - (*SendMessageResponse)(nil), // 67: kandev.plugin.v1.SendMessageResponse - nil, // 68: kandev.plugin.v1.WebhookRequest.HeadersEntry - nil, // 69: kandev.plugin.v1.WebhookResponse.HeadersEntry - (*structpb.Struct)(nil), // 70: google.protobuf.Struct + (*AgentToolRequest)(nil), // 2: kandev.plugin.v1.AgentToolRequest + (*AgentToolContext)(nil), // 3: kandev.plugin.v1.AgentToolContext + (*AgentToolResponse)(nil), // 4: kandev.plugin.v1.AgentToolResponse + (*WebhookRequest)(nil), // 5: kandev.plugin.v1.WebhookRequest + (*WebhookResponse)(nil), // 6: kandev.plugin.v1.WebhookResponse + (*GetStateRequest)(nil), // 7: kandev.plugin.v1.GetStateRequest + (*GetStateResponse)(nil), // 8: kandev.plugin.v1.GetStateResponse + (*SetStateRequest)(nil), // 9: kandev.plugin.v1.SetStateRequest + (*SetStateResponse)(nil), // 10: kandev.plugin.v1.SetStateResponse + (*DeleteStateRequest)(nil), // 11: kandev.plugin.v1.DeleteStateRequest + (*DeleteStateResponse)(nil), // 12: kandev.plugin.v1.DeleteStateResponse + (*ListStateRequest)(nil), // 13: kandev.plugin.v1.ListStateRequest + (*ListStateResponse)(nil), // 14: kandev.plugin.v1.ListStateResponse + (*StateEntry)(nil), // 15: kandev.plugin.v1.StateEntry + (*GetConfigRequest)(nil), // 16: kandev.plugin.v1.GetConfigRequest + (*GetConfigResponse)(nil), // 17: kandev.plugin.v1.GetConfigResponse + (*GetSecretRequest)(nil), // 18: kandev.plugin.v1.GetSecretRequest + (*GetSecretResponse)(nil), // 19: kandev.plugin.v1.GetSecretResponse + (*SetSecretRequest)(nil), // 20: kandev.plugin.v1.SetSecretRequest + (*SetSecretResponse)(nil), // 21: kandev.plugin.v1.SetSecretResponse + (*DeleteSecretRequest)(nil), // 22: kandev.plugin.v1.DeleteSecretRequest + (*DeleteSecretResponse)(nil), // 23: kandev.plugin.v1.DeleteSecretResponse + (*RevealSecretRequest)(nil), // 24: kandev.plugin.v1.RevealSecretRequest + (*RevealSecretResponse)(nil), // 25: kandev.plugin.v1.RevealSecretResponse + (*EmitEventRequest)(nil), // 26: kandev.plugin.v1.EmitEventRequest + (*EmitEventResponse)(nil), // 27: kandev.plugin.v1.EmitEventResponse + (*Page)(nil), // 28: kandev.plugin.v1.Page + (*PageInfo)(nil), // 29: kandev.plugin.v1.PageInfo + (*Task)(nil), // 30: kandev.plugin.v1.Task + (*TaskRepository)(nil), // 31: kandev.plugin.v1.TaskRepository + (*TaskFilter)(nil), // 32: kandev.plugin.v1.TaskFilter + (*ListTasksRequest)(nil), // 33: kandev.plugin.v1.ListTasksRequest + (*ListTasksResponse)(nil), // 34: kandev.plugin.v1.ListTasksResponse + (*GetTaskRequest)(nil), // 35: kandev.plugin.v1.GetTaskRequest + (*GetTaskResponse)(nil), // 36: kandev.plugin.v1.GetTaskResponse + (*Workspace)(nil), // 37: kandev.plugin.v1.Workspace + (*ListWorkspacesRequest)(nil), // 38: kandev.plugin.v1.ListWorkspacesRequest + (*ListWorkspacesResponse)(nil), // 39: kandev.plugin.v1.ListWorkspacesResponse + (*Workflow)(nil), // 40: kandev.plugin.v1.Workflow + (*ListWorkflowsRequest)(nil), // 41: kandev.plugin.v1.ListWorkflowsRequest + (*ListWorkflowsResponse)(nil), // 42: kandev.plugin.v1.ListWorkflowsResponse + (*WorkflowStep)(nil), // 43: kandev.plugin.v1.WorkflowStep + (*ListWorkflowStepsRequest)(nil), // 44: kandev.plugin.v1.ListWorkflowStepsRequest + (*ListWorkflowStepsResponse)(nil), // 45: kandev.plugin.v1.ListWorkflowStepsResponse + (*AgentProfile)(nil), // 46: kandev.plugin.v1.AgentProfile + (*ListAgentProfilesRequest)(nil), // 47: kandev.plugin.v1.ListAgentProfilesRequest + (*ListAgentProfilesResponse)(nil), // 48: kandev.plugin.v1.ListAgentProfilesResponse + (*Repository)(nil), // 49: kandev.plugin.v1.Repository + (*ListRepositoriesRequest)(nil), // 50: kandev.plugin.v1.ListRepositoriesRequest + (*ListRepositoriesResponse)(nil), // 51: kandev.plugin.v1.ListRepositoriesResponse + (*Session)(nil), // 52: kandev.plugin.v1.Session + (*SessionFilter)(nil), // 53: kandev.plugin.v1.SessionFilter + (*ListSessionsRequest)(nil), // 54: kandev.plugin.v1.ListSessionsRequest + (*ListSessionsResponse)(nil), // 55: kandev.plugin.v1.ListSessionsResponse + (*SessionCodeStats)(nil), // 56: kandev.plugin.v1.SessionCodeStats + (*ListSessionCodeStatsRequest)(nil), // 57: kandev.plugin.v1.ListSessionCodeStatsRequest + (*ListSessionCodeStatsResponse)(nil), // 58: kandev.plugin.v1.ListSessionCodeStatsResponse + (*Message)(nil), // 59: kandev.plugin.v1.Message + (*MessageFilter)(nil), // 60: kandev.plugin.v1.MessageFilter + (*ListMessagesRequest)(nil), // 61: kandev.plugin.v1.ListMessagesRequest + (*ListMessagesResponse)(nil), // 62: kandev.plugin.v1.ListMessagesResponse + (*InvokeUtilityAgentRequest)(nil), // 63: kandev.plugin.v1.InvokeUtilityAgentRequest + (*InvokeUtilityAgentResponse)(nil), // 64: kandev.plugin.v1.InvokeUtilityAgentResponse + (*CreateTaskRequest)(nil), // 65: kandev.plugin.v1.CreateTaskRequest + (*CreateTaskResponse)(nil), // 66: kandev.plugin.v1.CreateTaskResponse + (*UpdateTaskRequest)(nil), // 67: kandev.plugin.v1.UpdateTaskRequest + (*UpdateTaskResponse)(nil), // 68: kandev.plugin.v1.UpdateTaskResponse + (*SendMessageRequest)(nil), // 69: kandev.plugin.v1.SendMessageRequest + (*SendMessageResponse)(nil), // 70: kandev.plugin.v1.SendMessageResponse + nil, // 71: kandev.plugin.v1.WebhookRequest.HeadersEntry + nil, // 72: kandev.plugin.v1.WebhookResponse.HeadersEntry + (*structpb.Struct)(nil), // 73: google.protobuf.Struct } var file_kandev_plugin_v1_plugin_proto_depIdxs = []int32{ - 70, // 0: kandev.plugin.v1.Event.payload:type_name -> google.protobuf.Struct - 68, // 1: kandev.plugin.v1.WebhookRequest.headers:type_name -> kandev.plugin.v1.WebhookRequest.HeadersEntry - 69, // 2: kandev.plugin.v1.WebhookResponse.headers:type_name -> kandev.plugin.v1.WebhookResponse.HeadersEntry - 70, // 3: kandev.plugin.v1.GetStateResponse.value:type_name -> google.protobuf.Struct - 70, // 4: kandev.plugin.v1.SetStateRequest.value:type_name -> google.protobuf.Struct - 12, // 5: kandev.plugin.v1.ListStateResponse.entries:type_name -> kandev.plugin.v1.StateEntry - 70, // 6: kandev.plugin.v1.StateEntry.value:type_name -> google.protobuf.Struct - 70, // 7: kandev.plugin.v1.GetConfigResponse.config:type_name -> google.protobuf.Struct - 70, // 8: kandev.plugin.v1.EmitEventRequest.payload:type_name -> google.protobuf.Struct - 28, // 9: kandev.plugin.v1.Task.repositories:type_name -> kandev.plugin.v1.TaskRepository - 70, // 10: kandev.plugin.v1.Task.metadata:type_name -> google.protobuf.Struct - 29, // 11: kandev.plugin.v1.ListTasksRequest.filter:type_name -> kandev.plugin.v1.TaskFilter - 25, // 12: kandev.plugin.v1.ListTasksRequest.page:type_name -> kandev.plugin.v1.Page - 27, // 13: kandev.plugin.v1.ListTasksResponse.tasks:type_name -> kandev.plugin.v1.Task - 26, // 14: kandev.plugin.v1.ListTasksResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 27, // 15: kandev.plugin.v1.GetTaskResponse.task:type_name -> kandev.plugin.v1.Task - 25, // 16: kandev.plugin.v1.ListWorkspacesRequest.page:type_name -> kandev.plugin.v1.Page - 34, // 17: kandev.plugin.v1.ListWorkspacesResponse.workspaces:type_name -> kandev.plugin.v1.Workspace - 26, // 18: kandev.plugin.v1.ListWorkspacesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 25, // 19: kandev.plugin.v1.ListWorkflowsRequest.page:type_name -> kandev.plugin.v1.Page - 37, // 20: kandev.plugin.v1.ListWorkflowsResponse.workflows:type_name -> kandev.plugin.v1.Workflow - 26, // 21: kandev.plugin.v1.ListWorkflowsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 40, // 22: kandev.plugin.v1.ListWorkflowStepsResponse.steps:type_name -> kandev.plugin.v1.WorkflowStep - 25, // 23: kandev.plugin.v1.ListAgentProfilesRequest.page:type_name -> kandev.plugin.v1.Page - 43, // 24: kandev.plugin.v1.ListAgentProfilesResponse.profiles:type_name -> kandev.plugin.v1.AgentProfile - 26, // 25: kandev.plugin.v1.ListAgentProfilesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 25, // 26: kandev.plugin.v1.ListRepositoriesRequest.page:type_name -> kandev.plugin.v1.Page - 46, // 27: kandev.plugin.v1.ListRepositoriesResponse.repositories:type_name -> kandev.plugin.v1.Repository - 26, // 28: kandev.plugin.v1.ListRepositoriesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 50, // 29: kandev.plugin.v1.ListSessionsRequest.filter:type_name -> kandev.plugin.v1.SessionFilter - 25, // 30: kandev.plugin.v1.ListSessionsRequest.page:type_name -> kandev.plugin.v1.Page - 49, // 31: kandev.plugin.v1.ListSessionsResponse.sessions:type_name -> kandev.plugin.v1.Session - 26, // 32: kandev.plugin.v1.ListSessionsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 50, // 33: kandev.plugin.v1.ListSessionCodeStatsRequest.filter:type_name -> kandev.plugin.v1.SessionFilter - 25, // 34: kandev.plugin.v1.ListSessionCodeStatsRequest.page:type_name -> kandev.plugin.v1.Page - 53, // 35: kandev.plugin.v1.ListSessionCodeStatsResponse.stats:type_name -> kandev.plugin.v1.SessionCodeStats - 26, // 36: kandev.plugin.v1.ListSessionCodeStatsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 57, // 37: kandev.plugin.v1.ListMessagesRequest.filter:type_name -> kandev.plugin.v1.MessageFilter - 25, // 38: kandev.plugin.v1.ListMessagesRequest.page:type_name -> kandev.plugin.v1.Page - 56, // 39: kandev.plugin.v1.ListMessagesResponse.messages:type_name -> kandev.plugin.v1.Message - 26, // 40: kandev.plugin.v1.ListMessagesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 27, // 41: kandev.plugin.v1.CreateTaskResponse.task:type_name -> kandev.plugin.v1.Task - 27, // 42: kandev.plugin.v1.UpdateTaskResponse.task:type_name -> kandev.plugin.v1.Task - 0, // 43: kandev.plugin.v1.Plugin.DeliverEvent:input_type -> kandev.plugin.v1.Event - 2, // 44: kandev.plugin.v1.Plugin.HandleWebhook:input_type -> kandev.plugin.v1.WebhookRequest - 4, // 45: kandev.plugin.v1.Host.GetState:input_type -> kandev.plugin.v1.GetStateRequest - 6, // 46: kandev.plugin.v1.Host.SetState:input_type -> kandev.plugin.v1.SetStateRequest - 8, // 47: kandev.plugin.v1.Host.DeleteState:input_type -> kandev.plugin.v1.DeleteStateRequest - 10, // 48: kandev.plugin.v1.Host.ListState:input_type -> kandev.plugin.v1.ListStateRequest - 21, // 49: kandev.plugin.v1.Host.RevealSecret:input_type -> kandev.plugin.v1.RevealSecretRequest - 23, // 50: kandev.plugin.v1.Host.EmitEvent:input_type -> kandev.plugin.v1.EmitEventRequest - 15, // 51: kandev.plugin.v1.Host.GetSecret:input_type -> kandev.plugin.v1.GetSecretRequest - 17, // 52: kandev.plugin.v1.Host.SetSecret:input_type -> kandev.plugin.v1.SetSecretRequest - 19, // 53: kandev.plugin.v1.Host.DeleteSecret:input_type -> kandev.plugin.v1.DeleteSecretRequest - 13, // 54: kandev.plugin.v1.Host.GetConfig:input_type -> kandev.plugin.v1.GetConfigRequest - 30, // 55: kandev.plugin.v1.Host.ListTasks:input_type -> kandev.plugin.v1.ListTasksRequest - 32, // 56: kandev.plugin.v1.Host.GetTask:input_type -> kandev.plugin.v1.GetTaskRequest - 35, // 57: kandev.plugin.v1.Host.ListWorkspaces:input_type -> kandev.plugin.v1.ListWorkspacesRequest - 38, // 58: kandev.plugin.v1.Host.ListWorkflows:input_type -> kandev.plugin.v1.ListWorkflowsRequest - 41, // 59: kandev.plugin.v1.Host.ListWorkflowSteps:input_type -> kandev.plugin.v1.ListWorkflowStepsRequest - 44, // 60: kandev.plugin.v1.Host.ListAgentProfiles:input_type -> kandev.plugin.v1.ListAgentProfilesRequest - 47, // 61: kandev.plugin.v1.Host.ListRepositories:input_type -> kandev.plugin.v1.ListRepositoriesRequest - 51, // 62: kandev.plugin.v1.Host.ListSessions:input_type -> kandev.plugin.v1.ListSessionsRequest - 54, // 63: kandev.plugin.v1.Host.ListSessionCodeStats:input_type -> kandev.plugin.v1.ListSessionCodeStatsRequest - 58, // 64: kandev.plugin.v1.Host.ListMessages:input_type -> kandev.plugin.v1.ListMessagesRequest - 60, // 65: kandev.plugin.v1.Host.InvokeUtilityAgent:input_type -> kandev.plugin.v1.InvokeUtilityAgentRequest - 62, // 66: kandev.plugin.v1.Host.CreateTask:input_type -> kandev.plugin.v1.CreateTaskRequest - 64, // 67: kandev.plugin.v1.Host.UpdateTask:input_type -> kandev.plugin.v1.UpdateTaskRequest - 66, // 68: kandev.plugin.v1.Host.SendMessage:input_type -> kandev.plugin.v1.SendMessageRequest - 1, // 69: kandev.plugin.v1.Plugin.DeliverEvent:output_type -> kandev.plugin.v1.EventAck - 3, // 70: kandev.plugin.v1.Plugin.HandleWebhook:output_type -> kandev.plugin.v1.WebhookResponse - 5, // 71: kandev.plugin.v1.Host.GetState:output_type -> kandev.plugin.v1.GetStateResponse - 7, // 72: kandev.plugin.v1.Host.SetState:output_type -> kandev.plugin.v1.SetStateResponse - 9, // 73: kandev.plugin.v1.Host.DeleteState:output_type -> kandev.plugin.v1.DeleteStateResponse - 11, // 74: kandev.plugin.v1.Host.ListState:output_type -> kandev.plugin.v1.ListStateResponse - 22, // 75: kandev.plugin.v1.Host.RevealSecret:output_type -> kandev.plugin.v1.RevealSecretResponse - 24, // 76: kandev.plugin.v1.Host.EmitEvent:output_type -> kandev.plugin.v1.EmitEventResponse - 16, // 77: kandev.plugin.v1.Host.GetSecret:output_type -> kandev.plugin.v1.GetSecretResponse - 18, // 78: kandev.plugin.v1.Host.SetSecret:output_type -> kandev.plugin.v1.SetSecretResponse - 20, // 79: kandev.plugin.v1.Host.DeleteSecret:output_type -> kandev.plugin.v1.DeleteSecretResponse - 14, // 80: kandev.plugin.v1.Host.GetConfig:output_type -> kandev.plugin.v1.GetConfigResponse - 31, // 81: kandev.plugin.v1.Host.ListTasks:output_type -> kandev.plugin.v1.ListTasksResponse - 33, // 82: kandev.plugin.v1.Host.GetTask:output_type -> kandev.plugin.v1.GetTaskResponse - 36, // 83: kandev.plugin.v1.Host.ListWorkspaces:output_type -> kandev.plugin.v1.ListWorkspacesResponse - 39, // 84: kandev.plugin.v1.Host.ListWorkflows:output_type -> kandev.plugin.v1.ListWorkflowsResponse - 42, // 85: kandev.plugin.v1.Host.ListWorkflowSteps:output_type -> kandev.plugin.v1.ListWorkflowStepsResponse - 45, // 86: kandev.plugin.v1.Host.ListAgentProfiles:output_type -> kandev.plugin.v1.ListAgentProfilesResponse - 48, // 87: kandev.plugin.v1.Host.ListRepositories:output_type -> kandev.plugin.v1.ListRepositoriesResponse - 52, // 88: kandev.plugin.v1.Host.ListSessions:output_type -> kandev.plugin.v1.ListSessionsResponse - 55, // 89: kandev.plugin.v1.Host.ListSessionCodeStats:output_type -> kandev.plugin.v1.ListSessionCodeStatsResponse - 59, // 90: kandev.plugin.v1.Host.ListMessages:output_type -> kandev.plugin.v1.ListMessagesResponse - 61, // 91: kandev.plugin.v1.Host.InvokeUtilityAgent:output_type -> kandev.plugin.v1.InvokeUtilityAgentResponse - 63, // 92: kandev.plugin.v1.Host.CreateTask:output_type -> kandev.plugin.v1.CreateTaskResponse - 65, // 93: kandev.plugin.v1.Host.UpdateTask:output_type -> kandev.plugin.v1.UpdateTaskResponse - 67, // 94: kandev.plugin.v1.Host.SendMessage:output_type -> kandev.plugin.v1.SendMessageResponse - 69, // [69:95] is the sub-list for method output_type - 43, // [43:69] is the sub-list for method input_type - 43, // [43:43] is the sub-list for extension type_name - 43, // [43:43] is the sub-list for extension extendee - 0, // [0:43] is the sub-list for field type_name + 73, // 0: kandev.plugin.v1.Event.payload:type_name -> google.protobuf.Struct + 73, // 1: kandev.plugin.v1.AgentToolRequest.arguments:type_name -> google.protobuf.Struct + 3, // 2: kandev.plugin.v1.AgentToolRequest.context:type_name -> kandev.plugin.v1.AgentToolContext + 73, // 3: kandev.plugin.v1.AgentToolResponse.structured_content:type_name -> google.protobuf.Struct + 71, // 4: kandev.plugin.v1.WebhookRequest.headers:type_name -> kandev.plugin.v1.WebhookRequest.HeadersEntry + 72, // 5: kandev.plugin.v1.WebhookResponse.headers:type_name -> kandev.plugin.v1.WebhookResponse.HeadersEntry + 73, // 6: kandev.plugin.v1.GetStateResponse.value:type_name -> google.protobuf.Struct + 73, // 7: kandev.plugin.v1.SetStateRequest.value:type_name -> google.protobuf.Struct + 15, // 8: kandev.plugin.v1.ListStateResponse.entries:type_name -> kandev.plugin.v1.StateEntry + 73, // 9: kandev.plugin.v1.StateEntry.value:type_name -> google.protobuf.Struct + 73, // 10: kandev.plugin.v1.GetConfigResponse.config:type_name -> google.protobuf.Struct + 73, // 11: kandev.plugin.v1.EmitEventRequest.payload:type_name -> google.protobuf.Struct + 31, // 12: kandev.plugin.v1.Task.repositories:type_name -> kandev.plugin.v1.TaskRepository + 73, // 13: kandev.plugin.v1.Task.metadata:type_name -> google.protobuf.Struct + 32, // 14: kandev.plugin.v1.ListTasksRequest.filter:type_name -> kandev.plugin.v1.TaskFilter + 28, // 15: kandev.plugin.v1.ListTasksRequest.page:type_name -> kandev.plugin.v1.Page + 30, // 16: kandev.plugin.v1.ListTasksResponse.tasks:type_name -> kandev.plugin.v1.Task + 29, // 17: kandev.plugin.v1.ListTasksResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 30, // 18: kandev.plugin.v1.GetTaskResponse.task:type_name -> kandev.plugin.v1.Task + 28, // 19: kandev.plugin.v1.ListWorkspacesRequest.page:type_name -> kandev.plugin.v1.Page + 37, // 20: kandev.plugin.v1.ListWorkspacesResponse.workspaces:type_name -> kandev.plugin.v1.Workspace + 29, // 21: kandev.plugin.v1.ListWorkspacesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 28, // 22: kandev.plugin.v1.ListWorkflowsRequest.page:type_name -> kandev.plugin.v1.Page + 40, // 23: kandev.plugin.v1.ListWorkflowsResponse.workflows:type_name -> kandev.plugin.v1.Workflow + 29, // 24: kandev.plugin.v1.ListWorkflowsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 43, // 25: kandev.plugin.v1.ListWorkflowStepsResponse.steps:type_name -> kandev.plugin.v1.WorkflowStep + 28, // 26: kandev.plugin.v1.ListAgentProfilesRequest.page:type_name -> kandev.plugin.v1.Page + 46, // 27: kandev.plugin.v1.ListAgentProfilesResponse.profiles:type_name -> kandev.plugin.v1.AgentProfile + 29, // 28: kandev.plugin.v1.ListAgentProfilesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 28, // 29: kandev.plugin.v1.ListRepositoriesRequest.page:type_name -> kandev.plugin.v1.Page + 49, // 30: kandev.plugin.v1.ListRepositoriesResponse.repositories:type_name -> kandev.plugin.v1.Repository + 29, // 31: kandev.plugin.v1.ListRepositoriesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 53, // 32: kandev.plugin.v1.ListSessionsRequest.filter:type_name -> kandev.plugin.v1.SessionFilter + 28, // 33: kandev.plugin.v1.ListSessionsRequest.page:type_name -> kandev.plugin.v1.Page + 52, // 34: kandev.plugin.v1.ListSessionsResponse.sessions:type_name -> kandev.plugin.v1.Session + 29, // 35: kandev.plugin.v1.ListSessionsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 53, // 36: kandev.plugin.v1.ListSessionCodeStatsRequest.filter:type_name -> kandev.plugin.v1.SessionFilter + 28, // 37: kandev.plugin.v1.ListSessionCodeStatsRequest.page:type_name -> kandev.plugin.v1.Page + 56, // 38: kandev.plugin.v1.ListSessionCodeStatsResponse.stats:type_name -> kandev.plugin.v1.SessionCodeStats + 29, // 39: kandev.plugin.v1.ListSessionCodeStatsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 60, // 40: kandev.plugin.v1.ListMessagesRequest.filter:type_name -> kandev.plugin.v1.MessageFilter + 28, // 41: kandev.plugin.v1.ListMessagesRequest.page:type_name -> kandev.plugin.v1.Page + 59, // 42: kandev.plugin.v1.ListMessagesResponse.messages:type_name -> kandev.plugin.v1.Message + 29, // 43: kandev.plugin.v1.ListMessagesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 30, // 44: kandev.plugin.v1.CreateTaskResponse.task:type_name -> kandev.plugin.v1.Task + 30, // 45: kandev.plugin.v1.UpdateTaskResponse.task:type_name -> kandev.plugin.v1.Task + 0, // 46: kandev.plugin.v1.Plugin.DeliverEvent:input_type -> kandev.plugin.v1.Event + 5, // 47: kandev.plugin.v1.Plugin.HandleWebhook:input_type -> kandev.plugin.v1.WebhookRequest + 2, // 48: kandev.plugin.v1.Plugin.InvokeAgentTool:input_type -> kandev.plugin.v1.AgentToolRequest + 7, // 49: kandev.plugin.v1.Host.GetState:input_type -> kandev.plugin.v1.GetStateRequest + 9, // 50: kandev.plugin.v1.Host.SetState:input_type -> kandev.plugin.v1.SetStateRequest + 11, // 51: kandev.plugin.v1.Host.DeleteState:input_type -> kandev.plugin.v1.DeleteStateRequest + 13, // 52: kandev.plugin.v1.Host.ListState:input_type -> kandev.plugin.v1.ListStateRequest + 24, // 53: kandev.plugin.v1.Host.RevealSecret:input_type -> kandev.plugin.v1.RevealSecretRequest + 26, // 54: kandev.plugin.v1.Host.EmitEvent:input_type -> kandev.plugin.v1.EmitEventRequest + 18, // 55: kandev.plugin.v1.Host.GetSecret:input_type -> kandev.plugin.v1.GetSecretRequest + 20, // 56: kandev.plugin.v1.Host.SetSecret:input_type -> kandev.plugin.v1.SetSecretRequest + 22, // 57: kandev.plugin.v1.Host.DeleteSecret:input_type -> kandev.plugin.v1.DeleteSecretRequest + 16, // 58: kandev.plugin.v1.Host.GetConfig:input_type -> kandev.plugin.v1.GetConfigRequest + 33, // 59: kandev.plugin.v1.Host.ListTasks:input_type -> kandev.plugin.v1.ListTasksRequest + 35, // 60: kandev.plugin.v1.Host.GetTask:input_type -> kandev.plugin.v1.GetTaskRequest + 38, // 61: kandev.plugin.v1.Host.ListWorkspaces:input_type -> kandev.plugin.v1.ListWorkspacesRequest + 41, // 62: kandev.plugin.v1.Host.ListWorkflows:input_type -> kandev.plugin.v1.ListWorkflowsRequest + 44, // 63: kandev.plugin.v1.Host.ListWorkflowSteps:input_type -> kandev.plugin.v1.ListWorkflowStepsRequest + 47, // 64: kandev.plugin.v1.Host.ListAgentProfiles:input_type -> kandev.plugin.v1.ListAgentProfilesRequest + 50, // 65: kandev.plugin.v1.Host.ListRepositories:input_type -> kandev.plugin.v1.ListRepositoriesRequest + 54, // 66: kandev.plugin.v1.Host.ListSessions:input_type -> kandev.plugin.v1.ListSessionsRequest + 57, // 67: kandev.plugin.v1.Host.ListSessionCodeStats:input_type -> kandev.plugin.v1.ListSessionCodeStatsRequest + 61, // 68: kandev.plugin.v1.Host.ListMessages:input_type -> kandev.plugin.v1.ListMessagesRequest + 63, // 69: kandev.plugin.v1.Host.InvokeUtilityAgent:input_type -> kandev.plugin.v1.InvokeUtilityAgentRequest + 65, // 70: kandev.plugin.v1.Host.CreateTask:input_type -> kandev.plugin.v1.CreateTaskRequest + 67, // 71: kandev.plugin.v1.Host.UpdateTask:input_type -> kandev.plugin.v1.UpdateTaskRequest + 69, // 72: kandev.plugin.v1.Host.SendMessage:input_type -> kandev.plugin.v1.SendMessageRequest + 1, // 73: kandev.plugin.v1.Plugin.DeliverEvent:output_type -> kandev.plugin.v1.EventAck + 6, // 74: kandev.plugin.v1.Plugin.HandleWebhook:output_type -> kandev.plugin.v1.WebhookResponse + 4, // 75: kandev.plugin.v1.Plugin.InvokeAgentTool:output_type -> kandev.plugin.v1.AgentToolResponse + 8, // 76: kandev.plugin.v1.Host.GetState:output_type -> kandev.plugin.v1.GetStateResponse + 10, // 77: kandev.plugin.v1.Host.SetState:output_type -> kandev.plugin.v1.SetStateResponse + 12, // 78: kandev.plugin.v1.Host.DeleteState:output_type -> kandev.plugin.v1.DeleteStateResponse + 14, // 79: kandev.plugin.v1.Host.ListState:output_type -> kandev.plugin.v1.ListStateResponse + 25, // 80: kandev.plugin.v1.Host.RevealSecret:output_type -> kandev.plugin.v1.RevealSecretResponse + 27, // 81: kandev.plugin.v1.Host.EmitEvent:output_type -> kandev.plugin.v1.EmitEventResponse + 19, // 82: kandev.plugin.v1.Host.GetSecret:output_type -> kandev.plugin.v1.GetSecretResponse + 21, // 83: kandev.plugin.v1.Host.SetSecret:output_type -> kandev.plugin.v1.SetSecretResponse + 23, // 84: kandev.plugin.v1.Host.DeleteSecret:output_type -> kandev.plugin.v1.DeleteSecretResponse + 17, // 85: kandev.plugin.v1.Host.GetConfig:output_type -> kandev.plugin.v1.GetConfigResponse + 34, // 86: kandev.plugin.v1.Host.ListTasks:output_type -> kandev.plugin.v1.ListTasksResponse + 36, // 87: kandev.plugin.v1.Host.GetTask:output_type -> kandev.plugin.v1.GetTaskResponse + 39, // 88: kandev.plugin.v1.Host.ListWorkspaces:output_type -> kandev.plugin.v1.ListWorkspacesResponse + 42, // 89: kandev.plugin.v1.Host.ListWorkflows:output_type -> kandev.plugin.v1.ListWorkflowsResponse + 45, // 90: kandev.plugin.v1.Host.ListWorkflowSteps:output_type -> kandev.plugin.v1.ListWorkflowStepsResponse + 48, // 91: kandev.plugin.v1.Host.ListAgentProfiles:output_type -> kandev.plugin.v1.ListAgentProfilesResponse + 51, // 92: kandev.plugin.v1.Host.ListRepositories:output_type -> kandev.plugin.v1.ListRepositoriesResponse + 55, // 93: kandev.plugin.v1.Host.ListSessions:output_type -> kandev.plugin.v1.ListSessionsResponse + 58, // 94: kandev.plugin.v1.Host.ListSessionCodeStats:output_type -> kandev.plugin.v1.ListSessionCodeStatsResponse + 62, // 95: kandev.plugin.v1.Host.ListMessages:output_type -> kandev.plugin.v1.ListMessagesResponse + 64, // 96: kandev.plugin.v1.Host.InvokeUtilityAgent:output_type -> kandev.plugin.v1.InvokeUtilityAgentResponse + 66, // 97: kandev.plugin.v1.Host.CreateTask:output_type -> kandev.plugin.v1.CreateTaskResponse + 68, // 98: kandev.plugin.v1.Host.UpdateTask:output_type -> kandev.plugin.v1.UpdateTaskResponse + 70, // 99: kandev.plugin.v1.Host.SendMessage:output_type -> kandev.plugin.v1.SendMessageResponse + 73, // [73:100] is the sub-list for method output_type + 46, // [46:73] is the sub-list for method input_type + 46, // [46:46] is the sub-list for extension type_name + 46, // [46:46] is the sub-list for extension extendee + 0, // [0:46] is the sub-list for field type_name } func init() { file_kandev_plugin_v1_plugin_proto_init() } @@ -4572,22 +4792,22 @@ func file_kandev_plugin_v1_plugin_proto_init() { if File_kandev_plugin_v1_plugin_proto != nil { return } - file_kandev_plugin_v1_plugin_proto_msgTypes[27].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[29].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[34].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[30].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[32].OneofWrappers = []any{} file_kandev_plugin_v1_plugin_proto_msgTypes[37].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[46].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[40].OneofWrappers = []any{} file_kandev_plugin_v1_plugin_proto_msgTypes[49].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[57].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[62].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[64].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[52].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[60].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[65].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[67].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_kandev_plugin_v1_plugin_proto_rawDesc), len(file_kandev_plugin_v1_plugin_proto_rawDesc)), NumEnums: 0, - NumMessages: 70, + NumMessages: 73, NumExtensions: 0, NumServices: 2, }, diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.proto b/apps/backend/proto/kandev/plugin/v1/plugin.proto index 42f01d14ef..1714216434 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin.proto +++ b/apps/backend/proto/kandev/plugin/v1/plugin.proto @@ -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; diff --git a/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go b/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go index 8efbc6ec4c..1162d92db3 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go +++ b/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go @@ -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", diff --git a/docs/decisions/2026-08-11-plugin-tools-through-kandev-mcp.md b/docs/decisions/2026-08-11-plugin-tools-through-kandev-mcp.md new file mode 100644 index 0000000000..73ff947ec7 --- /dev/null +++ b/docs/decisions/2026-08-11-plugin-tools-through-kandev-mcp.md @@ -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__` 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. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 0d0d1bc6ea..494d7db228 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -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 | diff --git a/docs/plans/plugin-agent-tools/plan.md b/docs/plans/plugin-agent-tools/plan.md new file mode 100644 index 0000000000..90e7eeb889 --- /dev/null +++ b/docs/plans/plugin-agent-tools/plan.md @@ -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. diff --git a/docs/plans/plugin-agent-tools/task-01-manifest-sdk-contract.md b/docs/plans/plugin-agent-tools/task-01-manifest-sdk-contract.md new file mode 100644 index 0000000000..362c1da8d7 --- /dev/null +++ b/docs/plans/plugin-agent-tools/task-01-manifest-sdk-contract.md @@ -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. diff --git a/docs/plans/plugin-agent-tools/task-02-plugin-catalog-invocation.md b/docs/plans/plugin-agent-tools/task-02-plugin-catalog-invocation.md new file mode 100644 index 0000000000..4dabc38e79 --- /dev/null +++ b/docs/plans/plugin-agent-tools/task-02-plugin-catalog-invocation.md @@ -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. diff --git a/docs/plans/plugin-agent-tools/task-03-agentctl-dynamic-registry.md b/docs/plans/plugin-agent-tools/task-03-agentctl-dynamic-registry.md new file mode 100644 index 0000000000..b6db88c366 --- /dev/null +++ b/docs/plans/plugin-agent-tools/task-03-agentctl-dynamic-registry.md @@ -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. diff --git a/docs/plans/plugin-agent-tools/task-04-runtime-propagation-backend-dispatch.md b/docs/plans/plugin-agent-tools/task-04-runtime-propagation-backend-dispatch.md new file mode 100644 index 0000000000..f804ffcb5d --- /dev/null +++ b/docs/plans/plugin-agent-tools/task-04-runtime-propagation-backend-dispatch.md @@ -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. diff --git a/docs/plans/plugin-agent-tools/task-05-end-to-end-fixture.md b/docs/plans/plugin-agent-tools/task-05-end-to-end-fixture.md new file mode 100644 index 0000000000..642485dfb8 --- /dev/null +++ b/docs/plans/plugin-agent-tools/task-05-end-to-end-fixture.md @@ -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. diff --git a/docs/plans/plugin-agent-tools/task-06-authoring-docs.md b/docs/plans/plugin-agent-tools/task-06-authoring-docs.md new file mode 100644 index 0000000000..87caee51a2 --- /dev/null +++ b/docs/plans/plugin-agent-tools/task-06-authoring-docs.md @@ -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. diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md index 15a8b924fa..64e7c9ec7d 100644 --- a/docs/public/plugins-authoring.md +++ b/docs/public/plugins-authoring.md @@ -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__` (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 diff --git a/docs/public/plugins-manifest.md b/docs/public/plugins-manifest.md index 57ad211ace..85eb0a93a4 100644 --- a/docs/public/plugins-manifest.md +++ b/docs/public/plugins-manifest.md @@ -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 > ``** (`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 `` 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`. | diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index f2d22e4cd3..dac6ba252f 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -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 | diff --git a/docs/specs/plugins/agent-tools.md b/docs/specs/plugins/agent-tools.md new file mode 100644 index 0000000000..8ff861351b --- /dev/null +++ b/docs/specs/plugins/agent-tools.md @@ -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__`, 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. diff --git a/docs/specs/plugins/spec.md b/docs/specs/plugins/spec.md index 4218ac783e..d71758c9bb 100644 --- a/docs/specs/plugins/spec.md +++ b/docs/specs/plugins/spec.md @@ -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.