From 82084c3c777cc2bd52028cd75b1cc0f7bb0e2487 Mon Sep 17 00:00:00 2001 From: CoderMungan Date: Sat, 14 Mar 2026 04:50:40 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(mcp):=20implement=20MCP=20v0.2=20?= =?UTF-8?q?=E2=80=94=20tools,=20prompts,=20session=20state,=20subscription?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 7 new MCP tools (recall, watch_update, compact, next, check_task_completion, session_event, remind), 5 prompt templates (session-start, add-decision, add-learning, reflect, checkpoint), session state tracking, and resource subscription polling. Security: add ValidateBoundary to toolAdd and toolComplete. Convention: replace all literal "\n" with config.NewlineLF. Docs: update cli/mcp.md with full tool/prompt reference. Site: rebuild with zensical. 38 tests, all passing. Signed-off-by: CoderMungan --- docs/cli/mcp.md | 126 ++++++- internal/config/mcp/mcp.go | 8 + internal/mcp/dispatch.go | 13 +- internal/mcp/doc.go | 35 +- internal/mcp/prompts.go | 248 +++++++++++++ internal/mcp/protocol.go | 60 ++++ internal/mcp/resources.go | 163 +++++++++ internal/mcp/server.go | 26 +- internal/mcp/server_test.go | 690 +++++++++++++++++++++++++++++++++++- internal/mcp/session.go | 59 +++ internal/mcp/tools.go | 622 ++++++++++++++++++++++++++++++++ internal/mcp/types.go | 8 +- site/cli/mcp/index.html | 593 ++++++++++++++++++++++++++++++- 13 files changed, 2634 insertions(+), 17 deletions(-) create mode 100644 internal/mcp/prompts.go create mode 100644 internal/mcp/session.go diff --git a/docs/cli/mcp.md b/docs/cli/mcp.md index 585bf98a..141d0e41 100644 --- a/docs/cli/mcp.md +++ b/docs/cli/mcp.md @@ -108,6 +108,13 @@ has a URI, name, and returns Markdown text. The `agent` resource assembles all non-empty context files into a single Markdown document, ordered by the configured read priority. +### Resource Subscriptions + +Clients can subscribe to resource changes via `resources/subscribe`. +The server polls for file mtime changes (default: 5 seconds) and +emits `notifications/resources/updated` when a subscribed file +changes on disk. + --- ## Tools @@ -119,7 +126,7 @@ JSON arguments and returns text results. Show context health: file count, token estimate, and per-file summary. -**Arguments:** None. +**Arguments:** None. **Read-only.** ### `ctx_add` @@ -149,6 +156,121 @@ Mark a task as done by number or text match. Detect stale or invalid context. Returns violations, warnings, and passed checks. -**Arguments:** None. +**Arguments:** None. **Read-only.** + +### `ctx_recall` + +Query recent AI session history (summaries, decisions, topics). + +| Argument | Type | Required | Description | +|----------|--------|----------|------------------------------------------| +| `limit` | number | No | Max sessions to return (default: 5) | +| `since` | string | No | ISO date filter: sessions after this date (YYYY-MM-DD) | + +**Read-only.** + +### `ctx_watch_update` + +Apply a structured context update to `.context/` files. Supports +task, decision, learning, convention, and complete entry types. +Human confirmation required before calling. + +| Argument | Type | Required | Description | +|----------------|--------|----------|------------------------------------------| +| `type` | string | Yes | Entry type: task, decision, learning, convention, complete | +| `content` | string | Yes | Main content | +| `context` | string | Conditional | Context background (decisions/learnings) | +| `rationale` | string | Conditional | Rationale (decisions only) | +| `consequences` | string | Conditional | Consequences (decisions only) | +| `lesson` | string | Conditional | Lesson learned (learnings only) | +| `application` | string | Conditional | How to apply (learnings only) | + +### `ctx_compact` + +Move completed tasks to the archive section and remove empty +sections from context files. Human confirmation required. + +| Argument | Type | Required | Description | +|-----------|---------|----------|------------------------------------------| +| `archive` | boolean | No | Also write tasks to `.context/archive/` (default: false) | + +### `ctx_next` + +Suggest the next pending task based on priority and position. + +**Arguments:** None. **Read-only.** + +### `ctx_check_task_completion` + +Advisory check: after a write operation, detect if any pending tasks +were silently completed. Returns nudge text if a match is found. + +| Argument | Type | Required | Description | +|-----------------|--------|----------|----------------------------------------| +| `recent_action` | string | No | Brief description of what was just done | + +**Read-only.** + +### `ctx_session_event` + +Signal a session lifecycle event. Type `end` triggers the session-end +persistence ceremony — human confirmation required. + +| Argument | Type | Required | Description | +|----------|--------|----------|------------------------------------------| +| `type` | string | Yes | Event type: start, end | +| `caller` | string | No | Caller identifier (cursor, windsurf, vscode, claude-desktop) | + +### `ctx_remind` + +List pending session-scoped reminders. + +**Arguments:** None. **Read-only.** + +--- + +## Prompts + +Prompts provide pre-built templates for common workflows. Clients +can list available prompts via `prompts/list` and retrieve a +specific prompt via `prompts/get`. + +### `ctx-session-start` + +Load full context at the beginning of a session. Returns all context +files assembled in priority read order with session orientation +instructions. + +### `ctx-add-decision` + +Format an architectural decision entry with all required fields. + +| Argument | Type | Required | Description | +|----------------|--------|----------|--------------------------------| +| `content` | string | Yes | Decision title | +| `context` | string | Yes | Background context | +| `rationale` | string | Yes | Why this decision was made | +| `consequences` | string | Yes | Expected consequences | + +### `ctx-add-learning` + +Format a learning entry with all required fields. + +| Argument | Type | Required | Description | +|---------------|--------|----------|---------------------------------| +| `content` | string | Yes | Learning title | +| `context` | string | Yes | Background context | +| `lesson` | string | Yes | The lesson learned | +| `application` | string | Yes | How to apply this lesson | + +### `ctx-reflect` + +Guide end-of-session reflection. Returns a structured review prompt +covering progress assessment and context update recommendations. + +### `ctx-checkpoint` + +Report session statistics: tool calls made, entries added, and +pending updates queued during the current session. diff --git a/internal/config/mcp/mcp.go b/internal/config/mcp/mcp.go index 7b6273c3..77f39398 100644 --- a/internal/config/mcp/mcp.go +++ b/internal/config/mcp/mcp.go @@ -22,10 +22,18 @@ const ( MCPMethodResourcesList = "resources/list" // MCPMethodResourcesRead is the MCP method for reading a resource. MCPMethodResourcesRead = "resources/read" + // MCPMethodResourcesSubscribe is the MCP method for subscribing to resource changes. + MCPMethodResourcesSubscribe = "resources/subscribe" + // MCPMethodResourcesUnsubscribe is the MCP method for unsubscribing from resource changes. + MCPMethodResourcesUnsubscribe = "resources/unsubscribe" // MCPMethodToolsList is the MCP method for listing tools. MCPMethodToolsList = "tools/list" // MCPMethodToolsCall is the MCP method for calling a tool. MCPMethodToolsCall = "tools/call" + // MCPMethodPromptsList is the MCP method for listing prompts. + MCPMethodPromptsList = "prompts/list" + // MCPMethodPromptsGet is the MCP method for getting a prompt. + MCPMethodPromptsGet = "prompts/get" // MCPJSONRPCVersion is the JSON-RPC protocol version string. MCPJSONRPCVersion = "2.0" // MCPServerName is the server name reported during initialization. diff --git a/internal/mcp/dispatch.go b/internal/mcp/dispatch.go index 3e27c087..ba25306e 100644 --- a/internal/mcp/dispatch.go +++ b/internal/mcp/dispatch.go @@ -61,10 +61,18 @@ func (s *Server) dispatch(req Request) *Response { return s.handleResourcesList(req) case mcp.MCPMethodResourcesRead: return s.handleResourcesRead(req) + case mcp.MCPMethodResourcesSubscribe: + return s.handleResourcesSubscribe(req) + case mcp.MCPMethodResourcesUnsubscribe: + return s.handleResourcesUnsubscribe(req) case mcp.MCPMethodToolsList: return s.handleToolsList(req) case mcp.MCPMethodToolsCall: return s.handleToolsCall(req) + case mcp.MCPMethodPromptsList: + return s.handlePromptsList(req) + case mcp.MCPMethodPromptsGet: + return s.handlePromptsGet(req) default: return s.error(req.ID, errCodeNotFound, fmt.Sprintf( @@ -97,8 +105,9 @@ func (s *Server) handleInitialize(req Request) *Response { result := InitializeResult{ ProtocolVersion: protocolVersion, Capabilities: ServerCaps{ - Resources: &ResourcesCap{}, + Resources: &ResourcesCap{Subscribe: true}, Tools: &ToolsCap{}, + Prompts: &PromptsCap{}, }, ServerInfo: AppInfo{ Name: mcp.MCPServerName, @@ -154,6 +163,8 @@ func (s *Server) error(id json.RawMessage, code int, msg string) *Response { func (s *Server) writeError(id json.RawMessage, code int, msg string) { resp := s.error(id, code, msg) if out, marshalErr := json.Marshal(resp); marshalErr == nil { + s.outMu.Lock() _, _ = s.out.Write(append(out, token.NewlineLF[0])) + s.outMu.Unlock() } } diff --git a/internal/mcp/doc.go b/internal/mcp/doc.go index bd2e4258..1ee5bac3 100644 --- a/internal/mcp/doc.go +++ b/internal/mcp/doc.go @@ -37,10 +37,27 @@ // // Tools expose ctx commands as callable operations: // -// ctx_status → Context health summary -// ctx_add → Add a task, decision, learning, or convention -// ctx_complete → Mark a task as done -// ctx_drift → Detect stale or invalid context +// ctx_status → Context health summary +// ctx_add → Add a task, decision, learning, or convention +// ctx_complete → Mark a task as done +// ctx_drift → Detect stale or invalid context +// ctx_recall → Query past session history +// ctx_watch_update → Apply structured context updates to files +// ctx_compact → Move completed tasks to archive +// ctx_next → Get the next pending task +// ctx_check_task_completion → Nudge when a recent action may complete a task +// ctx_session_event → Signal session start/end lifecycle +// ctx_remind → List active reminders +// +// # Prompts +// +// Prompts provide pre-built templates for common workflows: +// +// ctx-session-start → Load full context at session start +// ctx-add-decision → Format an architectural decision entry +// ctx-add-learning → Format a learning entry +// ctx-reflect → Guide end-of-session reflection +// ctx-checkpoint → Report session statistics // // # Usage // @@ -57,4 +74,14 @@ // - Human authority: tools propose changes through file writes // - Local-first: no network required for core operation // - No telemetry: no data leaves the local machine +// +// # File Organization +// +// - prompts.go: MCP prompt definitions and handlers +// - protocol.go: JSON-RPC 2.0 message types for MCP +// - resources.go: MCP resource definitions, handlers, and subscription poller +// - server.go: Server lifecycle, dispatch, and I/O handling +// - session.go: Per-session advisory state tracking +// - tools.go: MCP tool definitions and handlers +// - types.go: Server struct definition package mcp diff --git a/internal/mcp/prompts.go b/internal/mcp/prompts.go new file mode 100644 index 00000000..27c07a22 --- /dev/null +++ b/internal/mcp/prompts.go @@ -0,0 +1,248 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package mcp + +import ( + "encoding/json" + "fmt" + "strings" + + ctxCfg "github.com/ActiveMemory/ctx/internal/config/ctx" + "github.com/ActiveMemory/ctx/internal/config/token" + "github.com/ActiveMemory/ctx/internal/context" +) + +// promptDefs defines all available MCP prompts. +var promptDefs = []Prompt{ + { + Name: "ctx-session-start", + Description: "Initialize a new session: loads full context and provides orientation", + }, + { + Name: "ctx-add-decision", + Description: "Record an architectural decision with context, rationale, and consequences", + Arguments: []PromptArgument{ + {Name: "content", Description: "Decision title", Required: true}, + {Name: "context", Description: "Background context for the decision", Required: true}, + {Name: "rationale", Description: "Why this decision was made", Required: true}, + {Name: "consequences", Description: "Impact of the decision", Required: true}, + }, + }, + { + Name: "ctx-add-learning", + Description: "Record a lesson learned with context, lesson, and application", + Arguments: []PromptArgument{ + {Name: "content", Description: "Learning title", Required: true}, + {Name: "context", Description: "Background context", Required: true}, + {Name: "lesson", Description: "What was learned", Required: true}, + {Name: "application", Description: "How to apply this lesson", Required: true}, + }, + }, + { + Name: "ctx-reflect", + Description: "Review the current session and capture outstanding learnings and decisions", + }, + { + Name: "ctx-checkpoint", + Description: "Summarize session progress and persist important context before ending", + }, +} + +// handlePromptsList returns all available MCP prompts. +func (s *Server) handlePromptsList(req Request) *Response { + return s.ok(req.ID, PromptListResult{Prompts: promptDefs}) +} + +// handlePromptsGet returns the content of a requested prompt. +func (s *Server) handlePromptsGet(req Request) *Response { + var params GetPromptParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return s.error(req.ID, errCodeInvalidArg, "invalid params") + } + + switch params.Name { + case "ctx-session-start": + return s.promptSessionStart(req.ID) + case "ctx-add-decision": + return s.promptAddDecision(req.ID, params.Arguments) + case "ctx-add-learning": + return s.promptAddLearning(req.ID, params.Arguments) + case "ctx-reflect": + return s.promptReflect(req.ID) + case "ctx-checkpoint": + return s.promptCheckpoint(req.ID) + default: + return s.error(req.ID, errCodeNotFound, + fmt.Sprintf("unknown prompt: %s", params.Name)) + } +} + +// promptSessionStart loads context and provides a session orientation. +func (s *Server) promptSessionStart(id json.RawMessage) *Response { + ctx, err := context.Load(s.contextDir) + if err != nil { + return s.error(id, errCodeInternal, + fmt.Sprintf("failed to load context: %v", err)) + } + + var sb strings.Builder + sb.WriteString("You are starting a new session. Read the following context files carefully.") + sb.WriteString(token.NewlineLF) + sb.WriteString(token.NewlineLF) + + for _, fileName := range ctxCfg.ReadOrder { + f := ctx.File(fileName) + if f == nil || f.IsEmpty { + continue + } + fmt.Fprintf(&sb, "## %s%s%s%s%s", + fileName, token.NewlineLF, token.NewlineLF, + string(f.Content), token.NewlineLF) + } + + sb.WriteString(token.NewlineLF) + sb.WriteString("Remember this context throughout the session. ") + sb.WriteString("Use ctx_add to record decisions and learnings as you work. ") + sb.WriteString("At session end, use ctx-checkpoint to capture outstanding context.") + + return s.ok(id, GetPromptResult{ + Description: "Session initialization with full context load", + Messages: []PromptMessage{ + { + Role: "user", + Content: ToolContent{Type: "text", Text: sb.String()}, + }, + }, + }) +} + +// promptAddDecision formats a decision for recording. +func (s *Server) promptAddDecision( + id json.RawMessage, args map[string]string, +) *Response { + content := args["content"] + ctx := args["context"] + rationale := args["rationale"] + consequences := args["consequences"] + + var sb strings.Builder + sb.WriteString("Record this architectural decision using ctx_add:") + sb.WriteString(token.NewlineLF) + sb.WriteString(token.NewlineLF) + fmt.Fprintf(&sb, "- **Decision**: %s%s", content, token.NewlineLF) + fmt.Fprintf(&sb, "- **Context**: %s%s", ctx, token.NewlineLF) + fmt.Fprintf(&sb, "- **Rationale**: %s%s", rationale, token.NewlineLF) + fmt.Fprintf(&sb, "- **Consequences**: %s%s", consequences, token.NewlineLF) + sb.WriteString(token.NewlineLF) + sb.WriteString("Call ctx_add with type=\"decision\" and all fields above.") + + return s.ok(id, GetPromptResult{ + Description: "Record an architectural decision", + Messages: []PromptMessage{ + { + Role: "user", + Content: ToolContent{Type: "text", Text: sb.String()}, + }, + }, + }) +} + +// promptAddLearning formats a learning for recording. +func (s *Server) promptAddLearning( + id json.RawMessage, args map[string]string, +) *Response { + content := args["content"] + ctx := args["context"] + lesson := args["lesson"] + application := args["application"] + + var sb strings.Builder + sb.WriteString("Record this learning using ctx_add:") + sb.WriteString(token.NewlineLF) + sb.WriteString(token.NewlineLF) + fmt.Fprintf(&sb, "- **Learning**: %s%s", content, token.NewlineLF) + fmt.Fprintf(&sb, "- **Context**: %s%s", ctx, token.NewlineLF) + fmt.Fprintf(&sb, "- **Lesson**: %s%s", lesson, token.NewlineLF) + fmt.Fprintf(&sb, "- **Application**: %s%s", application, token.NewlineLF) + sb.WriteString(token.NewlineLF) + sb.WriteString("Call ctx_add with type=\"learning\" and all fields above.") + + return s.ok(id, GetPromptResult{ + Description: "Record a lesson learned", + Messages: []PromptMessage{ + { + Role: "user", + Content: ToolContent{Type: "text", Text: sb.String()}, + }, + }, + }) +} + +// promptReflect reviews the current session for outstanding items. +func (s *Server) promptReflect(id json.RawMessage) *Response { + var sb strings.Builder + sb.WriteString("Reflect on this session and identify:") + sb.WriteString(token.NewlineLF) + sb.WriteString(token.NewlineLF) + sb.WriteString("1. **Decisions made** — Record each with ctx_add type=\"decision\"") + sb.WriteString(token.NewlineLF) + sb.WriteString("2. **Lessons learned** — Record each with ctx_add type=\"learning\"") + sb.WriteString(token.NewlineLF) + sb.WriteString("3. **Tasks completed** — Mark done with ctx_complete") + sb.WriteString(token.NewlineLF) + sb.WriteString("4. **New tasks identified** — Add with ctx_add type=\"task\"") + sb.WriteString(token.NewlineLF) + sb.WriteString(token.NewlineLF) + sb.WriteString("Review what was discussed and changed. ") + sb.WriteString("Don't let important context slip away.") + + return s.ok(id, GetPromptResult{ + Description: "Review session for outstanding learnings and decisions", + Messages: []PromptMessage{ + { + Role: "user", + Content: ToolContent{Type: "text", Text: sb.String()}, + }, + }, + }) +} + +// promptCheckpoint summarizes progress and prepares for session end. +func (s *Server) promptCheckpoint(id json.RawMessage) *Response { + pending := s.session.pendingCount() + adds := totalAdds(s.session.addsPerformed) + + var sb strings.Builder + sb.WriteString("Session checkpoint. Before ending this session:") + sb.WriteString(token.NewlineLF) + sb.WriteString(token.NewlineLF) + + fmt.Fprintf(&sb, "- Tool calls this session: %d%s", s.session.toolCalls, token.NewlineLF) + fmt.Fprintf(&sb, "- Entries added: %d%s", adds, token.NewlineLF) + fmt.Fprintf(&sb, "- Pending updates: %d%s", pending, token.NewlineLF) + + sb.WriteString(token.NewlineLF) + sb.WriteString("1. Check ctx_status for current context state") + sb.WriteString(token.NewlineLF) + sb.WriteString("2. Record any remaining decisions or learnings") + sb.WriteString(token.NewlineLF) + sb.WriteString("3. Mark completed tasks with ctx_complete") + sb.WriteString(token.NewlineLF) + sb.WriteString("4. Run ctx_compact if needed") + sb.WriteString(token.NewlineLF) + sb.WriteString("5. Call ctx_session_event type=\"end\" when done") + + return s.ok(id, GetPromptResult{ + Description: "Summarize session progress and persist context", + Messages: []PromptMessage{ + { + Role: "user", + Content: ToolContent{Type: "text", Text: sb.String()}, + }, + }, + }) +} diff --git a/internal/mcp/protocol.go b/internal/mcp/protocol.go index d25a976c..ad55d36e 100644 --- a/internal/mcp/protocol.go +++ b/internal/mcp/protocol.go @@ -86,6 +86,7 @@ type InitializeResult struct { type ServerCaps struct { Resources *ResourcesCap `json:"resources,omitempty"` Tools *ToolsCap `json:"tools,omitempty"` + Prompts *PromptsCap `json:"prompts,omitempty"` } // ResourcesCap indicates the server supports resources. @@ -131,6 +132,21 @@ type ReadResourceResult struct { Contents []ResourceContent `json:"contents"` } +// SubscribeParams is sent with resources/subscribe. +type SubscribeParams struct { + URI string `json:"uri"` +} + +// UnsubscribeParams is sent with resources/unsubscribe. +type UnsubscribeParams struct { + URI string `json:"uri"` +} + +// ResourceUpdatedParams is sent with notifications/resources/updated. +type ResourceUpdatedParams struct { + URI string `json:"uri"` +} + // --- Tool types --- // ToolAnnotations provides hints about a tool's behavior. @@ -184,3 +200,47 @@ type CallToolResult struct { Content []ToolContent `json:"content"` IsError bool `json:"isError,omitempty"` } + +// --- Prompt types --- + +// PromptsCap indicates the server supports prompts. +type PromptsCap struct { + ListChanged bool `json:"listChanged,omitempty"` +} + +// Prompt describes a single MCP prompt template. +type Prompt struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Arguments []PromptArgument `json:"arguments,omitempty"` +} + +// PromptArgument describes a single argument for a prompt. +type PromptArgument struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Required bool `json:"required,omitempty"` +} + +// PromptListResult is returned by prompts/list. +type PromptListResult struct { + Prompts []Prompt `json:"prompts"` +} + +// GetPromptParams is sent with prompts/get. +type GetPromptParams struct { + Name string `json:"name"` + Arguments map[string]string `json:"arguments,omitempty"` +} + +// PromptMessage represents a message in a prompt response. +type PromptMessage struct { + Role string `json:"role"` + Content ToolContent `json:"content"` +} + +// GetPromptResult is returned by prompts/get. +type GetPromptResult struct { + Description string `json:"description,omitempty"` + Messages []PromptMessage `json:"messages"` +} diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go index 19ef2d27..fe4aad94 100644 --- a/internal/mcp/resources.go +++ b/internal/mcp/resources.go @@ -9,7 +9,11 @@ package mcp import ( "encoding/json" "fmt" + "os" + "path/filepath" "strings" + "sync" + "time" "github.com/ActiveMemory/ctx/internal/assets" ctxCfg "github.com/ActiveMemory/ctx/internal/config/ctx" @@ -167,3 +171,162 @@ func (s *Server) readAgentPacket( }}, }) } + +// defaultPollInterval is the default interval for resource change polling. +const defaultPollInterval = 5 * time.Second + +// resourcePoller tracks subscribed resources and polls for file changes. +type resourcePoller struct { + mu sync.Mutex + subs map[string]bool // URI → subscribed + mtimes map[string]time.Time // file path → last known mtime + contextDir string + pollStop chan struct{} + notifyFunc func(Notification) // callback to emit notifications +} + +// newResourcePoller creates a poller for the given context directory. +func newResourcePoller(contextDir string, notify func(Notification)) *resourcePoller { + return &resourcePoller{ + subs: make(map[string]bool), + mtimes: make(map[string]time.Time), + contextDir: contextDir, + notifyFunc: notify, + } +} + +// subscribe adds a URI to the watch set and starts polling if needed. +func (p *resourcePoller) subscribe(uri string) { + p.mu.Lock() + defer p.mu.Unlock() + + p.subs[uri] = true + + // Snapshot current mtime for the resource's file. + if fileName := p.uriToFile(uri); fileName != "" { + fpath := filepath.Join(p.contextDir, fileName) + if info, err := os.Stat(fpath); err == nil { + p.mtimes[fpath] = info.ModTime() + } + } + + // Start poller if this is the first subscription. + if len(p.subs) == 1 && p.pollStop == nil { + p.pollStop = make(chan struct{}) + go p.poll() + } +} + +// unsubscribe removes a URI from the watch set and stops polling if empty. +func (p *resourcePoller) unsubscribe(uri string) { + p.mu.Lock() + defer p.mu.Unlock() + + delete(p.subs, uri) + + if len(p.subs) == 0 && p.pollStop != nil { + close(p.pollStop) + p.pollStop = nil + } +} + +// stop shuts down the poller goroutine. +func (p *resourcePoller) stop() { + p.mu.Lock() + defer p.mu.Unlock() + + if p.pollStop != nil { + close(p.pollStop) + p.pollStop = nil + } +} + +// uriToFile maps a resource URI to its context file name. +func (p *resourcePoller) uriToFile(uri string) string { + for _, rm := range resourceTable { + if uri == resourceURI(rm.name) { + return rm.file + } + } + return "" +} + +// poll checks subscribed resources for mtime changes on a fixed interval. +func (p *resourcePoller) poll() { + ticker := time.NewTicker(defaultPollInterval) + defer ticker.Stop() + + for { + select { + case <-p.pollStop: + return + case <-ticker.C: + p.checkChanges() + } + } +} + +// checkChanges compares current mtimes to snapshots and emits notifications. +func (p *resourcePoller) checkChanges() { + p.mu.Lock() + uris := make([]string, 0, len(p.subs)) + for uri := range p.subs { + uris = append(uris, uri) + } + p.mu.Unlock() + + for _, uri := range uris { + fileName := p.uriToFile(uri) + if fileName == "" { + continue + } + fpath := filepath.Join(p.contextDir, fileName) + info, err := os.Stat(fpath) + if err != nil { + continue + } + + p.mu.Lock() + prev, known := p.mtimes[fpath] + if known && info.ModTime().After(prev) { + p.mtimes[fpath] = info.ModTime() + p.mu.Unlock() + p.notifyFunc(Notification{ + JSONRPC: "2.0", + Method: "notifications/resources/updated", + Params: ResourceUpdatedParams{URI: uri}, + }) + } else { + if !known { + p.mtimes[fpath] = info.ModTime() + } + p.mu.Unlock() + } + } +} + +// handleResourcesSubscribe registers a resource for change notifications. +func (s *Server) handleResourcesSubscribe(req Request) *Response { + var params SubscribeParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return s.error(req.ID, errCodeInvalidArg, "invalid params") + } + if params.URI == "" { + return s.error(req.ID, errCodeInvalidArg, "uri is required") + } + s.poller.subscribe(params.URI) + return s.ok(req.ID, struct{}{}) +} + +// handleResourcesUnsubscribe removes a resource from change notifications. +func (s *Server) handleResourcesUnsubscribe(req Request) *Response { + var params UnsubscribeParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return s.error(req.ID, errCodeInvalidArg, "invalid params") + } + if params.URI == "" { + return s.error(req.ID, errCodeInvalidArg, "uri is required") + } + s.poller.unsubscribe(params.URI) + return s.ok(req.ID, struct{}{}) +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index f9d15206..d3512cb1 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -26,13 +26,16 @@ import ( // Returns: // - *Server: a configured MCP server ready to serve func NewServer(contextDir, version string) *Server { - return &Server{ + srv := &Server{ contextDir: contextDir, version: version, tokenBudget: rc.TokenBudget(), out: os.Stdout, in: os.Stdin, + session: newSessionState(contextDir), } + srv.poller = newResourcePoller(contextDir, srv.emitNotification) + return srv } // Serve starts the MCP server, reading from stdin and writing to stdout. @@ -43,6 +46,8 @@ func NewServer(contextDir, version string) *Server { // Returns: // - error: non-nil if an I/O error prevents continued operation func (s *Server) Serve() error { + defer s.poller.stop() + scanner := bufio.NewScanner(s.in) scanner.Buffer(make([]byte, 0, mcp.MCPScanMaxSize), mcp.MCPScanMaxSize) @@ -67,12 +72,25 @@ func (s *Server) Serve() error { ) continue } - if _, writeErr := s.out.Write( - append(out, token.NewlineLF[0]), - ); writeErr != nil { + s.outMu.Lock() + _, writeErr := s.out.Write(append(out, token.NewlineLF[0])) + s.outMu.Unlock() + if writeErr != nil { return writeErr } } return scanner.Err() } + +// emitNotification writes a JSON-RPC notification to stdout. +// Safe to call from any goroutine (e.g., the resource poller). +func (s *Server) emitNotification(n Notification) { + out, err := json.Marshal(n) + if err != nil { + return + } + s.outMu.Lock() + _, _ = s.out.Write(append(out, token.NewlineLF[0])) + s.outMu.Unlock() +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index e707512e..a9ea83eb 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -12,7 +12,9 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" + "time" "github.com/ActiveMemory/ctx/internal/config/ctx" ) @@ -20,6 +22,17 @@ import ( func newTestServer(t *testing.T) (*Server, string) { t.Helper() dir := t.TempDir() + + // Change CWD to the temp dir so ValidateBoundary passes. + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(origDir) }) + contextDir := filepath.Join(dir, ".context") if err := os.MkdirAll(contextDir, 0o755); err != nil { t.Fatalf("mkdir: %v", err) @@ -101,9 +114,15 @@ func TestInitialize(t *testing.T) { if result.Capabilities.Resources == nil { t.Error("expected resources capability") } + if result.Capabilities.Resources != nil && !result.Capabilities.Resources.Subscribe { + t.Error("expected resources subscribe capability") + } if result.Capabilities.Tools == nil { t.Error("expected tools capability") } + if result.Capabilities.Prompts == nil { + t.Error("expected prompts capability") + } } func TestPing(t *testing.T) { @@ -212,14 +231,19 @@ func TestToolsList(t *testing.T) { if err := json.Unmarshal(raw, &result); err != nil { t.Fatalf("unmarshal: %v", err) } - if len(result.Tools) != 4 { - t.Errorf("tool count = %d, want 4", len(result.Tools)) + if len(result.Tools) != 11 { + t.Errorf("tool count = %d, want 11", len(result.Tools)) } names := make(map[string]bool) for _, tool := range result.Tools { names[tool.Name] = true } - for _, want := range []string{"ctx_status", "ctx_add", "ctx_complete", "ctx_drift"} { + for _, want := range []string{ + "ctx_status", "ctx_add", "ctx_complete", "ctx_drift", + "ctx_recall", "ctx_watch_update", "ctx_compact", + "ctx_next", "ctx_check_task_completion", + "ctx_session_event", "ctx_remind", + } { if !names[want] { t.Errorf("missing tool: %s", want) } @@ -441,3 +465,663 @@ func TestParseError(t *testing.T) { t.Errorf("expected parse error, got: %+v", resp.Error) } } + +// --- New tool tests (v0.2) --- + +func TestToolRecall(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_recall", + Arguments: map[string]interface{}{"limit": float64(3)}, + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if result.IsError { + t.Fatalf("unexpected tool error: %s", result.Content[0].Text) + } + // Should return something (either sessions or "No sessions found.") + if len(result.Content) == 0 { + t.Error("expected content in recall response") + } +} + +func TestToolRecallInvalidDate(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_recall", + Arguments: map[string]interface{}{"since": "not-a-date"}, + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !result.IsError { + t.Error("expected tool error for invalid date") + } +} + +func TestToolWatchUpdate(t *testing.T) { + srv, contextDir := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_watch_update", + Arguments: map[string]interface{}{ + "type": "task", + "content": "New MCP task from watch", + }, + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if result.IsError { + t.Fatalf("unexpected tool error: %s", result.Content[0].Text) + } + text := result.Content[0].Text + if !strings.Contains(text, "Wrote task") { + t.Errorf("expected advisory text, got: %s", text) + } + + // Verify the entry was written. + content, err := os.ReadFile(filepath.Join(contextDir, ctx.Task)) + if err != nil { + t.Fatalf("read tasks: %v", err) + } + if !strings.Contains(string(content), "New MCP task from watch") { + t.Errorf("task not found in file: %s", string(content)) + } +} + +func TestToolWatchUpdateDecision(t *testing.T) { + srv, contextDir := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_watch_update", + Arguments: map[string]interface{}{ + "type": "decision", + "content": "Use MCP protocol", + "context": "Need AI tool integration", + "rationale": "Standard protocol", + "consequences": "Must maintain compatibility", + }, + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if result.IsError { + t.Fatalf("unexpected tool error: %s", result.Content[0].Text) + } + + content, err := os.ReadFile(filepath.Join(contextDir, ctx.Decision)) + if err != nil { + t.Fatalf("read decisions: %v", err) + } + if !strings.Contains(string(content), "Use MCP protocol") { + t.Errorf("decision not found in file: %s", string(content)) + } +} + +func TestToolWatchUpdateValidationError(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_watch_update", + Arguments: map[string]interface{}{ + "type": "decision", + "content": "Missing fields", + // Missing context, rationale, consequences. + }, + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !result.IsError { + t.Error("expected validation error for decision missing required fields") + } +} + +func TestToolWatchUpdateComplete(t *testing.T) { + srv, contextDir := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_watch_update", + Arguments: map[string]interface{}{ + "type": "complete", + "content": "1", + }, + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if result.IsError { + t.Fatalf("unexpected tool error: %s", result.Content[0].Text) + } + if !strings.Contains(result.Content[0].Text, "Build MCP server") { + t.Errorf("expected completed task name, got: %s", result.Content[0].Text) + } + + content, err := os.ReadFile(filepath.Join(contextDir, ctx.Task)) + if err != nil { + t.Fatalf("read tasks: %v", err) + } + if !strings.Contains(string(content), "- [x] Build MCP server") { + t.Errorf("task not marked complete: %s", string(content)) + } +} + +func TestToolCompact(t *testing.T) { + srv, contextDir := newTestServer(t) + + // Set up TASKS.md with a completed task and a Completed section. + tasksContent := "# Tasks\n\n- [x] Done task\n- [ ] Pending task\n\n## Completed\n\n" + if err := os.WriteFile( + filepath.Join(contextDir, ctx.Task), + []byte(tasksContent), 0o644, + ); err != nil { + t.Fatalf("write tasks: %v", err) + } + + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_compact", + Arguments: map[string]interface{}{}, + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if result.IsError { + t.Fatalf("unexpected tool error: %s", result.Content[0].Text) + } + text := result.Content[0].Text + if !strings.Contains(text, "Compacted") { + t.Errorf("expected compacted message, got: %s", text) + } + + // Verify task was moved. + content, err := os.ReadFile(filepath.Join(contextDir, ctx.Task)) + if err != nil { + t.Fatalf("read tasks: %v", err) + } + if strings.Contains(string(content), "- [x] Done task\n- [ ] Pending task") { + t.Error("completed task should have been moved to Completed section") + } +} + +func TestToolCompactClean(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_compact", + Arguments: map[string]interface{}{}, + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if result.IsError { + t.Fatalf("unexpected tool error: %s", result.Content[0].Text) + } + // No completed tasks to move — should report clean. + text := result.Content[0].Text + if !strings.Contains(text, "clean") && !strings.Contains(text, "Compacted") { + t.Errorf("expected clean or compacted message, got: %s", text) + } +} + +func TestToolNext(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_next", + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if result.IsError { + t.Fatalf("unexpected tool error: %s", result.Content[0].Text) + } + text := result.Content[0].Text + if !strings.Contains(text, "Build MCP server") { + t.Errorf("expected first pending task, got: %s", text) + } +} + +func TestToolNextAllComplete(t *testing.T) { + srv, contextDir := newTestServer(t) + + tasksContent := "# Tasks\n\n- [x] Done 1\n- [x] Done 2\n" + if err := os.WriteFile( + filepath.Join(contextDir, ctx.Task), + []byte(tasksContent), 0o644, + ); err != nil { + t.Fatalf("write tasks: %v", err) + } + + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_next", + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !strings.Contains(result.Content[0].Text, "All tasks completed") { + t.Errorf("expected all complete message, got: %s", result.Content[0].Text) + } +} + +func TestToolCheckTaskCompletion(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_check_task_completion", + Arguments: map[string]interface{}{ + "recent_action": "Finished build of the MCP server", + }, + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + text := result.Content[0].Text + // Should find overlap with "Build MCP server". + if !strings.Contains(text, "Build MCP server") { + t.Errorf("expected task match nudge, got: %s", text) + } +} + +func TestToolCheckTaskCompletionNoMatch(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_check_task_completion", + Arguments: map[string]interface{}{ + "recent_action": "Updated CSS styles", + }, + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // Should not match. + if result.Content[0].Text != "" { + t.Errorf("expected empty response for no match, got: %s", result.Content[0].Text) + } +} + +func TestToolSessionEventStart(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_session_event", + Arguments: map[string]interface{}{ + "type": "start", + "caller": "vscode", + }, + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if result.IsError { + t.Fatalf("unexpected tool error: %s", result.Content[0].Text) + } + text := result.Content[0].Text + if !strings.Contains(text, "Session started") { + t.Errorf("expected session start message, got: %s", text) + } + if !strings.Contains(text, "vscode") { + t.Errorf("expected caller in message, got: %s", text) + } +} + +func TestToolSessionEventEnd(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_session_event", + Arguments: map[string]interface{}{"type": "end"}, + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if result.IsError { + t.Fatalf("unexpected tool error: %s", result.Content[0].Text) + } + text := result.Content[0].Text + if !strings.Contains(text, "Session ending") { + t.Errorf("expected session end message, got: %s", text) + } +} + +func TestToolSessionEventInvalid(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_session_event", + Arguments: map[string]interface{}{"type": "pause"}, + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !result.IsError { + t.Error("expected error for invalid event type") + } +} + +func TestToolRemind(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_remind", + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if result.IsError { + t.Fatalf("unexpected tool error: %s", result.Content[0].Text) + } + // No reminders file in test setup — should return "No reminders." + if !strings.Contains(result.Content[0].Text, "No reminders") { + t.Errorf("expected no reminders message, got: %s", result.Content[0].Text) + } +} + +// --- Prompt tests --- + +func TestPromptsList(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "prompts/list", nil) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result PromptListResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(result.Prompts) != 5 { + t.Errorf("prompt count = %d, want 5", len(result.Prompts)) + } + names := make(map[string]bool) + for _, p := range result.Prompts { + names[p.Name] = true + } + for _, want := range []string{ + "ctx-session-start", "ctx-add-decision", "ctx-add-learning", + "ctx-reflect", "ctx-checkpoint", + } { + if !names[want] { + t.Errorf("missing prompt: %s", want) + } + } +} + +func TestPromptSessionStart(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "prompts/get", GetPromptParams{ + Name: "ctx-session-start", + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result GetPromptResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(result.Messages) == 0 { + t.Fatal("expected at least one message in session-start prompt") + } + text := result.Messages[0].Content.Text + if !strings.Contains(text, "session") { + t.Errorf("expected session orientation text, got: %s", text) + } +} + +func TestPromptAddDecision(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "prompts/get", GetPromptParams{ + Name: "ctx-add-decision", + Arguments: map[string]string{ + "content": "Use Go", + "context": "Need compiled language", + "rationale": "Fast", + "consequences": "Team needs Go skills", + }, + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result GetPromptResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(result.Messages) == 0 { + t.Fatal("expected message in decision prompt") + } + text := result.Messages[0].Content.Text + if !strings.Contains(text, "Use Go") { + t.Errorf("expected decision content in text, got: %s", text) + } +} + +func TestPromptReflect(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "prompts/get", GetPromptParams{ + Name: "ctx-reflect", + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result GetPromptResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(result.Messages) == 0 { + t.Fatal("expected message in reflect prompt") + } + text := result.Messages[0].Content.Text + if !strings.Contains(text, "Reflect") { + t.Errorf("expected reflect text, got: %s", text) + } +} + +func TestPromptCheckpoint(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "prompts/get", GetPromptParams{ + Name: "ctx-checkpoint", + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result GetPromptResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(result.Messages) == 0 { + t.Fatal("expected message in checkpoint prompt") + } + text := result.Messages[0].Content.Text + if !strings.Contains(text, "checkpoint") { + t.Errorf("expected checkpoint text, got: %s", text) + } +} + +func TestPromptUnknown(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "prompts/get", GetPromptParams{ + Name: "nonexistent", + }) + if resp.Error == nil { + t.Fatal("expected error for unknown prompt") + } +} + +// --- Session state tests --- + +func TestSessionStateTracking(t *testing.T) { + srv, _ := newTestServer(t) + + // Start session. + request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_session_event", + Arguments: map[string]interface{}{"type": "start"}, + }) + + // Call a few tools. + request(t, srv, "tools/call", CallToolParams{Name: "ctx_status"}) + request(t, srv, "tools/call", CallToolParams{Name: "ctx_next"}) + + // End session — should report tool call count. + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_session_event", + Arguments: map[string]interface{}{"type": "end"}, + }) + raw, _ := json.Marshal(resp.Result) + var result CallToolResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + text := result.Content[0].Text + // After start, status, next, end = 4 calls (start resets, so status + next + end = 3) + if !strings.Contains(text, "tool calls") { + t.Errorf("expected tool call stats, got: %s", text) + } +} + +func TestResourcesSubscribe(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "resources/subscribe", SubscribeParams{ + URI: "ctx://context/tasks", + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + // Cleanup: stop poller. + srv.poller.stop() +} + +func TestResourcesUnsubscribe(t *testing.T) { + srv, _ := newTestServer(t) + // Subscribe first. + request(t, srv, "resources/subscribe", SubscribeParams{ + URI: "ctx://context/tasks", + }) + // Then unsubscribe. + resp := request(t, srv, "resources/unsubscribe", UnsubscribeParams{ + URI: "ctx://context/tasks", + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + srv.poller.stop() +} + +func TestResourcePollerNotification(t *testing.T) { + srv, contextDir := newTestServer(t) + + var mu sync.Mutex + var notifications []Notification + srv.poller.notifyFunc = func(n Notification) { + mu.Lock() + notifications = append(notifications, n) + mu.Unlock() + } + + // Subscribe to tasks. + request(t, srv, "resources/subscribe", SubscribeParams{ + URI: "ctx://context/tasks", + }) + + // Modify the tasks file. + time.Sleep(10 * time.Millisecond) // Ensure mtime differs. + taskFile := filepath.Join(contextDir, ctx.Task) + if err := os.WriteFile(taskFile, []byte("# Tasks\n\n- [ ] Modified task\n"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + // Manually trigger a poll check instead of waiting for the timer. + srv.poller.checkChanges() + + mu.Lock() + count := len(notifications) + mu.Unlock() + + if count != 1 { + t.Fatalf("notification count = %d, want 1", count) + } + + params, ok := notifications[0].Params.(ResourceUpdatedParams) + if !ok { + t.Fatalf("unexpected params type: %T", notifications[0].Params) + } + if params.URI != "ctx://context/tasks" { + t.Errorf("notification URI = %q, want %q", params.URI, "ctx://context/tasks") + } + + srv.poller.stop() +} diff --git a/internal/mcp/session.go b/internal/mcp/session.go new file mode 100644 index 00000000..efcb1a13 --- /dev/null +++ b/internal/mcp/session.go @@ -0,0 +1,59 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package mcp + +import "time" + +// sessionState tracks per-context-dir advisory state. +// +// Session state is keyed by contextDir on the Server struct. It tracks +// tool call counts, entry additions, and pending context updates that +// need human review before persisting. +type sessionState struct { + contextDir string + toolCalls int + addsPerformed map[string]int + sessionStartedAt time.Time + pendingFlush []PendingUpdate +} + +// PendingUpdate represents a context update awaiting human confirmation. +type PendingUpdate struct { + Type string + Content string + Attrs map[string]string + QueuedAt time.Time +} + +// newSessionState creates a new session state for the given context directory. +func newSessionState(contextDir string) *sessionState { + return &sessionState{ + contextDir: contextDir, + addsPerformed: make(map[string]int), + sessionStartedAt: time.Now(), + } +} + +// recordToolCall increments the tool call counter. +func (ss *sessionState) recordToolCall() { + ss.toolCalls++ +} + +// recordAdd increments the add counter for the given entry type. +func (ss *sessionState) recordAdd(entryType string) { + ss.addsPerformed[entryType]++ +} + +// queuePendingUpdate adds an update to the pending flush queue. +func (ss *sessionState) queuePendingUpdate(update PendingUpdate) { + ss.pendingFlush = append(ss.pendingFlush, update) +} + +// pendingCount returns the number of pending updates. +func (ss *sessionState) pendingCount() int { + return len(ss.pendingFlush) +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index cd385cd4..c5b2f0f3 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -9,17 +9,27 @@ package mcp import ( "encoding/json" "fmt" + "os" "strings" + "time" "github.com/ActiveMemory/ctx/internal/assets" + "github.com/ActiveMemory/ctx/internal/cli/compact/core" + remindcore "github.com/ActiveMemory/ctx/internal/cli/remind/core" taskcomplete "github.com/ActiveMemory/ctx/internal/cli/task/cmd/complete" "github.com/ActiveMemory/ctx/internal/config/cli" + ctxCfg "github.com/ActiveMemory/ctx/internal/config/ctx" entry2 "github.com/ActiveMemory/ctx/internal/config/entry" + "github.com/ActiveMemory/ctx/internal/config/fs" "github.com/ActiveMemory/ctx/internal/config/mcp" + "github.com/ActiveMemory/ctx/internal/config/regex" "github.com/ActiveMemory/ctx/internal/config/token" "github.com/ActiveMemory/ctx/internal/context" "github.com/ActiveMemory/ctx/internal/drift" "github.com/ActiveMemory/ctx/internal/entry" + "github.com/ActiveMemory/ctx/internal/recall/parser" + "github.com/ActiveMemory/ctx/internal/task" + "github.com/ActiveMemory/ctx/internal/validation" ) // toolDefs defines all available MCP tools. @@ -96,6 +106,129 @@ var toolDefs = []Tool{ InputSchema: InputSchema{Type: mcp.MCPSchemaObject}, Annotations: &ToolAnnotations{ReadOnlyHint: true}, }, + { + Name: "ctx_recall", + Description: "Query recent AI session history (summaries, decisions, topics)", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]Property{ + "limit": { + Type: "number", + Description: "Max sessions to return (default: 5)", + }, + "since": { + Type: "string", + Description: "ISO date filter: sessions after this date (YYYY-MM-DD)", + }, + }, + }, + Annotations: &ToolAnnotations{ReadOnlyHint: true}, + }, + { + Name: "ctx_watch_update", + Description: "Apply a structured context-update to .context/ files " + + "(learning, decision, task, convention, complete). " + + "Human confirmation required before calling.", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]Property{ + "type": { + Type: "string", + Description: "Entry type: task|decision|learning|convention|complete", + }, + "content": { + Type: "string", + Description: "Main content", + }, + "context": { + Type: "string", + Description: "Context background (required for decisions/learnings)", + }, + "rationale": { + Type: "string", + Description: "Rationale (required for decisions)", + }, + "consequences": { + Type: "string", + Description: "Consequences (required for decisions)", + }, + "lesson": { + Type: "string", + Description: "Lesson learned (required for learnings)", + }, + "application": { + Type: "string", + Description: "How to apply this lesson (required for learnings)", + }, + }, + Required: []string{"type", "content"}, + }, + Annotations: &ToolAnnotations{}, + }, + { + Name: "ctx_compact", + Description: "Move completed tasks to archive section. " + + "Removes empty sections from all context files. " + + "Human confirmation required — this reorganizes TASKS.md.", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]Property{ + "archive": { + Type: "boolean", + Description: "Also write tasks to .context/archive/ (default: false)", + }, + }, + }, + Annotations: &ToolAnnotations{}, + }, + { + Name: "ctx_next", + Description: "Suggest the next pending task based on priority and recency", + InputSchema: InputSchema{Type: "object"}, + Annotations: &ToolAnnotations{ReadOnlyHint: true}, + }, + { + Name: "ctx_check_task_completion", + Description: "Advisory check: after a write operation, detect if any " + + "pending tasks were silently completed. Returns nudge text if found.", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]Property{ + "recent_action": { + Type: "string", + Description: "Brief description of what was just done", + }, + }, + }, + Annotations: &ToolAnnotations{ReadOnlyHint: true}, + }, + { + Name: "ctx_session_event", + Description: "Signal a session lifecycle event. " + + "Type 'end' triggers the session-end persistence ceremony — " + + "human confirmation required.", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]Property{ + "type": { + Type: "string", + Description: "Event type: start|end", + }, + "caller": { + Type: "string", + Description: "Caller identifier (cursor|windsurf|vscode|claude-desktop)", + }, + }, + Required: []string{"type"}, + }, + Annotations: &ToolAnnotations{}, + }, + { + Name: "ctx_remind", + Description: "List pending session-scoped reminders", + InputSchema: InputSchema{Type: "object"}, + Annotations: &ToolAnnotations{ReadOnlyHint: true}, + }, } // handleToolsList returns all available MCP tools. @@ -110,6 +243,8 @@ func (s *Server) handleToolsCall(req Request) *Response { return s.error(req.ID, errCodeInvalidArg, assets.TextDesc(assets.TextDescKeyMCPInvalidParams)) } + s.session.recordToolCall() + switch params.Name { case mcp.MCPToolStatus: return s.toolStatus(req.ID) @@ -119,6 +254,20 @@ func (s *Server) handleToolsCall(req Request) *Response { return s.toolComplete(req.ID, params.Arguments) case mcp.MCPToolDrift: return s.toolDrift(req.ID) + case "ctx_recall": + return s.toolRecall(req.ID, params.Arguments) + case "ctx_watch_update": + return s.toolWatchUpdate(req.ID, params.Arguments) + case "ctx_compact": + return s.toolCompact(req.ID, params.Arguments) + case "ctx_next": + return s.toolNext(req.ID) + case "ctx_check_task_completion": + return s.toolCheckTaskCompletion(req.ID, params.Arguments) + case "ctx_session_event": + return s.toolSessionEvent(req.ID, params.Arguments) + case "ctx_remind": + return s.toolRemind(req.ID) default: return s.error(req.ID, errCodeNotFound, fmt.Sprintf(assets.TextDesc(assets.TextDescKeyMCPUnknownTool), params.Name)) @@ -153,6 +302,10 @@ func (s *Server) toolStatus(id json.RawMessage) *Response { func (s *Server) toolAdd( id json.RawMessage, args map[string]interface{}, ) *Response { + if err := validation.ValidateBoundary(s.contextDir); err != nil { + return s.toolError(id, fmt.Sprintf("boundary violation: %v", err)) + } + entryType, _ := args[cli.AttrType].(string) content, _ := args["content"].(string) @@ -203,6 +356,10 @@ func (s *Server) toolAdd( func (s *Server) toolComplete( id json.RawMessage, args map[string]interface{}, ) *Response { + if err := validation.ValidateBoundary(s.contextDir); err != nil { + return s.toolError(id, fmt.Sprintf("boundary violation: %v", err)) + } + query, _ := args["query"].(string) if query == "" { return s.toolError(id, assets.TextDesc(assets.TextDescKeyMCPQueryRequired)) @@ -270,3 +427,468 @@ func (s *Server) toolError(id json.RawMessage, msg string) *Response { IsError: true, }) } + +// toolRecall queries recent session history. +func (s *Server) toolRecall( + id json.RawMessage, args map[string]interface{}, +) *Response { + limit := 5 + if v, ok := args["limit"].(float64); ok && v > 0 { + limit = int(v) + } + + var sinceFilter time.Time + if v, ok := args["since"].(string); ok && v != "" { + parsed, parseErr := time.Parse("2006-01-02", v) + if parseErr != nil { + return s.toolError(id, fmt.Sprintf("invalid since date (use YYYY-MM-DD): %v", parseErr)) + } + sinceFilter = parsed + } + + sessions, err := parser.FindSessions() + if err != nil { + return s.toolError(id, fmt.Sprintf("failed to find sessions: %v", err)) + } + + // Apply since filter. + if !sinceFilter.IsZero() { + var filtered []*parser.Session + for _, sess := range sessions { + if sess.StartTime.After(sinceFilter) || sess.StartTime.Equal(sinceFilter) { + filtered = append(filtered, sess) + } + } + sessions = filtered + } + + // Apply limit. + if len(sessions) > limit { + sessions = sessions[:limit] + } + + if len(sessions) == 0 { + return s.toolOK(id, "No sessions found.") + } + + var sb strings.Builder + fmt.Fprintf(&sb, "Found %d session(s):%s%s", len(sessions), token.NewlineLF, token.NewlineLF) + + for i, sess := range sessions { + duration := sess.Duration.Round(time.Second) + fmt.Fprintf(&sb, "%d. %s", i+1, sess.StartTime.Format("2006-01-02 15:04")) + if sess.Project != "" { + fmt.Fprintf(&sb, " [%s]", sess.Project) + } + fmt.Fprintf(&sb, " (%s, %d turns)", duration, sess.TurnCount) + sb.WriteString(token.NewlineLF) + + if sess.FirstUserMsg != "" { + fmt.Fprintf(&sb, " %s", sess.FirstUserMsg) + sb.WriteString(token.NewlineLF) + } + } + + return s.toolOK(id, sb.String()) +} + +// toolWatchUpdate applies a structured context-update to .context/ files. +func (s *Server) toolWatchUpdate( + id json.RawMessage, args map[string]interface{}, +) *Response { + if err := validation.ValidateBoundary(s.contextDir); err != nil { + return s.toolError(id, fmt.Sprintf("boundary violation: %v", err)) + } + + entryType, _ := args["type"].(string) + content, _ := args["content"].(string) + + if entryType == "" || content == "" { + return s.toolError(id, "type and content are required") + } + + // Handle "complete" type as a special case — delegate to ctx_complete. + if entryType == "complete" { + completedTask, err := taskcomplete.CompleteTask(content, s.contextDir) + if err != nil { + return s.toolError(id, err.Error()) + } + s.session.queuePendingUpdate(PendingUpdate{ + Type: entryType, + Content: content, + QueuedAt: time.Now(), + }) + return s.toolOK(id, + fmt.Sprintf("Completed: %s", completedTask)+token.NewlineLF+ + "Review with: ctx status") + } + + params := entry.Params{ + Type: entryType, + Content: content, + ContextDir: s.contextDir, + } + + if v, ok := args["context"].(string); ok { + params.Context = v + } + if v, ok := args["rationale"].(string); ok { + params.Rationale = v + } + if v, ok := args["consequences"].(string); ok { + params.Consequences = v + } + if v, ok := args["lesson"].(string); ok { + params.Lesson = v + } + if v, ok := args["application"].(string); ok { + params.Application = v + } + + if vErr := entry.Validate(params, nil); vErr != nil { + return s.toolError(id, vErr.Error()) + } + + if wErr := entry.Write(params); wErr != nil { + return s.toolError(id, fmt.Sprintf("write failed: %v", wErr)) + } + + fileName := entry2.ToCtxFile[strings.ToLower(entryType)] + s.session.recordAdd(entryType) + s.session.queuePendingUpdate(PendingUpdate{ + Type: entryType, + Content: content, + Attrs: map[string]string{ + "file": fileName, + }, + QueuedAt: time.Now(), + }) + + return s.toolOK(id, + fmt.Sprintf("Wrote %s to .context/%s.", entryType, fileName)+token.NewlineLF+ + "Review with: ctx status") +} + +// toolCompact moves completed tasks to the archive section. +func (s *Server) toolCompact( + id json.RawMessage, args map[string]interface{}, +) *Response { + if err := validation.ValidateBoundary(s.contextDir); err != nil { + return s.toolError(id, fmt.Sprintf("boundary violation: %v", err)) + } + + archive := false + if v, ok := args["archive"].(bool); ok { + archive = v + } + + ctx, err := context.Load(s.contextDir) + if err != nil { + return s.toolError(id, fmt.Sprintf("failed to load context: %v", err)) + } + + var sb strings.Builder + changes := 0 + + // Process TASKS.md. + tasksFile := ctx.File(ctxCfg.Task) + if tasksFile != nil { + content := string(tasksFile.Content) + lines := strings.Split(content, token.NewlineLF) + + blocks := core.ParseTaskBlocks(lines) + + var archivableBlocks []core.TaskBlock + for _, block := range blocks { + if block.IsArchivable { + archivableBlocks = append(archivableBlocks, block) + fmt.Fprintf(&sb, "Moved: %s%s", + core.TruncateString(block.ParentTaskText(), 50), token.NewlineLF) + } + } + + if len(archivableBlocks) > 0 { + newLines := core.RemoveBlocksFromLines(lines, archivableBlocks) + + // Add blocks to the Completed section. + for i, line := range newLines { + if strings.HasPrefix(line, assets.HeadingCompleted) { + insertIdx := i + 1 + for insertIdx < len(newLines) && newLines[insertIdx] != "" && + !strings.HasPrefix(newLines[insertIdx], token.HeadingLevelTwoStart) { + insertIdx++ + } + + var blocksToInsert []string + for _, block := range archivableBlocks { + blocksToInsert = append(blocksToInsert, block.Lines...) + } + + newLines = append(newLines[:insertIdx], + append(blocksToInsert, newLines[insertIdx:]...)...) + break + } + } + + newContent := strings.Join(newLines, token.NewlineLF) + if newContent != content { + if writeErr := writeContextFile(tasksFile.Path, []byte(newContent)); writeErr != nil { + return s.toolError(id, fmt.Sprintf("write failed: %v", writeErr)) + } + } + changes += len(archivableBlocks) + } + + // Archive old tasks if requested. + if archive && len(archivableBlocks) > 0 { + var archiveContent string + for _, block := range archivableBlocks { + archiveContent += block.BlockContent() + token.NewlineLF + token.NewlineLF + } + if _, archiveErr := core.WriteArchive("tasks", assets.HeadingArchivedTasks, archiveContent); archiveErr != nil { + fmt.Fprintf(&sb, "Archive warning: %v%s", archiveErr, token.NewlineLF) + } + } + } + + // Process other files for empty sections. + for _, f := range ctx.Files { + if f.Name == ctxCfg.Task { + continue + } + cleaned, count := core.RemoveEmptySections(string(f.Content)) + if count > 0 { + if writeErr := writeContextFile(f.Path, []byte(cleaned)); writeErr == nil { + fmt.Fprintf(&sb, "Removed %d empty sections from %s%s", + count, f.Name, token.NewlineLF) + changes += count + } + } + } + + if changes == 0 { + return s.toolOK(id, "Nothing to compact — context is already clean.") + } + + fmt.Fprintf(&sb, "%sCompacted %d items. This reorganized TASKS.md.%s", + token.NewlineLF, changes, token.NewlineLF) + sb.WriteString("Review with: ctx status") + + return s.toolOK(id, sb.String()) +} + +// toolNext suggests the next pending task. +func (s *Server) toolNext(id json.RawMessage) *Response { + ctx, err := context.Load(s.contextDir) + if err != nil { + return s.toolError(id, fmt.Sprintf("failed to load context: %v", err)) + } + + tasksFile := ctx.File(ctxCfg.Task) + if tasksFile == nil { + return s.toolOK(id, "No TASKS.md found.") + } + + content := string(tasksFile.Content) + lines := strings.Split(content, token.NewlineLF) + + // Find the first pending top-level task. + inCompletedSection := false + pendingIdx := 0 + + for _, line := range lines { + if strings.HasPrefix(line, assets.HeadingCompleted) { + inCompletedSection = true + continue + } + if strings.HasPrefix(line, token.HeadingLevelTwoStart) && inCompletedSection { + inCompletedSection = false + } + if inCompletedSection { + continue + } + + match := regex.Task.FindStringSubmatch(line) + if match == nil || !task.Pending(match) { + continue + } + + // Skip subtasks. + if task.SubTask(match) { + continue + } + + pendingIdx++ + return s.toolOK(id, fmt.Sprintf( + "Next task (#%d): %s", pendingIdx, task.Content(match))) + } + + return s.toolOK(id, "All tasks completed. No pending work.") +} + +// toolCheckTaskCompletion checks if a recent action completed any pending tasks. +func (s *Server) toolCheckTaskCompletion( + id json.RawMessage, args map[string]interface{}, +) *Response { + recentAction, _ := args["recent_action"].(string) + + ctx, err := context.Load(s.contextDir) + if err != nil { + return s.toolError(id, fmt.Sprintf("failed to load context: %v", err)) + } + + tasksFile := ctx.File(ctxCfg.Task) + if tasksFile == nil { + return s.toolOK(id, "") + } + + content := string(tasksFile.Content) + lines := strings.Split(content, token.NewlineLF) + + inCompletedSection := false + taskNum := 0 + + for _, line := range lines { + if strings.HasPrefix(line, assets.HeadingCompleted) { + inCompletedSection = true + continue + } + if strings.HasPrefix(line, token.HeadingLevelTwoStart) && inCompletedSection { + inCompletedSection = false + } + if inCompletedSection { + continue + } + + match := regex.Task.FindStringSubmatch(line) + if match == nil || !task.Pending(match) { + continue + } + if task.SubTask(match) { + continue + } + + taskNum++ + taskText := task.Content(match) + + // Check for keyword overlap between the recent action and the task. + if recentAction != "" && containsOverlap(recentAction, taskText) { + return s.toolOK(id, fmt.Sprintf( + "Did this complete task #%d: \"%s\"?"+token.NewlineLF+ + "If yes, run: ctx complete %d", taskNum, taskText, taskNum)) + } + } + + return s.toolOK(id, "") +} + +// toolSessionEvent handles session lifecycle events. +func (s *Server) toolSessionEvent( + id json.RawMessage, args map[string]interface{}, +) *Response { + eventType, _ := args["type"].(string) + if eventType == "" { + return s.toolError(id, "type is required (start|end)") + } + + switch eventType { + case "start": + s.session = newSessionState(s.contextDir) + if caller, ok := args["caller"].(string); ok && caller != "" { + return s.toolOK(id, fmt.Sprintf( + "Session started for %s. Context: %s", caller, s.contextDir)) + } + return s.toolOK(id, fmt.Sprintf( + "Session started. Context: %s", s.contextDir)) + + case "end": + pending := s.session.pendingCount() + var sb strings.Builder + sb.WriteString("Session ending.") + sb.WriteString(token.NewlineLF) + + if pending > 0 { + fmt.Fprintf(&sb, "%d pending updates queued.%s", + pending, token.NewlineLF) + for i, pu := range s.session.pendingFlush { + fmt.Fprintf(&sb, " %d. [%s] %s%s", + i+1, pu.Type, core.TruncateString(pu.Content, 60), token.NewlineLF) + } + sb.WriteString("Review pending context updates before persisting.") + } else { + sb.WriteString("No pending updates.") + } + + fmt.Fprintf(&sb, "%sSession stats: %d tool calls, %d entries added.", + token.NewlineLF, s.session.toolCalls, totalAdds(s.session.addsPerformed)) + + return s.toolOK(id, sb.String()) + + default: + return s.toolError(id, + fmt.Sprintf("unknown event type: %s (use start|end)", eventType)) + } +} + +// toolRemind lists pending session-scoped reminders. +func (s *Server) toolRemind(id json.RawMessage) *Response { + reminders, readErr := remindcore.ReadReminders() + if readErr != nil { + return s.toolError(id, fmt.Sprintf("failed to read reminders: %v", readErr)) + } + + if len(reminders) == 0 { + return s.toolOK(id, "No reminders.") + } + + today := time.Now().Format("2006-01-02") + var sb strings.Builder + fmt.Fprintf(&sb, "%d reminder(s):%s", len(reminders), token.NewlineLF) + + for _, r := range reminders { + annotation := "" + if r.After != nil { + if *r.After > today { + annotation = fmt.Sprintf(" (after %s, not yet due)", *r.After) + } + } + fmt.Fprintf(&sb, " [%d] %s%s%s", r.ID, r.Message, annotation, token.NewlineLF) + } + + return s.toolOK(id, sb.String()) +} + +// containsOverlap checks if two strings share meaningful words. +func containsOverlap(action, taskText string) bool { + actionLower := strings.ToLower(action) + taskLower := strings.ToLower(taskText) + + // Split task text into words, check if any appear in the action. + words := strings.Fields(taskLower) + matchCount := 0 + for _, w := range words { + if len(w) < 4 { + continue // Skip short common words. + } + if strings.Contains(actionLower, w) { + matchCount++ + } + } + + // Require at least 2 word matches for a reasonable signal. + return matchCount >= 2 +} + +// totalAdds sums all entry add counts. +func totalAdds(m map[string]int) int { + total := 0 + for _, v := range m { + total += v + } + return total +} + +// writeContextFile writes content to a context file with standard permissions. +func writeContextFile(path string, data []byte) error { + return os.WriteFile(path, data, fs.PermFile) +} diff --git a/internal/mcp/types.go b/internal/mcp/types.go index 247934a1..49981bd6 100644 --- a/internal/mcp/types.go +++ b/internal/mcp/types.go @@ -6,7 +6,10 @@ package mcp -import "io" +import ( + "io" + "sync" +) // Server is an MCP server that exposes ctx context over JSON-RPC 2.0. // @@ -17,5 +20,8 @@ type Server struct { version string tokenBudget int out io.Writer + outMu sync.Mutex in io.Reader + session *sessionState + poller *resourcePoller } diff --git a/site/cli/mcp/index.html b/site/cli/mcp/index.html index d0ae61e7..e369b341 100644 --- a/site/cli/mcp/index.html +++ b/site/cli/mcp/index.html @@ -2903,6 +2903,23 @@ + +
  • @@ -2959,6 +2976,155 @@ +
  • + +
  • + + + + ctx_recall + + + + +
  • + +
  • + + + + ctx_watch_update + + + + +
  • + +
  • + + + + ctx_compact + + + + +
  • + +
  • + + + + ctx_next + + + + +
  • + +
  • + + + + ctx_check_task_completion + + + + +
  • + +
  • + + + + ctx_session_event + + + + +
  • + +
  • + + + + ctx_remind + + + + +
  • + + + + + + +
  • + + + + Prompts + + + + +
  • @@ -3792,6 +3975,155 @@ +
  • + +
  • + + + + ctx_recall + + + + +
  • + +
  • + + + + ctx_watch_update + + + + +
  • + +
  • + + + + ctx_compact + + + + +
  • + +
  • + + + + ctx_next + + + + +
  • + +
  • + + + + ctx_check_task_completion + + + + +
  • + +
  • + + + + ctx_session_event + + + + +
  • + +
  • + + + + ctx_remind + + + + +
  • + + + + + + +
  • + + + + Prompts + + + + +