diff --git a/apps/backend/config/prompts/config-context.md b/apps/backend/config/prompts/config-context.md index c6524edf9d..8cf20935d5 100644 --- a/apps/backend/config/prompts/config-context.md +++ b/apps/backend/config/prompts/config-context.md @@ -39,6 +39,13 @@ MCP CONFIG TOOLS: - get_mcp_config_kandev: Get MCP server config for a profile. Required: profile_id. - update_mcp_config_kandev: Update MCP config for a profile. Required: profile_id. Optional: enabled, servers. +SAVED PROMPT TOOLS: +- list_shared_prompts_kandev: List saved prompt summaries without content. +- get_shared_prompt_kandev: Read one saved prompt by exact name. Required: name. +Saved prompt names are case-sensitive. Surrounding whitespace is ignored. Use +the list result to discover names before reading a prompt. These tools are +read-only and do not create, update, delete, or expand saved prompts. + TASK TOOLS: - list_tasks_kandev: List all tasks in a workflow. Required: workflow_id. - move_task_kandev: Move a task to a different workflow step. Required: task_id, workflow_step_id. diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 991d3a3843..5079c5b0f1 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -1655,6 +1655,7 @@ func registerMCPAndDebugRoutes( mcpHandlers.SetConfigDeps(p.services.Workflow, p.agentSettingsController, p.mcpConfigSvc) mcpHandlers.SetClarificationInputPauser(p.orchestratorSvc) mcpHandlers.SetPromptReferenceResolver(p.services.Prompts) + mcpHandlers.SetPromptReader(p.services.Prompts) mcpHandlers.SetTaskStopper(p.orchestratorSvc) mcpHandlers.SetAgentPermissionService(p.orchestratorSvc) mcpHandlers.SetTaskTitleBranchRenamer(p.orchestratorSvc) diff --git a/apps/backend/internal/mcp/handlers/config_prompt_handlers.go b/apps/backend/internal/mcp/handlers/config_prompt_handlers.go new file mode 100644 index 0000000000..d0893fe914 --- /dev/null +++ b/apps/backend/internal/mcp/handlers/config_prompt_handlers.go @@ -0,0 +1,89 @@ +package handlers + +import ( + "context" + "errors" + "strings" + "time" + + promptservice "github.com/kandev/kandev/internal/prompts/service" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +type sharedPromptSummary struct { + Name string `json:"name"` + Builtin bool `json:"builtin"` + ContentBytes int `json:"content_bytes"` +} + +type sharedPromptRead struct { + Name string `json:"name"` + Content string `json:"content"` + Builtin bool `json:"builtin"` + ContentBytes int `json:"content_bytes"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func (h *Handlers) registerPromptHandlers(d *guardedMCPDispatcher) { + d.RegisterFunc(ws.ActionMCPListSharedPrompts, h.handleListSharedPrompts) + d.RegisterFunc(ws.ActionMCPGetSharedPrompt, h.handleGetSharedPrompt) +} + +func (h *Handlers) handleListSharedPrompts(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + prompts, err := h.promptReader.ListPrompts(ctx) + if err != nil { + h.logger.Error("failed to list shared prompts", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list shared prompts", nil) + } + + summaries := make([]sharedPromptSummary, 0, len(prompts)) + for _, prompt := range prompts { + if prompt == nil { + continue + } + summaries = append(summaries, sharedPromptSummary{ + Name: prompt.Name, + Builtin: prompt.Builtin, + ContentBytes: len(prompt.Content), + }) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "shared_prompts": summaries, + "total": len(summaries), + }) +} + +func (h *Handlers) handleGetSharedPrompt(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + name, err := unmarshalStringField(msg.Payload, "name") + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + name = strings.TrimSpace(name) + if name == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "name is required", nil) + } + + prompt, err := h.promptReader.GetPromptByName(ctx, name) + // Keep the nil-result guard for alternate PromptReader implementations. The + // production service maps nil results to ErrPromptNotFound. + if errors.Is(err, promptservice.ErrPromptNotFound) || (err == nil && prompt == nil) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Shared prompt not found", nil) + } + if err != nil { + h.logger.Error("failed to get shared prompt", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get shared prompt", nil) + } + + result := sharedPromptRead{ + Name: prompt.Name, + Content: prompt.Content, + Builtin: prompt.Builtin, + ContentBytes: len(prompt.Content), + CreatedAt: prompt.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: prompt.UpdatedAt.UTC().Format(time.RFC3339), + } + return ws.NewResponse(msg.ID, msg.Action, result) +} diff --git a/apps/backend/internal/mcp/handlers/config_prompt_handlers_test.go b/apps/backend/internal/mcp/handlers/config_prompt_handlers_test.go new file mode 100644 index 0000000000..cf643efe37 --- /dev/null +++ b/apps/backend/internal/mcp/handlers/config_prompt_handlers_test.go @@ -0,0 +1,180 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + promptmodels "github.com/kandev/kandev/internal/prompts/models" + promptservice "github.com/kandev/kandev/internal/prompts/service" + ws "github.com/kandev/kandev/pkg/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type sharedPromptReaderStub struct { + prompts []*promptmodels.Prompt + prompt *promptmodels.Prompt + listErr error + getErr error + requestedName string + requestedCtx context.Context +} + +type sharedPromptContextKey struct{} + +func (s *sharedPromptReaderStub) ListPrompts(ctx context.Context) ([]*promptmodels.Prompt, error) { + s.requestedCtx = ctx + return s.prompts, s.listErr +} + +func (s *sharedPromptReaderStub) GetPromptByName(ctx context.Context, name string) (*promptmodels.Prompt, error) { + s.requestedCtx = ctx + s.requestedName = name + return s.prompt, s.getErr +} + +func TestRegisterHandlers_PromptReaderControlsSavedPromptActions(t *testing.T) { + for _, tc := range []struct { + name string + reader PromptReader + want bool + }{ + {name: "reader available", reader: &sharedPromptReaderStub{}, want: true}, + {name: "reader unavailable", reader: nil, want: false}, + } { + t.Run(tc.name, func(t *testing.T) { + h := &Handlers{logger: testLogger(t)} + h.SetPromptReader(tc.reader) + dispatcher := ws.NewDispatcher() + h.RegisterHandlers(dispatcher) + + assert.Equal(t, tc.want, dispatcher.HasHandler(ws.ActionMCPListSharedPrompts)) + assert.Equal(t, tc.want, dispatcher.HasHandler(ws.ActionMCPGetSharedPrompt)) + }) + } +} + +func TestHandleListSharedPrompts_MapsSummariesWithoutContent(t *testing.T) { + reader := &sharedPromptReaderStub{prompts: []*promptmodels.Prompt{ + {Name: "code-review", Content: "review é", Builtin: true}, + {Name: "custom", Content: "custom prompt", Builtin: false}, + }} + h := &Handlers{promptReader: reader, logger: testLogger(t)} + + resp, err := h.handleListSharedPrompts(context.Background(), makeWSMessage(t, ws.ActionMCPListSharedPrompts, map[string]interface{}{})) + require.NoError(t, err) + require.NotNil(t, resp) + + var payload struct { + SharedPrompts []struct { + Name string `json:"name"` + Builtin bool `json:"builtin"` + ContentBytes int `json:"content_bytes"` + Content string `json:"content"` + } `json:"shared_prompts"` + Total int `json:"total"` + } + require.NoError(t, json.Unmarshal(resp.Payload, &payload)) + require.Len(t, payload.SharedPrompts, 2) + assert.Equal(t, "code-review", payload.SharedPrompts[0].Name) + assert.True(t, payload.SharedPrompts[0].Builtin) + assert.Equal(t, len("review é"), payload.SharedPrompts[0].ContentBytes) + assert.Empty(t, payload.SharedPrompts[0].Content) + assert.Equal(t, "custom", payload.SharedPrompts[1].Name) + assert.False(t, payload.SharedPrompts[1].Builtin) + assert.Equal(t, 2, payload.Total) + assert.NotContains(t, string(resp.Payload), "review é") +} + +func TestHandleGetSharedPrompt_TrimsNameAndOmitsInternalID(t *testing.T) { + createdAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.FixedZone("test", 3600)) + updatedAt := createdAt.Add(time.Hour) + reader := &sharedPromptReaderStub{prompt: &promptmodels.Prompt{ + ID: "internal-prompt-id", + Name: "code-review", + Content: "Review the change.", + Builtin: true, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }} + h := &Handlers{promptReader: reader, logger: testLogger(t)} + + ctx := context.WithValue(context.Background(), sharedPromptContextKey{}, "request-value") + resp, err := h.handleGetSharedPrompt(ctx, makeWSMessage(t, ws.ActionMCPGetSharedPrompt, map[string]interface{}{ + "name": " code-review ", + })) + require.NoError(t, err) + assert.Equal(t, "code-review", reader.requestedName) + assert.Equal(t, "request-value", reader.requestedCtx.Value(sharedPromptContextKey{})) + assert.NotContains(t, string(resp.Payload), "internal-prompt-id") + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Payload, &payload)) + assert.Equal(t, "code-review", payload["name"]) + assert.Equal(t, "Review the change.", payload["content"]) + assert.Equal(t, true, payload["builtin"]) + assert.Equal(t, float64(len("Review the change.")), payload["content_bytes"]) + assert.Equal(t, "2025-01-02T02:04:05Z", payload["created_at"]) + assert.Equal(t, "2025-01-02T03:04:05Z", payload["updated_at"]) + assert.NotContains(t, payload, "id") +} + +func TestHandleGetSharedPrompt_RejectsBlankAndMapsNotFound(t *testing.T) { + reader := &sharedPromptReaderStub{getErr: promptservice.ErrPromptNotFound} + h := &Handlers{promptReader: reader, logger: testLogger(t)} + + blank, err := h.handleGetSharedPrompt(context.Background(), makeWSMessage(t, ws.ActionMCPGetSharedPrompt, map[string]string{ + "name": " \t", + })) + require.NoError(t, err) + assertWSError(t, blank, ws.ErrorCodeValidation) + assert.Empty(t, reader.requestedName) + + missing, err := h.handleGetSharedPrompt(context.Background(), makeWSMessage(t, ws.ActionMCPGetSharedPrompt, map[string]string{ + "name": "missing", + })) + require.NoError(t, err) + assertWSError(t, missing, ws.ErrorCodeNotFound) + assert.NotContains(t, string(missing.Payload), "content") +} + +func TestHandleGetSharedPrompt_MapsNilResultToNotFound(t *testing.T) { + reader := &sharedPromptReaderStub{} + h := &Handlers{promptReader: reader, logger: testLogger(t)} + + resp, err := h.handleGetSharedPrompt(context.Background(), makeWSMessage(t, ws.ActionMCPGetSharedPrompt, map[string]string{ + "name": "missing", + })) + require.NoError(t, err) + assertWSError(t, resp, ws.ErrorCodeNotFound) + assert.NotContains(t, string(resp.Payload), "content") +} + +func TestHandleGetSharedPrompt_HidesRepositoryErrors(t *testing.T) { + reader := &sharedPromptReaderStub{ + prompt: &promptmodels.Prompt{Content: "secret prompt content"}, + getErr: errors.New("database details"), + } + h := &Handlers{promptReader: reader, logger: testLogger(t)} + + resp, err := h.handleGetSharedPrompt(context.Background(), makeWSMessage(t, ws.ActionMCPGetSharedPrompt, map[string]string{ + "name": "code-review", + })) + require.NoError(t, err) + assertWSError(t, resp, ws.ErrorCodeInternalError) + assert.NotContains(t, string(resp.Payload), "database details") + assert.NotContains(t, string(resp.Payload), "secret prompt content") +} + +func TestHandleSharedPrompts_HidesRepositoryErrors(t *testing.T) { + reader := &sharedPromptReaderStub{listErr: errors.New("database details")} + h := &Handlers{promptReader: reader, logger: testLogger(t)} + + resp, err := h.handleListSharedPrompts(context.Background(), makeWSMessage(t, ws.ActionMCPListSharedPrompts, map[string]interface{}{})) + require.NoError(t, err) + assertWSError(t, resp, ws.ErrorCodeInternalError) + assert.NotContains(t, string(resp.Payload), "database details") +} diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 415eadc84e..4adef7a702 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -26,6 +26,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/orchestrator/messagequeue" "github.com/kandev/kandev/internal/plugins" + promptmodels "github.com/kandev/kandev/internal/prompts/models" promptservice "github.com/kandev/kandev/internal/prompts/service" "github.com/kandev/kandev/internal/steptelemetry" "github.com/kandev/kandev/internal/sysprompt" @@ -238,6 +239,13 @@ type PromptReferenceResolver interface { ResolvePromptReferences(ctx context.Context, content string) ([]promptservice.PromptReferenceExpansion, error) } +// PromptReader provides the read-only saved prompt access exposed by +// configuration and external MCP surfaces. +type PromptReader interface { + ListPrompts(ctx context.Context) ([]*promptmodels.Prompt, error) + GetPromptByName(ctx context.Context, name string) (*promptmodels.Prompt, error) +} + // UserSettingsProvider supplies the single portable preference used when an // MCP-created task omits agent_profile_id. type UserSettingsProvider interface { @@ -263,6 +271,7 @@ type Handlers struct { stopTaskGetter func(context.Context, string) (*models.Task, error) messageQueue MessageQueuer promptResolver PromptReferenceResolver + promptReader PromptReader userSettingsProvider UserSettingsProvider logger *logger.Logger @@ -359,6 +368,11 @@ func (h *Handlers) SetPromptReferenceResolver(resolver PromptReferenceResolver) h.promptResolver = resolver } +// SetPromptReader wires read-only saved prompt access into config-mode MCP. +func (h *Handlers) SetPromptReader(reader PromptReader) { + h.promptReader = reader +} + // SetTaskStopper wires the orchestrator-owned halt operation. func (h *Handlers) SetTaskStopper(stopper TaskStopper) { h.taskStopper = stopper @@ -485,6 +499,9 @@ func (h *Handlers) registerTaskQuestionHandlers(d *guardedMCPDispatcher) { } func (h *Handlers) registerConfigModeHandlers(d *guardedMCPDispatcher) { + if h.promptReader != nil { + h.registerPromptHandlers(d) + } if h.workflowSvc != nil { h.registerWorkflowHandlers(d) } diff --git a/apps/backend/internal/mcp/server/config_handlers.go b/apps/backend/internal/mcp/server/config_handlers.go index 816b8cbfe8..7cb0c0467f 100644 --- a/apps/backend/internal/mcp/server/config_handlers.go +++ b/apps/backend/internal/mcp/server/config_handlers.go @@ -222,6 +222,53 @@ func (s *Server) registerConfigMcpTools() { ) } +// --- Saved prompt tools --- + +func (s *Server) registerConfigPromptTools() { + listTool := mcp.NewToolWithRawSchema( + "list_shared_prompts_kandev", + "List saved prompts without their content. Each summary includes the prompt name, whether it is built in, and its UTF-8 content size.", + json.RawMessage(`{"type":"object","properties":{}}`), + ) + // NewToolWithRawSchema does not accept option functions, so set annotations directly. + listTool.Annotations.ReadOnlyHint = mcp.ToBoolPtr(true) + listTool.Annotations.DestructiveHint = mcp.ToBoolPtr(false) + listTool.Annotations.IdempotentHint = mcp.ToBoolPtr(true) + listTool.Annotations.OpenWorldHint = mcp.ToBoolPtr(false) + s.mcpServer.AddTool( + listTool, + s.wrapHandler("list_shared_prompts_kandev", s.listSharedPromptsHandler()), + ) + + s.mcpServer.AddTool( + mcp.NewTool("get_shared_prompt_kandev", + mcp.WithDescription("Read one saved prompt by its exact, case-sensitive name. Surrounding whitespace is ignored."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(false), + mcp.WithString("name", mcp.Required(), mcp.MinLength(1), mcp.Description("Saved prompt name.")), + ), + s.wrapHandler("get_shared_prompt_kandev", s.getSharedPromptHandler()), + ) +} + +func (s *Server) listSharedPromptsHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return s.forwardToBackend(ctx, ws.ActionMCPListSharedPrompts, nil) + } +} + +func (s *Server) getSharedPromptHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name := strings.TrimSpace(req.GetString("name", "")) + if name == "" { + return mcp.NewToolResultError("name is required"), nil + } + return s.forwardToBackend(ctx, ws.ActionMCPGetSharedPrompt, map[string]interface{}{"name": name}) + } +} + // --- Executor config tools --- func (s *Server) registerConfigExecutorTools() { diff --git a/apps/backend/internal/mcp/server/config_handlers_test.go b/apps/backend/internal/mcp/server/config_handlers_test.go index 7bf98d319d..e8d3c9476d 100644 --- a/apps/backend/internal/mcp/server/config_handlers_test.go +++ b/apps/backend/internal/mcp/server/config_handlers_test.go @@ -98,6 +98,8 @@ func TestActionConstants_MatchWebSocketActions(t *testing.T) { assert.Equal(t, "mcp.delete_agent_profile", ws.ActionMCPDeleteAgentProfile) assert.Equal(t, "mcp.get_mcp_config", ws.ActionMCPGetMcpConfig) assert.Equal(t, "mcp.update_mcp_config", ws.ActionMCPUpdateMcpConfig) + assert.Equal(t, "mcp.list_shared_prompts", ws.ActionMCPListSharedPrompts) + assert.Equal(t, "mcp.get_shared_prompt", ws.ActionMCPGetSharedPrompt) assert.Equal(t, "mcp.list_executors", ws.ActionMCPListExecutors) assert.Equal(t, "mcp.list_executor_profiles", ws.ActionMCPListExecutorProfiles) assert.Equal(t, "mcp.create_executor_profile", ws.ActionMCPCreateExecutorProfile) diff --git a/apps/backend/internal/mcp/server/config_prompt_handlers_test.go b/apps/backend/internal/mcp/server/config_prompt_handlers_test.go new file mode 100644 index 0000000000..fcf3a47770 --- /dev/null +++ b/apps/backend/internal/mcp/server/config_prompt_handlers_test.go @@ -0,0 +1,96 @@ +package mcp + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSharedPromptTools_AreOnlyRegisteredForConfigAndExternalModes(t *testing.T) { + for _, tc := range []struct { + name string + mode string + available bool + }{ + {name: "config", mode: ModeConfig, available: true}, + {name: "external", mode: ModeExternal, available: true}, + {name: "task", mode: ModeTask, available: false}, + {name: "office", mode: ModeOffice, available: false}, + {name: "automation", mode: ModeAutomation, available: false}, + } { + t.Run(tc.name, func(t *testing.T) { + s := New(&testBackend{}, "test-session", "test-task", 10005, newTestLogger(t), "", false, tc.mode) + tools := s.mcpServer.ListTools() + if tc.available { + assert.Contains(t, tools, "list_shared_prompts_kandev") + assert.Contains(t, tools, "get_shared_prompt_kandev") + return + } + assert.NotContains(t, tools, "list_shared_prompts_kandev") + assert.NotContains(t, tools, "get_shared_prompt_kandev") + }) + } +} + +func TestSharedPromptTools_ExposeReadOnlySchemas(t *testing.T) { + s := newTestServer(t, &testBackend{}) + + listTool, ok := s.mcpServer.ListTools()["list_shared_prompts_kandev"] + require.True(t, ok) + getTool, ok := s.mcpServer.ListTools()["get_shared_prompt_kandev"] + require.True(t, ok) + + assert.Empty(t, rawToolInputProperties(t, s, "list_shared_prompts_kandev")) + assert.Contains(t, toolInputProperties(t, s, "get_shared_prompt_kandev"), "name") + assert.Contains(t, listTool.Tool.Description, "saved prompts") + assert.Contains(t, getTool.Tool.Description, "saved prompt") + assert.True(t, *listTool.Tool.Annotations.ReadOnlyHint) + assert.False(t, *listTool.Tool.Annotations.DestructiveHint) + assert.True(t, *listTool.Tool.Annotations.IdempotentHint) + assert.False(t, *listTool.Tool.Annotations.OpenWorldHint) + assert.True(t, *getTool.Tool.Annotations.ReadOnlyHint) + assert.False(t, *getTool.Tool.Annotations.DestructiveHint) + assert.True(t, *getTool.Tool.Annotations.IdempotentHint) + assert.False(t, *getTool.Tool.Annotations.OpenWorldHint) +} + +func rawToolInputProperties(t *testing.T, s *Server, toolName string) map[string]interface{} { + t.Helper() + toolsMap := s.mcpServer.ListTools() + st, ok := toolsMap[toolName] + require.True(t, ok, "tool %q not registered", toolName) + + data, err := json.Marshal(st.Tool) + require.NoError(t, err) + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal(data, &parsed)) + schema, ok := parsed["inputSchema"].(map[string]interface{}) + require.True(t, ok, "tool schema should have inputSchema") + props, ok := schema["properties"].(map[string]interface{}) + require.True(t, ok, "schema should have properties") + return props +} + +func TestSharedPromptTools_ForwardRequestsAndRejectBlankName(t *testing.T) { + backend := &testBackend{response: map[string]interface{}{ + "shared_prompts": []interface{}{}, + "total": float64(0), + }} + s := newTestServer(t, backend) + + listResult := callTool(t, s, "list_shared_prompts_kandev", map[string]interface{}{}) + require.False(t, listResult.IsError) + require.Equal(t, "mcp.list_shared_prompts", backend.lastAction) + + getResult := callTool(t, s, "get_shared_prompt_kandev", map[string]interface{}{"name": " code-review "}) + require.False(t, getResult.IsError) + require.Equal(t, "mcp.get_shared_prompt", backend.lastAction) + payload, ok := backend.lastPayload.(map[string]interface{}) + require.True(t, ok) + require.Equal(t, "code-review", payload["name"]) + + blankResult := callTool(t, s, "get_shared_prompt_kandev", map[string]interface{}{"name": " \t"}) + assert.True(t, blankResult.IsError) +} diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 6c7cebd3fe..3f07f22e75 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -905,6 +905,7 @@ func (s *Server) profileToolGroups() []profileToolGroup { {name: "configuration-workflows", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigWorkflowTools() }}, {name: "configuration-agents", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigAgentTools() }}, {name: "configuration-mcp", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigMcpTools() }}, + {name: "configuration-prompts", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigPromptTools() }}, {name: "configuration-executors", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigExecutorTools() }}, {name: "configuration-tasks", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigTaskTools() }}, {name: "external-create-task", enabled: external, register: func(s *Server) { s.registerCreateTaskTool() }}, diff --git a/apps/backend/internal/mcp/server/server_test.go b/apps/backend/internal/mcp/server/server_test.go index 5253ae5f4d..0c07b568f9 100644 --- a/apps/backend/internal/mcp/server/server_test.go +++ b/apps/backend/internal/mcp/server/server_test.go @@ -945,9 +945,9 @@ func TestServerModeConfig_ToolCount(t *testing.T) { s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeConfig) tools := getRegisteredToolNames(s) - // 13 workflow (incl. list_repositories + import_workflow + export_workflow) + 4 agent + 4 mcp + 5 executor + 7 task (incl. list_task_sessions) + 1 interaction = 34 + // 13 workflow (incl. list_repositories + import_workflow + export_workflow) + 4 agent + 4 mcp + 2 saved prompts + 5 executor + 7 task (incl. list_task_sessions) + 1 interaction = 36 assert.NotContains(t, tools, "step_complete_kandev", "step_complete_kandev requires a live task session; must NOT register in config mode") - assert.Equal(t, 34, len(tools)) + assert.Equal(t, 36, len(tools)) } func TestServerModeConfig_ToolDescriptions(t *testing.T) { @@ -1121,9 +1121,9 @@ func TestServerModeExternal_ToolCount(t *testing.T) { s := New(backend, "", "", 0, log, "", true, ModeExternal) tools := getRegisteredToolNames(s) - // 13 workflow (incl. list_repositories + import_workflow + export_workflow) + 4 agent + 4 mcp + 5 executor + 7 task (incl. list_task_sessions) + 1 create_task + 2 task-dependency + 2 question-answering (list_pending_questions + answer_question) + 2 agent permission (list_pending_agent_permissions + resolve_agent_permission) = 40. + // 13 workflow (incl. list_repositories + import_workflow + export_workflow) + 4 agent + 4 mcp + 2 saved prompts + 5 executor + 7 task (incl. list_task_sessions) + 1 create_task + 2 task-dependency + 2 question-answering (list_pending_questions + answer_question) + 2 agent permission (list_pending_agent_permissions + resolve_agent_permission) = 42. // add_branch_to_task_kandev is task-mode only — external coding agents have no live session to attach a worktree to. - assert.Equal(t, 40, len(tools)) + assert.Equal(t, 42, len(tools)) assert.NotContains(t, tools, "add_branch_to_task_kandev") } diff --git a/apps/backend/internal/prompts/service/service.go b/apps/backend/internal/prompts/service/service.go index 6d1218c0ba..dedb50c937 100644 --- a/apps/backend/internal/prompts/service/service.go +++ b/apps/backend/internal/prompts/service/service.go @@ -36,6 +36,24 @@ func (s *Service) ListPrompts(ctx context.Context) ([]*models.Prompt, error) { return s.repo.ListPrompts(ctx) } +// GetPromptByName returns one saved prompt by its exact name after trimming +// surrounding whitespace. Missing or blank names map to ErrPromptNotFound so +// callers do not need to depend on the repository's sql.ErrNoRows result. +func (s *Service) GetPromptByName(ctx context.Context, name string) (*models.Prompt, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, ErrPromptNotFound + } + prompt, err := s.repo.GetPromptByName(ctx, name) + if errors.Is(err, sql.ErrNoRows) || (err == nil && prompt == nil) { + return nil, ErrPromptNotFound + } + if err != nil { + return nil, err + } + return prompt, nil +} + func (s *Service) CreatePrompt(ctx context.Context, name, content string) (*models.Prompt, error) { name = strings.TrimSpace(name) content = strings.TrimSpace(content) diff --git a/apps/backend/internal/prompts/service/shared_prompt_test.go b/apps/backend/internal/prompts/service/shared_prompt_test.go new file mode 100644 index 0000000000..233cf2a888 --- /dev/null +++ b/apps/backend/internal/prompts/service/shared_prompt_test.go @@ -0,0 +1,24 @@ +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestService_GetPromptByName_TrimsNameAndMapsMissingRows(t *testing.T) { + svc, cleanup := createService(t) + t.Cleanup(cleanup) + + prompt, err := svc.GetPromptByName(context.Background(), " code-review ") + require.NoError(t, err) + require.NotNil(t, prompt) + require.Equal(t, "code-review", prompt.Name) + + _, err = svc.GetPromptByName(context.Background(), " missing-prompt ") + require.ErrorIs(t, err, ErrPromptNotFound) + + _, err = svc.GetPromptByName(context.Background(), " CODE-REVIEW ") + require.ErrorIs(t, err, ErrPromptNotFound) +} diff --git a/apps/backend/internal/sysprompt/sysprompt_test.go b/apps/backend/internal/sysprompt/sysprompt_test.go index e3f82e70d6..631fee2fd2 100644 --- a/apps/backend/internal/sysprompt/sysprompt_test.go +++ b/apps/backend/internal/sysprompt/sysprompt_test.go @@ -43,6 +43,8 @@ func TestConfigContext_ContainsAllTools(t *testing.T) { "update_agent_profile_kandev", "get_mcp_config_kandev", "update_mcp_config_kandev", + "list_shared_prompts_kandev", + "get_shared_prompt_kandev", "list_tasks_kandev", "move_task_kandev", "delete_task_kandev", @@ -61,6 +63,7 @@ func TestConfigContext_ContainsSections(t *testing.T) { assert.Contains(t, ConfigContext(), "AGENT TOOLS:") assert.Contains(t, ConfigContext(), "EXECUTOR PROFILE TOOLS:") assert.Contains(t, ConfigContext(), "MCP CONFIG TOOLS:") + assert.Contains(t, ConfigContext(), "SAVED PROMPT TOOLS:") assert.Contains(t, ConfigContext(), "TASK TOOLS:") assert.Contains(t, ConfigContext(), "INTERACTION:") assert.Contains(t, ConfigContext(), "EXAMPLE REQUESTS") diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index aa089227b9..21d414c802 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -473,6 +473,8 @@ const ( ActionMCPDeleteAgentProfile = "mcp.delete_agent_profile" ActionMCPGetMcpConfig = "mcp.get_mcp_config" ActionMCPUpdateMcpConfig = "mcp.update_mcp_config" + ActionMCPListSharedPrompts = "mcp.list_shared_prompts" + ActionMCPGetSharedPrompt = "mcp.get_shared_prompt" ActionMCPListExecutors = "mcp.list_executors" ActionMCPListExecutorProfiles = "mcp.list_executor_profiles" diff --git a/docs/plans/shared-prompts-mcp/plan.md b/docs/plans/shared-prompts-mcp/plan.md new file mode 100644 index 0000000000..5bd73eb038 --- /dev/null +++ b/docs/plans/shared-prompts-mcp/plan.md @@ -0,0 +1,122 @@ +--- +created: 2026-08-28 +status: done +requirements: + - REQ-INTEGRATIONS-EXTERNAL-MCP-002 +system_design: + - ../../specs/integrations/system-design/external-mcp-shared-prompts.md +legacy_specs: [] +--- + +# Implementation Plan: Shared Prompts MCP + +## Overview + +Add two read-only saved prompt tools to configuration and external MCP. The +backend tool work comes first because the public reference must describe the +final live contract. + +## Scope + +### In scope + +- List saved prompt summaries without content. +- Read one saved prompt by its exact name. +- Register both tools only on configuration and external MCP surfaces. +- Use the current prompt service, repository order, authentication, and backend + bridge. +- Update the configuration-agent context and public MCP reference. + +### Out of scope + +- Saved prompt create, update, or delete tools. +- Automatic expansion in workflow-step list results. +- Prompt storage, ownership, or reference-expansion changes. +- Frontend behavior or UI changes. + +## Technical approach + +### Prompt read boundary + +Add `GetPromptByName` to `internal/prompts/service`. The method trims the name +and maps an absent row to `ErrPromptNotFound`. + +Add a narrow prompt-reader dependency to `internal/mcp/handlers`. Wire the +existing prompt service through `internal/backendapp/helpers.go`. + +### Backend actions and handlers + +Add `ActionMCPListSharedPrompts` and `ActionMCPGetSharedPrompt` in +`pkg/websocket/actions.go`. Register both actions only when the prompt reader is +available. + +Place the backend handlers in `internal/mcp/handlers/config_prompt_handlers.go`. +The list handler returns name, built-in status, and UTF-8 byte size. The get +handler returns the full read result without the internal prompt ID. + +### MCP tool surface + +Add a saved prompt tool group in `internal/mcp/server/config_handlers.go`. +Register it for the same configuration and external predicates as other +configuration groups. + +Use the existing backend forwarding path. Add all four read-only MCP +annotations to both tools. + +Update `config/prompts/config-context.md` with the exact names, inputs, and +read-only behavior. + +### Public documentation + +Update the External MCP reference in `docs/public/automation-and-mcp.md`. Add +the saved prompt tools and change the external tool count from 40 to 42. + +Update the Configuration Chat text in `docs/public/developer-tools.md`. State +that configuration agents can list and read saved prompts. + +Add both tool names to the MCP coverage list in `docs/public/coverage.json`. +The primary content type remains reference. + +## Tests + +- `AC-INTEGRATIONS-EXTERNAL-MCP-002.1`, `.7`: MCP server surface and tool-count + tests. +- `AC-INTEGRATIONS-EXTERNAL-MCP-002.2`, `.3`, `.4`: backend handler and server + forwarding tests. +- `AC-INTEGRATIONS-EXTERNAL-MCP-002.5`: service, backend, and server error tests. +- `AC-INTEGRATIONS-EXTERNAL-MCP-002.6`: handler wiring and existing transport + context propagation. +- `AC-INTEGRATIONS-EXTERNAL-MCP-002.8`: focused prompt service and resolver tests + remain green without resolver changes. + +## E2E tests + +No browser E2E test is necessary. The change adds a backend MCP contract and +documentation. It does not change rendered UI behavior. + +## Work orders + +- [x] [Task 01: Expose saved prompt reads](task-01-expose-saved-prompt-reads.md) +- [x] [Task 02: Document saved prompt tools](task-02-document-saved-prompt-tools.md) + +## Verification results + +Implemented the two read-only saved prompt tools on configuration and external +MCP surfaces. The service, backend bridge, server catalog, configuration-agent +context, public MCP reference, and coverage map now share the same contract. + +- `go test ./internal/prompts/service ./internal/mcp/handlers ./internal/mcp/server ./internal/sysprompt ./pkg/websocket` passed with 979 tests. +- `go test -race ./internal/prompts/service ./internal/mcp/handlers ./internal/mcp/server` passed with 914 tests. +- `python3 scripts/lint-spec-files.py --all` passed. +- `node --test scripts/validate-public-docs.test.mjs` passed with 61 tests. +- `node scripts/validate-public-docs.mjs` validated 41 published docs pages. +- `git diff --check -- docs/public` passed. + +## Risks + +- Saved prompt content can contain operator conventions. The tool must remain + on authenticated configuration surfaces. +- The list result can expose content if a handler reuses the existing full + prompt DTO. A dedicated summary type prevents that error. +- Tool-count tests and public docs use exact counts. Both must change with the + two new tools. diff --git a/docs/plans/shared-prompts-mcp/task-01-expose-saved-prompt-reads.md b/docs/plans/shared-prompts-mcp/task-01-expose-saved-prompt-reads.md new file mode 100644 index 0000000000..c8fc877cb4 --- /dev/null +++ b/docs/plans/shared-prompts-mcp/task-01-expose-saved-prompt-reads.md @@ -0,0 +1,110 @@ +--- +id: "01-expose-saved-prompt-reads" +title: "Expose saved prompt reads" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-INTEGRATIONS-EXTERNAL-MCP-002 +acceptance_criteria: + - AC-INTEGRATIONS-EXTERNAL-MCP-002.1 + - AC-INTEGRATIONS-EXTERNAL-MCP-002.2 + - AC-INTEGRATIONS-EXTERNAL-MCP-002.3 + - AC-INTEGRATIONS-EXTERNAL-MCP-002.4 + - AC-INTEGRATIONS-EXTERNAL-MCP-002.5 + - AC-INTEGRATIONS-EXTERNAL-MCP-002.6 + - AC-INTEGRATIONS-EXTERNAL-MCP-002.7 + - AC-INTEGRATIONS-EXTERNAL-MCP-002.8 +system_design: + - ../../specs/integrations/system-design/external-mcp-shared-prompts.md +--- + +# Task 01: Expose saved prompt reads + +## Summary + +Add two read-only saved prompt tools to configuration and external MCP. Reuse +the current prompt repository and backend bridge without changing expansion. + +## In scope + +- Add prompt lookup by name to the prompt service. +- Add the list and get backend actions and handlers. +- Add the list and get MCP tools, schemas, annotations, and forwarding. +- Wire the prompt reader into MCP handlers. +- Update the configuration-agent context. +- Add focused service, handler, server, and context tests. + +## Out of scope + +- Prompt mutations over MCP. +- Prompt persistence or ownership changes. +- Workflow-step response changes. +- Public documentation updates. + +## Acceptance + +- Configuration and external MCP list both tools. Other MCP surfaces do not. +- List output omits content. Get output returns full content for an exact name. +- Invalid and unknown names return tool errors without content. + +## Verification + +```bash +go test ./internal/prompts/service ./internal/mcp/handlers ./internal/mcp/server ./internal/sysprompt ./pkg/websocket +``` + +Run this command from `apps/backend`. + +## Files likely touched + +- `apps/backend/internal/prompts/service/service.go` +- `apps/backend/internal/prompts/service/service_test.go` +- `apps/backend/pkg/websocket/actions.go` +- `apps/backend/internal/mcp/handlers/handlers.go` +- `apps/backend/internal/mcp/handlers/config_prompt_handlers.go` +- `apps/backend/internal/mcp/handlers/config_prompt_handlers_test.go` +- `apps/backend/internal/mcp/server/config_handlers.go` +- `apps/backend/internal/mcp/server/config_prompt_handlers_test.go` +- `apps/backend/internal/mcp/server/server.go` +- `apps/backend/internal/mcp/server/server_test.go` +- `apps/backend/internal/backendapp/helpers.go` +- `apps/backend/config/prompts/config-context.md` +- `apps/backend/internal/mcp/server/sysprompt_sync_test.go` + +## Dependencies + +None. + +## Risks + +- Reusing the full HTTP DTO for list output can expose content. +- A missing dependency can leave backend actions unregistered. + +## Parallelism + +`sequential` + +## Inputs + +- `REQ-INTEGRATIONS-EXTERNAL-MCP-002` +- `docs/specs/integrations/system-design/external-mcp-shared-prompts.md` +- Existing workflow export MCP handlers and tests. +- Existing prompt service, repository, and HTTP DTO. + +## Results + +Implemented the saved prompt read boundary. + +- Added trimmed, case-sensitive name lookup with `ErrPromptNotFound` mapping in + the prompt service. +- Added the two WebSocket actions and a narrow prompt-reader dependency for + backend handlers. +- Added content-free list summaries and full name-based reads without internal + prompt IDs. +- Registered both read-only tools only for configuration and external MCP + surfaces, with the existing backend forwarding and request context. +- Added focused service, handler, server, and configuration-context coverage. +- `go test ./internal/prompts/service ./internal/mcp/handlers ./internal/mcp/server ./internal/sysprompt ./pkg/websocket` passed. +- `go test -race ./internal/prompts/service ./internal/mcp/handlers ./internal/mcp/server` passed. diff --git a/docs/plans/shared-prompts-mcp/task-02-document-saved-prompt-tools.md b/docs/plans/shared-prompts-mcp/task-02-document-saved-prompt-tools.md new file mode 100644 index 0000000000..2f14bc61ef --- /dev/null +++ b/docs/plans/shared-prompts-mcp/task-02-document-saved-prompt-tools.md @@ -0,0 +1,90 @@ +--- +id: "02-document-saved-prompt-tools" +title: "Document saved prompt tools" +status: done +wave: 2 +depends_on: + - "01-expose-saved-prompt-reads" +plan: "plan.md" +requirements: + - REQ-INTEGRATIONS-EXTERNAL-MCP-002 +acceptance_criteria: + - AC-INTEGRATIONS-EXTERNAL-MCP-002.1 + - AC-INTEGRATIONS-EXTERNAL-MCP-002.2 + - AC-INTEGRATIONS-EXTERNAL-MCP-002.3 + - AC-INTEGRATIONS-EXTERNAL-MCP-002.4 + - AC-INTEGRATIONS-EXTERNAL-MCP-002.7 +system_design: + - ../../specs/integrations/system-design/external-mcp-shared-prompts.md +--- + +# Task 02: Document saved prompt tools + +## Summary + +Update the public MCP reference after the live tool contract is complete. Add +the two tools to the public coverage map. + +## In scope + +- Update the External MCP tool count and tool groups. +- Document list and get inputs and outputs. +- Update Configuration Chat saved prompt capabilities. +- Add both tools to public coverage data. + +## Out of scope + +- New public pages or navigation changes. +- UI screenshots. +- Saved prompt mutation documentation. + +## Acceptance + +- The public reference matches the live tool catalog and response contract. +- The Configuration Chat guide states that saved prompt reads are available. +- Public documentation validation succeeds. + +## Verification + +```bash +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +git diff --check -- docs/public +``` + +Run these commands from the repository root. + +## Files likely touched + +- `docs/public/automation-and-mcp.md` +- `docs/public/developer-tools.md` +- `docs/public/coverage.json` + +## Dependencies + +- Task 01 must define the final live tool contract. + +## Risks + +- An incorrect exact tool count can make the public reference stale. + +## Parallelism + +`sequential` + +## Inputs + +- `REQ-INTEGRATIONS-EXTERNAL-MCP-002` +- `docs/specs/integrations/system-design/external-mcp-shared-prompts.md` +- The implemented tool schemas and result shapes from Task 01. + +## Results + +Updated the External MCP reference with the 42-tool catalog, saved prompt +request and response examples, exact-name behavior, and read-only limits. +Updated Configuration Chat capability text and assigned both tools in the +public coverage map. + +- `node --test scripts/validate-public-docs.test.mjs` passed with 61 tests. +- `node scripts/validate-public-docs.mjs` validated 41 published docs pages. +- `git diff --check -- docs/public` passed. diff --git a/docs/public/automation-and-mcp.md b/docs/public/automation-and-mcp.md index dfc2f15ff5..08abfac1ae 100644 --- a/docs/public/automation-and-mcp.md +++ b/docs/public/automation-and-mcp.md @@ -686,15 +686,41 @@ http://127.0.0.1:/mcp SSE compatibility uses `/mcp/sse` with messages sent to `/mcp/message`. A reverse proxy must support long-lived streaming connections. -External MCP exposes 40 tools in these groups: +External MCP exposes 42 tools in these groups: - workspace/workflow configuration: list workspaces, workflows, repositories, and workflow steps; create, update, delete, import, or export workflows; create, update, delete, or reorder steps; - agents and profiles: list/update agents; create/delete profiles; list/update profiles; get/update profile MCP configuration; - executors: list executors and profiles; create, update, or delete executor profiles; +- saved prompts: list prompt summaries without content or read one prompt by its exact, case-sensitive name; saved prompt tools are read-only; - tasks: list, create, move, delete, archive, or update task state; list a task's sessions; read task conversation; discover or answer pending clarification questions; and discover or resolve live agent permission requests. `export_workflow_kandev` takes `workflow_id` and returns one version 1 `kandev_workflow` JSON document. It omits instance IDs and timestamps. Pass its JSON text unchanged as `document` to `import_workflow_kandev` when it is within the existing 1 MiB import limit. +### Read a saved prompt + +Use `list_shared_prompts_kandev` without arguments to discover saved prompt names. The result +contains summaries only, so it does not include prompt content: + +```json +{ + "shared_prompts": [ + { "name": "code-review", "builtin": true, "content_bytes": 1234 } + ], + "total": 1 +} +``` + +Use `get_shared_prompt_kandev` with one saved prompt name to read its full content: + +```json +{ "name": "code-review" } +``` + +Names are case-sensitive. Kandev trims surrounding whitespace before lookup. The result contains +`name`, `content`, `builtin`, `content_bytes`, `created_at`, and `updated_at`; it does not expose the +internal prompt ID. An empty or unknown name returns an error without prompt content. These tools +only read saved prompts. They do not create, update, delete, or expand `@name` references. + ### Answer a pending clarification question Use `list_pending_questions_kandev` when an external client needs to discover clarification diff --git a/docs/public/coverage.json b/docs/public/coverage.json index eaf01b345d..91e4a7c299 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -361,7 +361,12 @@ "apps/backend/internal/mcp/server/server_test.go" ], "settingsRoutes": ["/settings/automations", "/settings/external-mcp"], - "mcpTools": ["get_mcp_config_kandev", "update_mcp_config_kandev"] + "mcpTools": [ + "get_mcp_config_kandev", + "update_mcp_config_kandev", + "list_shared_prompts_kandev", + "get_shared_prompt_kandev" + ] }, { "id": "personal-settings-and-secrets", diff --git a/docs/public/developer-tools.md b/docs/public/developer-tools.md index 501ecf7a03..6506a459e4 100644 --- a/docs/public/developer-tools.md +++ b/docs/public/developer-tools.md @@ -87,7 +87,7 @@ The same settings page configures the **Configuration Chat Agent** for each work Open Configuration Chat from the floating chat button on Settings pages, turn on **Configuration chat** while creating a Quick Chat, or run **Configuration Chat** from the `Cmd/Ctrl+K` command menu. A workspace currently has one configuration conversation. The Settings panel shows that conversation without tabs; **Open in Quick Chat** moves the same setup or session into the larger tabbed dialog without copying it. -Configuration Chat uses a repository-less ephemeral task. Its configuration-mode MCP can inspect and change workflows, agent profiles, and MCP configuration. The selected profile's model, credentials, permissions, and external MCP settings apply. Review requested configuration mutations before approving them. +Configuration Chat uses a repository-less ephemeral task. Its configuration-mode MCP can inspect and change workflows, agent profiles, and MCP configuration, and can list and read saved prompts by exact name. The selected profile's model, credentials, permissions, and external MCP settings apply. Review requested configuration mutations before approving them. Closing the floating Settings panel preserves the conversation. To delete it, open it in Quick Chat, close its tab, and confirm deletion. Configuration tasks are excluded from the seven-day Quick Chat sweeper and remain available until explicitly deleted or their workspace is deleted. diff --git a/docs/specs/integrations/README.md b/docs/specs/integrations/README.md index 47aa6415f8..68964286ef 100644 --- a/docs/specs/integrations/README.md +++ b/docs/specs/integrations/README.md @@ -60,6 +60,7 @@ outcomes that expose those contracts. +- [External MCP Saved Prompt Reads](system-design/external-mcp-shared-prompts.md) - [Azure DevOps Integration System Design Part 1](system-design/azure-devops-integration-01.md) - [Azure DevOps Integration System Design Part 2](system-design/azure-devops-integration-02.md) - [Bitbucket Connector Plugin System Design Part 1](system-design/bitbucket-plugin-01.md) diff --git a/docs/specs/integrations/requirements/external-mcp.md b/docs/specs/integrations/requirements/external-mcp.md index a23e5e6e18..ae5586ae8a 100644 --- a/docs/specs/integrations/requirements/external-mcp.md +++ b/docs/specs/integrations/requirements/external-mcp.md @@ -28,6 +28,34 @@ Users want to operate on Kandev workspaces, workflows, agents, and tasks from co - **AC-INTEGRATIONS-EXTERNAL-MCP-001.7:** The Settings UI shows the URL and ready-to-paste config snippets for popular agents. - **AC-INTEGRATIONS-EXTERNAL-MCP-001.8:** The existing per-`agentctl` MCP behavior is unchanged — the same tool definitions back both endpoints. +### REQ-INTEGRATIONS-EXTERNAL-MCP-002: Saved prompt reads + +**Intent:** Configuration agents need the saved prompt content that workflow +steps reference. This content lets an agent audit the complete effective prompt +without operator copy and paste. + +#### Acceptance criteria + +- **AC-INTEGRATIONS-EXTERNAL-MCP-002.1:** When a configuration or external MCP + client lists tools, the catalog shall include `list_shared_prompts_kandev` + and `get_shared_prompt_kandev`. +- **AC-INTEGRATIONS-EXTERNAL-MCP-002.2:** When a client calls + `list_shared_prompts_kandev`, the result shall enumerate the saved prompts + without their content. +- **AC-INTEGRATIONS-EXTERNAL-MCP-002.3:** Each prompt summary shall include the + exact name, built-in status, and content size in bytes. +- **AC-INTEGRATIONS-EXTERNAL-MCP-002.4:** When a client calls + `get_shared_prompt_kandev` with an exact saved prompt name, the result shall + include the full content and prompt metadata. +- **AC-INTEGRATIONS-EXTERNAL-MCP-002.5:** When the name is empty or unknown, the + tool shall return an error without prompt content. +- **AC-INTEGRATIONS-EXTERNAL-MCP-002.6:** The tools shall use the authenticated + MCP request context and the saved prompt collection for that Kandev instance. +- **AC-INTEGRATIONS-EXTERNAL-MCP-002.7:** Task, Office, and automation MCP + catalogs shall not include the saved prompt tools. +- **AC-INTEGRATIONS-EXTERNAL-MCP-002.8:** Saved prompt reads shall not change + prompt content or the current `@name` expansion behavior. + ## Migrated source detail ## Why @@ -100,6 +128,9 @@ The tool does not return a workflow from another user's workspace. Authenticatio - A workspace-level `export_workflows_kandev` batch tool. - Changes to the portable workflow format or the 1 MiB import limit. - Changes to the existing HTTP export endpoints or workflow export UI. +- Saved prompt create, update, or delete tools over MCP. +- Automatic expansion of saved prompt references in workflow-step list results. +- Changes to saved prompt persistence, reference matching, or expansion depth. ## Implementation plan diff --git a/docs/specs/integrations/system-design/external-mcp-shared-prompts.md b/docs/specs/integrations/system-design/external-mcp-shared-prompts.md new file mode 100644 index 0000000000..c7f963866b --- /dev/null +++ b/docs/specs/integrations/system-design/external-mcp-shared-prompts.md @@ -0,0 +1,119 @@ +--- +status: draft +system: integrations +requirements: + - REQ-INTEGRATIONS-EXTERNAL-MCP-002 +--- + +# External MCP Saved Prompt Reads System Design + +## Purpose and boundaries + +The integration system owns the tool contract for configuration and external +MCP clients. The prompts package remains the source of saved prompt data and +reference expansion. + +This design adds read access to the existing saved prompt collection. It does +not add storage, mutation tools, or automatic reference expansion. + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| `REQ-INTEGRATIONS-EXTERNAL-MCP-002` | [Tool contract](#tool-contract), [Control flow](#control-flow), [Security](#security) | + +## Components and responsibilities + +- `internal/prompts/service` reads saved prompts through the current repository. + A name lookup trims the input and maps a missing row to + `ErrPromptNotFound`. +- `internal/mcp/handlers` owns the backend actions. A narrow prompt-reader + dependency supplies list and name lookup operations. +- `internal/mcp/server` registers the tools for configuration and external MCP + surfaces. Its handlers forward requests through the existing backend bridge. +- `pkg/websocket/actions.go` owns the two action names used by both MCP + transports. +- `config/prompts/config-context.md` tells the configuration agent when and how + to use the tools. + +## Tool contract + +### `list_shared_prompts_kandev` + +The tool has no input fields. It returns this JSON text shape: + +```json +{ + "shared_prompts": [ + { + "name": "code-review", + "builtin": true, + "content_bytes": 1234 + } + ], + "total": 1 +} +``` + +The result keeps the repository order. Built-in prompts come first. Names are +then in ascending order. The summary does not include prompt content. + +### `get_shared_prompt_kandev` + +The tool requires one non-empty `name` string. The handler trims surrounding +space before lookup. Names remain exact and case-sensitive. + +The result contains `name`, `content`, `builtin`, `content_bytes`, +`created_at`, and `updated_at`. The tool does not return a prompt ID because +the read contract uses the prompt name. + +Both tools use the MCP read-only, non-destructive, idempotent, and closed-world +annotations. + +## Control flow + +1. The MCP server validates the tool input. +2. The server sends `mcp.list_shared_prompts` or `mcp.get_shared_prompt` to the + backend dispatcher. +3. The backend handler calls the prompt-reader dependency with the same + request context. +4. The list handler maps models to summaries and omits content. +5. The get handler maps one model to the full read result. +6. The MCP bridge returns the backend object as indented JSON text. + +The existing prompt resolver does not participate in this flow. Workflow-step +list output also remains unchanged. + +## Failure behavior + +The MCP server rejects a missing or blank `name` before backend lookup. The +backend returns a tool error for an unknown name or a repository error. Error +results do not include prompt content or internal database details. + +## Security + +The tools exist only on configuration and external MCP surfaces. Existing MCP +transport authentication and request-context propagation remain the security +boundary. + +Saved prompts are instance-wide configuration today. The tools return the same +collection that the authenticated caller sees in Settings > Prompts. This +change does not add workspace-level or user-level prompt ownership. + +## Compatibility + +No database migration is necessary. Existing HTTP prompt routes, Settings UI, +and `@name` expansion keep their current contracts. + +The public MCP reference adds the two tools. The external tool count changes +from 40 to 42. + +## Verification strategy + +- Prompt-service tests cover trimmed lookup and missing-name translation. +- Backend-handler tests cover summaries, full reads, missing names, and errors. +- MCP-server tests cover schemas, annotations, forwarding, and surface rules. +- Prompt-context tests keep documented tool names synchronized with the live + catalog. +- Public documentation validation covers the updated MCP reference and coverage + map.