feat: expose saved prompt reads over MCP (#3118)

This commit is contained in:
Carlos Florêncio
2026-08-28 19:19:13 +00:00
committed by GitHub
parent 263fab6342
commit f56fd8e180
23 changed files with 998 additions and 7 deletions
@@ -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.
@@ -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)
@@ -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)
}
@@ -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")
}
@@ -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)
}
@@ -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() {
@@ -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)
@@ -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)
}
@@ -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() }},
@@ -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")
}
@@ -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)
@@ -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)
}
@@ -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")
+2
View File
@@ -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"
+122
View File
@@ -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.
@@ -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.
@@ -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.
+27 -1
View File
@@ -686,15 +686,41 @@ http://127.0.0.1:<backend-port>/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
+6 -1
View File
@@ -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",
+1 -1
View File
@@ -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.
+1
View File
@@ -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)
@@ -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
@@ -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.