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/assets/commands/text/mcp.yaml b/internal/assets/commands/text/mcp.yaml index be0ec4fa..ed6b081d 100644 --- a/internal/assets/commands/text/mcp.yaml +++ b/internal/assets/commands/text/mcp.yaml @@ -126,3 +126,201 @@ mcp.unknown-tool: short: 'unknown tool: %s' mcp.write-failed: short: 'write failed: %v' + +mcp.all-tasks-complete: + short: All tasks completed. No pending work. +mcp.boundary-violation: + short: 'boundary violation: %v' +mcp.check-task-format: + short: 'Did this complete task #%d: "%s"?' +mcp.check-task-hint: + short: 'If yes, run: ctx complete %d' +mcp.compact-clean: + short: Nothing to compact — context is already clean. +mcp.compacted-format: + short: Compacted %d items. This reorganized TASKS.md. +mcp.event-type-required: + short: type is required (start|end) +mcp.find-sessions-failed: + short: 'failed to find sessions: %v' +mcp.invalid-since-date: + short: 'invalid since date (use YYYY-MM-DD): %v' +mcp.next-task-format: + short: 'Next task (#%d): %s' +mcp.no-pending: + short: No pending updates. +mcp.no-reminders: + short: No reminders. +mcp.no-sessions: + short: No sessions found. +mcp.no-tasks: + short: No TASKS.md found. +mcp.pending-updates-format: + short: '%d pending updates queued.' +mcp.prompt-add-decision-desc: + short: Record an architectural decision with context, rationale, and consequences +mcp.prompt-add-decision-field-format: + short: '- **%s**: %s' +mcp.prompt-add-decision-footer: + short: Call ctx_add with type="decision" and all fields above. +mcp.prompt-add-decision-header: + short: 'Record this architectural decision using ctx_add:' +mcp.prompt-add-decision-result-desc: + short: Record an architectural decision +mcp.prompt-add-learning-desc: + short: Record a lesson learned with context, lesson, and application +mcp.prompt-add-learning-field-format: + short: '- **%s**: %s' +mcp.prompt-add-learning-footer: + short: Call ctx_add with type="learning" and all fields above. +mcp.prompt-add-learning-header: + short: 'Record this learning using ctx_add:' +mcp.prompt-add-learning-result-desc: + short: Record a lesson learned +mcp.prompt-arg-decision-consequences: + short: Impact of the decision +mcp.prompt-arg-decision-ctx: + short: Background context for the decision +mcp.prompt-arg-decision-rationale: + short: Why this decision was made +mcp.prompt-arg-decision-title: + short: Decision title +mcp.prompt-arg-learning-app: + short: How to apply this lesson +mcp.prompt-arg-learning-ctx: + short: Background context +mcp.prompt-arg-learning-lesson: + short: What was learned +mcp.prompt-arg-learning-title: + short: Learning title +mcp.prompt-checkpoint-desc: + short: Summarize session progress and persist important context before ending +mcp.prompt-checkpoint-header: + short: 'Session checkpoint. Before ending this session:' +mcp.prompt-checkpoint-result-desc: + short: Summarize session progress and persist context +mcp.prompt-checkpoint-stats-format: + short: '- Tool calls this session: %d + + - Entries added: %d + + - Pending updates: %d + + + ' +mcp.prompt-checkpoint-steps: + short: '1. Check ctx_status for current context state + + 2. Record any remaining decisions or learnings + + 3. Mark completed tasks with ctx_complete + + 4. Run ctx_compact if needed + + 5. Call ctx_session_event type="end" when done + + + ' +mcp.prompt-reflect-body: + short: 'Reflect on this session and identify: + + + 1. **Decisions made** — Record each with ctx_add type="decision" + + 2. **Lessons learned** — Record each with ctx_add type="learning" + + 3. **Tasks completed** — Mark done with ctx_complete + + 4. **New tasks identified** — Add with ctx_add type="task" + + + Review what was discussed and changed. Don''t let important context slip away. + + + ' +mcp.prompt-reflect-desc: + short: Review the current session and capture outstanding learnings and decisions +mcp.prompt-reflect-result-desc: + short: Review session for outstanding learnings and decisions +mcp.prompt-section-format: + short: '## %s + + + %s + + + ' +mcp.prompt-session-start-desc: + short: 'Initialize a new session: loads full context and provides orientation' +mcp.prompt-session-start-footer: + short: Remember this context throughout the session. Use ctx_add to record decisions + and learnings as you work. At session end, use ctx-checkpoint to capture outstanding + context. +mcp.prompt-session-start-header: + short: You are starting a new session. Read the following context files carefully. +mcp.prompt-session-start-result-desc: + short: Session initialization with full context load +mcp.read-reminders-failed: + short: 'failed to read reminders: %v' +mcp.reminders-format: + short: '%d reminder(s):' +mcp.review-pending: + short: Review pending context updates before persisting. +mcp.review-status: + short: 'Review with: ctx status' +mcp.session-ending: + short: Session ending. +mcp.session-started-caller-format: + short: 'Session started for %s. Context: %s' +mcp.session-started-format: + short: 'Session started. Context: %s' +mcp.session-stats-format: + short: 'Session stats: %d tool calls, %d entries added.' +mcp.sessions-found-format: + short: 'Found %d session(s):' +mcp.tool-check-task-desc: + short: 'Advisory check: after a write operation, detect if any pending tasks were + silently completed. Returns nudge text if found.' +mcp.tool-compact-desc: + short: Move completed tasks to archive section. Removes empty sections from all + context files. Human confirmation required — this reorganizes TASKS.md. +mcp.tool-next-desc: + short: Suggest the next pending task based on priority and recency +mcp.tool-prop-archive: + short: Also write tasks to .context/archive/ (default false) +mcp.tool-prop-caller: + short: Caller identifier (cursor|windsurf|vscode|claude-desktop) +mcp.tool-prop-ctx-background: + short: Context background (required for decisions/learnings) +mcp.tool-prop-entry-type: + short: 'Entry type: task|decision|learning|convention|complete' +mcp.tool-prop-event-type: + short: 'Event type: start|end' +mcp.tool-prop-limit: + short: Max sessions to return (default 5) +mcp.tool-prop-main-content: + short: Main content +mcp.tool-prop-recent-action: + short: Brief description of what was just done +mcp.tool-prop-since: + short: 'ISO date filter: sessions after this date (YYYY-MM-DD)' +mcp.tool-recall-desc: + short: Query recent AI session history (summaries, decisions, topics) +mcp.tool-remind-desc: + short: List pending session-scoped reminders +mcp.tool-session-desc: + short: Signal a session lifecycle event. Type 'end' triggers the session-end persistence + ceremony — human confirmation required. +mcp.tool-watch-update-desc: + short: Apply a structured context-update to .context/ files (learning, decision, + task, convention, complete). Human confirmation required before calling. +mcp.unknown-event-type: + short: 'unknown event type: %s (use start|end)' +mcp.unknown-prompt: + short: 'unknown prompt: %s' +mcp.uri-required: + short: uri is required +mcp.watch-completed-format: + short: 'Completed: %s' +mcp.wrote-format: + short: Wrote %s to .context/%s. diff --git a/internal/assets/embed.go b/internal/assets/embed.go index 6257417b..9462d0ee 100644 --- a/internal/assets/embed.go +++ b/internal/assets/embed.go @@ -798,6 +798,13 @@ const ( TextDescKeyMCPToolAddDesc = "mcp.tool-add-desc" TextDescKeyMCPToolCompleteDesc = "mcp.tool-complete-desc" TextDescKeyMCPToolDriftDesc = "mcp.tool-drift-desc" + TextDescKeyMCPToolRecallDesc = "mcp.tool-recall-desc" + TextDescKeyMCPToolWatchUpdateDesc = "mcp.tool-watch-update-desc" + TextDescKeyMCPToolCompactDesc = "mcp.tool-compact-desc" + TextDescKeyMCPToolNextDesc = "mcp.tool-next-desc" + TextDescKeyMCPToolCheckTaskDesc = "mcp.tool-check-task-desc" + TextDescKeyMCPToolSessionDesc = "mcp.tool-session-desc" + TextDescKeyMCPToolRemindDesc = "mcp.tool-remind-desc" TextDescKeyMCPToolPropType = "mcp.tool-prop-type" TextDescKeyMCPToolPropContent = "mcp.tool-prop-content" TextDescKeyMCPToolPropPriority = "mcp.tool-prop-priority" @@ -807,6 +814,15 @@ const ( TextDescKeyMCPToolPropLesson = "mcp.tool-prop-lesson" TextDescKeyMCPToolPropApplication = "mcp.tool-prop-application" TextDescKeyMCPToolPropQuery = "mcp.tool-prop-query" + TextDescKeyMCPToolPropLimit = "mcp.tool-prop-limit" + TextDescKeyMCPToolPropSince = "mcp.tool-prop-since" + TextDescKeyMCPToolPropEntryType = "mcp.tool-prop-entry-type" + TextDescKeyMCPToolPropMainContent = "mcp.tool-prop-main-content" + TextDescKeyMCPToolPropCtxBg = "mcp.tool-prop-ctx-background" + TextDescKeyMCPToolPropArchive = "mcp.tool-prop-archive" + TextDescKeyMCPToolPropRecentAct = "mcp.tool-prop-recent-action" + TextDescKeyMCPToolPropEventType = "mcp.tool-prop-event-type" + TextDescKeyMCPToolPropCaller = "mcp.tool-prop-caller" TextDescKeyMCPTypeContentRequired = "mcp.type-content-required" TextDescKeyMCPQueryRequired = "mcp.query-required" TextDescKeyMCPWriteFailed = "mcp.write-failed" @@ -827,14 +843,78 @@ const ( TextDescKeyMCPSectionFormat = "mcp.section-format" TextDescKeyMCPAlsoNoted = "mcp.also-noted" TextDescKeyMCPOmittedFormat = "mcp.omitted-format" - TextDescKeyDriftDeadPath = "drift.dead-path" - TextDescKeyDriftEntryCount = "drift.entry-count" - TextDescKeyDriftMissingFile = "drift.missing-file" - TextDescKeyDriftRegenerated = "drift.regenerated" - TextDescKeyDriftMissingPackage = "drift.missing-package" - TextDescKeyDriftSecret = "drift.secret" - TextDescKeyDriftStaleAge = "drift.stale-age" - TextDescKeyDriftStaleness = "drift.staleness" + + TextDescKeyMCPBoundaryViolation = "mcp.boundary-violation" + TextDescKeyMCPInvalidSinceDate = "mcp.invalid-since-date" + TextDescKeyMCPFindSessionsFailed = "mcp.find-sessions-failed" + TextDescKeyMCPNoSessions = "mcp.no-sessions" + TextDescKeyMCPSessionsFoundFormat = "mcp.sessions-found-format" + TextDescKeyMCPWatchCompletedFormat = "mcp.watch-completed-format" + TextDescKeyMCPReviewStatus = "mcp.review-status" + TextDescKeyMCPWroteFormat = "mcp.wrote-format" + TextDescKeyMCPCompactClean = "mcp.compact-clean" + TextDescKeyMCPCompactedFormat = "mcp.compacted-format" + TextDescKeyMCPNoTasks = "mcp.no-tasks" + TextDescKeyMCPNextTaskFormat = "mcp.next-task-format" + TextDescKeyMCPAllTasksComplete = "mcp.all-tasks-complete" + TextDescKeyMCPCheckTaskFormat = "mcp.check-task-format" + TextDescKeyMCPCheckTaskHint = "mcp.check-task-hint" + TextDescKeyMCPEventTypeRequired = "mcp.event-type-required" + TextDescKeyMCPSessionStartedCallerFormat = "mcp.session-started-caller-format" + TextDescKeyMCPSessionStartedFormat = "mcp.session-started-format" + TextDescKeyMCPSessionEnding = "mcp.session-ending" + TextDescKeyMCPPendingUpdatesFormat = "mcp.pending-updates-format" + TextDescKeyMCPReviewPending = "mcp.review-pending" + TextDescKeyMCPNoPending = "mcp.no-pending" + TextDescKeyMCPSessionStatsFormat = "mcp.session-stats-format" + TextDescKeyMCPUnknownEventType = "mcp.unknown-event-type" + TextDescKeyMCPReadRemindersFailed = "mcp.read-reminders-failed" + TextDescKeyMCPNoReminders = "mcp.no-reminders" + TextDescKeyMCPRemindersFormat = "mcp.reminders-format" + TextDescKeyMCPUnknownPrompt = "mcp.unknown-prompt" + TextDescKeyMCPURIRequired = "mcp.uri-required" + + TextDescKeyMCPPromptSessionStartDesc = "mcp.prompt-session-start-desc" + TextDescKeyMCPPromptAddDecisionDesc = "mcp.prompt-add-decision-desc" + TextDescKeyMCPPromptAddLearningDesc = "mcp.prompt-add-learning-desc" + TextDescKeyMCPPromptReflectDesc = "mcp.prompt-reflect-desc" + TextDescKeyMCPPromptCheckpointDesc = "mcp.prompt-checkpoint-desc" + + TextDescKeyMCPPromptArgDecisionTitle = "mcp.prompt-arg-decision-title" + TextDescKeyMCPPromptArgDecisionCtx = "mcp.prompt-arg-decision-ctx" + TextDescKeyMCPPromptArgDecisionRat = "mcp.prompt-arg-decision-rationale" + TextDescKeyMCPPromptArgDecisionConseq = "mcp.prompt-arg-decision-consequences" + TextDescKeyMCPPromptArgLearningTitle = "mcp.prompt-arg-learning-title" + TextDescKeyMCPPromptArgLearningCtx = "mcp.prompt-arg-learning-ctx" + TextDescKeyMCPPromptArgLearningLesson = "mcp.prompt-arg-learning-lesson" + TextDescKeyMCPPromptArgLearningApp = "mcp.prompt-arg-learning-app" + + TextDescKeyMCPPromptSessionStartHeader = "mcp.prompt-session-start-header" + TextDescKeyMCPPromptSessionStartFooter = "mcp.prompt-session-start-footer" + TextDescKeyMCPPromptSessionStartResultD = "mcp.prompt-session-start-result-desc" + TextDescKeyMCPPromptSectionFormat = "mcp.prompt-section-format" + TextDescKeyMCPPromptAddDecisionHeader = "mcp.prompt-add-decision-header" + TextDescKeyMCPPromptAddDecisionFieldFmt = "mcp.prompt-add-decision-field-format" + TextDescKeyMCPPromptAddDecisionFooter = "mcp.prompt-add-decision-footer" + TextDescKeyMCPPromptAddDecisionResultD = "mcp.prompt-add-decision-result-desc" + TextDescKeyMCPPromptAddLearningHeader = "mcp.prompt-add-learning-header" + TextDescKeyMCPPromptAddLearningFieldFmt = "mcp.prompt-add-learning-field-format" + TextDescKeyMCPPromptAddLearningFooter = "mcp.prompt-add-learning-footer" + TextDescKeyMCPPromptAddLearningResultD = "mcp.prompt-add-learning-result-desc" + TextDescKeyMCPPromptReflectBody = "mcp.prompt-reflect-body" + TextDescKeyMCPPromptReflectResultD = "mcp.prompt-reflect-result-desc" + TextDescKeyMCPPromptCheckpointHeader = "mcp.prompt-checkpoint-header" + TextDescKeyMCPPromptCheckpointStatsFormat = "mcp.prompt-checkpoint-stats-format" + TextDescKeyMCPPromptCheckpointSteps = "mcp.prompt-checkpoint-steps" + TextDescKeyMCPPromptCheckpointResultD = "mcp.prompt-checkpoint-result-desc" + TextDescKeyDriftDeadPath = "drift.dead-path" + TextDescKeyDriftEntryCount = "drift.entry-count" + TextDescKeyDriftMissingFile = "drift.missing-file" + TextDescKeyDriftRegenerated = "drift.regenerated" + TextDescKeyDriftMissingPackage = "drift.missing-package" + TextDescKeyDriftSecret = "drift.secret" + TextDescKeyDriftStaleAge = "drift.stale-age" + TextDescKeyDriftStaleness = "drift.staleness" TextDescKeyJournalMocSessionLink = "journal.moc.session-link" TextDescKeyJournalMocNavDescription = "journal.moc.nav-description" diff --git a/internal/config/mcp/mcp.go b/internal/config/mcp/mcp.go index 7b6273c3..caf8be7f 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. @@ -36,6 +44,10 @@ const ( MCPSchemaObject = "object" // MCPSchemaString is the JSON Schema type for strings. MCPSchemaString = "string" + // MCPSchemaNumber is the JSON Schema type for numbers. + MCPSchemaNumber = "number" + // MCPSchemaBoolean is the JSON Schema type for booleans. + MCPSchemaBoolean = "boolean" // MCPToolStatus is the MCP tool name for context status. MCPToolStatus = "ctx_status" // MCPToolAdd is the MCP tool name for adding entries. @@ -44,4 +56,32 @@ const ( MCPToolComplete = "ctx_complete" // MCPToolDrift is the MCP tool name for drift detection. MCPToolDrift = "ctx_drift" + // MCPToolRecall is the MCP tool name for querying session history. + MCPToolRecall = "ctx_recall" + // MCPToolWatchUpdate is the MCP tool name for structured context updates. + MCPToolWatchUpdate = "ctx_watch_update" + // MCPToolCompact is the MCP tool name for compacting tasks. + MCPToolCompact = "ctx_compact" + // MCPToolNext is the MCP tool name for suggesting the next task. + MCPToolNext = "ctx_next" + // MCPToolCheckTaskCompletion is the MCP tool name for task completion nudge. + MCPToolCheckTaskCompletion = "ctx_check_task_completion" + // MCPToolSessionEvent is the MCP tool name for session lifecycle events. + MCPToolSessionEvent = "ctx_session_event" + // MCPToolRemind is the MCP tool name for listing reminders. + MCPToolRemind = "ctx_remind" + + // MCPPromptSessionStart is the MCP prompt name for session initialization. + MCPPromptSessionStart = "ctx-session-start" + // MCPPromptAddDecision is the MCP prompt name for recording decisions. + MCPPromptAddDecision = "ctx-add-decision" + // MCPPromptAddLearning is the MCP prompt name for recording learnings. + MCPPromptAddLearning = "ctx-add-learning" + // MCPPromptReflect is the MCP prompt name for session reflection. + MCPPromptReflect = "ctx-reflect" + // MCPPromptCheckpoint is the MCP prompt name for session checkpoint. + MCPPromptCheckpoint = "ctx-checkpoint" + + // MCPNotifyResourcesUpdated is the MCP notification for resource changes. + MCPNotifyResourcesUpdated = "notifications/resources/updated" ) 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..4e653b55 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 // diff --git a/internal/mcp/prompts.go b/internal/mcp/prompts.go new file mode 100644 index 00000000..f146efeb --- /dev/null +++ b/internal/mcp/prompts.go @@ -0,0 +1,226 @@ +// / 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" + + "github.com/ActiveMemory/ctx/internal/assets" + ctxCfg "github.com/ActiveMemory/ctx/internal/config/ctx" + "github.com/ActiveMemory/ctx/internal/config/mcp" + "github.com/ActiveMemory/ctx/internal/config/token" + "github.com/ActiveMemory/ctx/internal/context" +) + +// promptDefs defines all available MCP prompts. +var promptDefs = []Prompt{ + { + Name: mcp.MCPPromptSessionStart, + Description: assets.TextDesc(assets.TextDescKeyMCPPromptSessionStartDesc), + }, + { + Name: mcp.MCPPromptAddDecision, + Description: assets.TextDesc(assets.TextDescKeyMCPPromptAddDecisionDesc), + Arguments: []PromptArgument{ + {Name: "content", Description: assets.TextDesc(assets.TextDescKeyMCPPromptArgDecisionTitle), Required: true}, + {Name: "context", Description: assets.TextDesc(assets.TextDescKeyMCPPromptArgDecisionCtx), Required: true}, + {Name: "rationale", Description: assets.TextDesc(assets.TextDescKeyMCPPromptArgDecisionRat), Required: true}, + {Name: "consequences", Description: assets.TextDesc(assets.TextDescKeyMCPPromptArgDecisionConseq), Required: true}, + }, + }, + { + Name: mcp.MCPPromptAddLearning, + Description: assets.TextDesc(assets.TextDescKeyMCPPromptAddLearningDesc), + Arguments: []PromptArgument{ + {Name: "content", Description: assets.TextDesc(assets.TextDescKeyMCPPromptArgLearningTitle), Required: true}, + {Name: "context", Description: assets.TextDesc(assets.TextDescKeyMCPPromptArgLearningCtx), Required: true}, + {Name: "lesson", Description: assets.TextDesc(assets.TextDescKeyMCPPromptArgLearningLesson), Required: true}, + {Name: "application", Description: assets.TextDesc(assets.TextDescKeyMCPPromptArgLearningApp), Required: true}, + }, + }, + { + Name: mcp.MCPPromptReflect, + Description: assets.TextDesc(assets.TextDescKeyMCPPromptReflectDesc), + }, + { + Name: mcp.MCPPromptCheckpoint, + Description: assets.TextDesc(assets.TextDescKeyMCPPromptCheckpointDesc), + }, +} + +// 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, assets.TextDesc(assets.TextDescKeyMCPInvalidParams)) + } + + switch params.Name { + case mcp.MCPPromptSessionStart: + return s.promptSessionStart(req.ID) + case mcp.MCPPromptAddDecision: + return s.promptAddDecision(req.ID, params.Arguments) + case mcp.MCPPromptAddLearning: + return s.promptAddLearning(req.ID, params.Arguments) + case mcp.MCPPromptReflect: + return s.promptReflect(req.ID) + case mcp.MCPPromptCheckpoint: + return s.promptCheckpoint(req.ID) + default: + return s.error(req.ID, errCodeNotFound, + fmt.Sprintf(assets.TextDesc(assets.TextDescKeyMCPUnknownPrompt), 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(assets.TextDesc(assets.TextDescKeyMCPLoadContext), err)) + } + + var sb strings.Builder + sb.WriteString(assets.TextDesc(assets.TextDescKeyMCPPromptSessionStartHeader)) + 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, assets.TextDesc(assets.TextDescKeyMCPPromptSectionFormat), + fileName, string(f.Content)) + } + + sb.WriteString(token.NewlineLF) + sb.WriteString(assets.TextDesc(assets.TextDescKeyMCPPromptSessionStartFooter)) + + return s.ok(id, GetPromptResult{ + Description: assets.TextDesc(assets.TextDescKeyMCPPromptSessionStartResultD), + Messages: []PromptMessage{ + { + Role: "user", + Content: ToolContent{Type: mcp.MCPContentTypeText, 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"] + + fieldFmt := assets.TextDesc(assets.TextDescKeyMCPPromptAddDecisionFieldFmt) + + var sb strings.Builder + sb.WriteString(assets.TextDesc(assets.TextDescKeyMCPPromptAddDecisionHeader)) + sb.WriteString(token.NewlineLF) + sb.WriteString(token.NewlineLF) + fmt.Fprintf(&sb, fieldFmt+"%s", "Decision", content, token.NewlineLF) + fmt.Fprintf(&sb, fieldFmt+"%s", "Context", ctx, token.NewlineLF) + fmt.Fprintf(&sb, fieldFmt+"%s", "Rationale", rationale, token.NewlineLF) + fmt.Fprintf(&sb, fieldFmt+"%s", "Consequences", consequences, token.NewlineLF) + sb.WriteString(token.NewlineLF) + sb.WriteString(assets.TextDesc(assets.TextDescKeyMCPPromptAddDecisionFooter)) + + return s.ok(id, GetPromptResult{ + Description: assets.TextDesc(assets.TextDescKeyMCPPromptAddDecisionResultD), + Messages: []PromptMessage{ + { + Role: "user", + Content: ToolContent{Type: mcp.MCPContentTypeText, 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"] + + fieldFmt := assets.TextDesc(assets.TextDescKeyMCPPromptAddLearningFieldFmt) + + var sb strings.Builder + sb.WriteString(assets.TextDesc(assets.TextDescKeyMCPPromptAddLearningHeader)) + sb.WriteString(token.NewlineLF) + sb.WriteString(token.NewlineLF) + fmt.Fprintf(&sb, fieldFmt+"%s", "Learning", content, token.NewlineLF) + fmt.Fprintf(&sb, fieldFmt+"%s", "Context", ctx, token.NewlineLF) + fmt.Fprintf(&sb, fieldFmt+"%s", "Lesson", lesson, token.NewlineLF) + fmt.Fprintf(&sb, fieldFmt+"%s", "Application", application, token.NewlineLF) + sb.WriteString(token.NewlineLF) + sb.WriteString(assets.TextDesc(assets.TextDescKeyMCPPromptAddLearningFooter)) + + return s.ok(id, GetPromptResult{ + Description: assets.TextDesc(assets.TextDescKeyMCPPromptAddLearningResultD), + Messages: []PromptMessage{ + { + Role: "user", + Content: ToolContent{Type: mcp.MCPContentTypeText, Text: sb.String()}, + }, + }, + }) +} + +// promptReflect reviews the current session for outstanding items. +func (s *Server) promptReflect(id json.RawMessage) *Response { + return s.ok(id, GetPromptResult{ + Description: assets.TextDesc(assets.TextDescKeyMCPPromptReflectResultD), + Messages: []PromptMessage{ + { + Role: "user", + Content: ToolContent{Type: mcp.MCPContentTypeText, Text: assets.TextDesc(assets.TextDescKeyMCPPromptReflectBody)}, + }, + }, + }) +} + +// 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(assets.TextDesc(assets.TextDescKeyMCPPromptCheckpointHeader)) + sb.WriteString(token.NewlineLF) + sb.WriteString(token.NewlineLF) + + fmt.Fprintf(&sb, assets.TextDesc(assets.TextDescKeyMCPPromptCheckpointStatsFormat), + s.session.toolCalls, adds, pending) + + sb.WriteString(token.NewlineLF) + sb.WriteString(assets.TextDesc(assets.TextDescKeyMCPPromptCheckpointSteps)) + + return s.ok(id, GetPromptResult{ + Description: assets.TextDesc(assets.TextDescKeyMCPPromptCheckpointResultD), + Messages: []PromptMessage{ + { + Role: "user", + Content: ToolContent{Type: mcp.MCPContentTypeText, 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..331bd807 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,166 @@ 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. +// +// Goroutine lifecycle: the poller goroutine is started on the first +// subscription and stopped when the last subscription is removed or +// when Server.Serve returns (via poller.stop in the deferred cleanup). +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: mcp.MCPJSONRPCVersion, + Method: mcp.MCPNotifyResourcesUpdated, + 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, assets.TextDesc(assets.TextDescKeyMCPInvalidParams)) + } + if params.URI == "" { + return s.error(req.ID, errCodeInvalidArg, assets.TextDesc(assets.TextDescKeyMCPURIRequired)) + } + 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, assets.TextDesc(assets.TextDescKeyMCPInvalidParams)) + } + if params.URI == "" { + return s.error(req.ID, errCodeInvalidArg, assets.TextDesc(assets.TextDescKeyMCPURIRequired)) + } + 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..a60df2ac --- /dev/null +++ b/internal/mcp/session.go @@ -0,0 +1,63 @@ +// / 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. +// +// Thread-safety: sessionState is only accessed from the main request +// loop (single goroutine). If future work introduces concurrent access, +// a mutex should be added here. +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..aaf85dfb 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,122 @@ var toolDefs = []Tool{ InputSchema: InputSchema{Type: mcp.MCPSchemaObject}, Annotations: &ToolAnnotations{ReadOnlyHint: true}, }, + { + Name: mcp.MCPToolRecall, + Description: assets.TextDesc(assets.TextDescKeyMCPToolRecallDesc), + InputSchema: InputSchema{ + Type: mcp.MCPSchemaObject, + Properties: map[string]Property{ + "limit": { + Type: mcp.MCPSchemaNumber, + Description: assets.TextDesc(assets.TextDescKeyMCPToolPropLimit), + }, + "since": { + Type: mcp.MCPSchemaString, + Description: assets.TextDesc(assets.TextDescKeyMCPToolPropSince), + }, + }, + }, + Annotations: &ToolAnnotations{ReadOnlyHint: true}, + }, + { + Name: mcp.MCPToolWatchUpdate, + Description: assets.TextDesc(assets.TextDescKeyMCPToolWatchUpdateDesc), + InputSchema: InputSchema{ + Type: mcp.MCPSchemaObject, + Properties: map[string]Property{ + "type": { + Type: mcp.MCPSchemaString, + Description: assets.TextDesc(assets.TextDescKeyMCPToolPropEntryType), + }, + "content": { + Type: mcp.MCPSchemaString, + Description: assets.TextDesc(assets.TextDescKeyMCPToolPropMainContent), + }, + "context": { + Type: mcp.MCPSchemaString, + Description: assets.TextDesc(assets.TextDescKeyMCPToolPropCtxBg), + }, + "rationale": { + Type: mcp.MCPSchemaString, + Description: assets.TextDesc(assets.TextDescKeyMCPToolPropRationale), + }, + "consequences": { + Type: mcp.MCPSchemaString, + Description: assets.TextDesc(assets.TextDescKeyMCPToolPropConseq), + }, + "lesson": { + Type: mcp.MCPSchemaString, + Description: assets.TextDesc(assets.TextDescKeyMCPToolPropLesson), + }, + "application": { + Type: mcp.MCPSchemaString, + Description: assets.TextDesc(assets.TextDescKeyMCPToolPropApplication), + }, + }, + Required: []string{"type", "content"}, + }, + Annotations: &ToolAnnotations{}, + }, + { + Name: mcp.MCPToolCompact, + Description: assets.TextDesc(assets.TextDescKeyMCPToolCompactDesc), + InputSchema: InputSchema{ + Type: mcp.MCPSchemaObject, + Properties: map[string]Property{ + "archive": { + Type: mcp.MCPSchemaBoolean, + Description: assets.TextDesc(assets.TextDescKeyMCPToolPropArchive), + }, + }, + }, + Annotations: &ToolAnnotations{}, + }, + { + Name: mcp.MCPToolNext, + Description: assets.TextDesc(assets.TextDescKeyMCPToolNextDesc), + InputSchema: InputSchema{Type: mcp.MCPSchemaObject}, + Annotations: &ToolAnnotations{ReadOnlyHint: true}, + }, + { + Name: mcp.MCPToolCheckTaskCompletion, + Description: assets.TextDesc(assets.TextDescKeyMCPToolCheckTaskDesc), + InputSchema: InputSchema{ + Type: mcp.MCPSchemaObject, + Properties: map[string]Property{ + "recent_action": { + Type: mcp.MCPSchemaString, + Description: assets.TextDesc(assets.TextDescKeyMCPToolPropRecentAct), + }, + }, + }, + Annotations: &ToolAnnotations{ReadOnlyHint: true}, + }, + { + Name: mcp.MCPToolSessionEvent, + Description: assets.TextDesc(assets.TextDescKeyMCPToolSessionDesc), + InputSchema: InputSchema{ + Type: mcp.MCPSchemaObject, + Properties: map[string]Property{ + "type": { + Type: mcp.MCPSchemaString, + Description: assets.TextDesc(assets.TextDescKeyMCPToolPropEventType), + }, + "caller": { + Type: mcp.MCPSchemaString, + Description: assets.TextDesc(assets.TextDescKeyMCPToolPropCaller), + }, + }, + Required: []string{"type"}, + }, + Annotations: &ToolAnnotations{}, + }, + { + Name: mcp.MCPToolRemind, + Description: assets.TextDesc(assets.TextDescKeyMCPToolRemindDesc), + InputSchema: InputSchema{Type: mcp.MCPSchemaObject}, + Annotations: &ToolAnnotations{ReadOnlyHint: true}, + }, } // handleToolsList returns all available MCP tools. @@ -110,6 +236,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 +247,20 @@ func (s *Server) handleToolsCall(req Request) *Response { return s.toolComplete(req.ID, params.Arguments) case mcp.MCPToolDrift: return s.toolDrift(req.ID) + case mcp.MCPToolRecall: + return s.toolRecall(req.ID, params.Arguments) + case mcp.MCPToolWatchUpdate: + return s.toolWatchUpdate(req.ID, params.Arguments) + case mcp.MCPToolCompact: + return s.toolCompact(req.ID, params.Arguments) + case mcp.MCPToolNext: + return s.toolNext(req.ID) + case mcp.MCPToolCheckTaskCompletion: + return s.toolCheckTaskCompletion(req.ID, params.Arguments) + case mcp.MCPToolSessionEvent: + return s.toolSessionEvent(req.ID, params.Arguments) + case mcp.MCPToolRemind: + return s.toolRemind(req.ID) default: return s.error(req.ID, errCodeNotFound, fmt.Sprintf(assets.TextDesc(assets.TextDescKeyMCPUnknownTool), params.Name)) @@ -153,6 +295,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(assets.TextDesc(assets.TextDescKeyMCPBoundaryViolation), err)) + } + entryType, _ := args[cli.AttrType].(string) content, _ := args["content"].(string) @@ -203,6 +349,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(assets.TextDesc(assets.TextDescKeyMCPBoundaryViolation), err)) + } + query, _ := args["query"].(string) if query == "" { return s.toolError(id, assets.TextDesc(assets.TextDescKeyMCPQueryRequired)) @@ -270,3 +420,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(assets.TextDesc(assets.TextDescKeyMCPInvalidSinceDate), parseErr)) + } + sinceFilter = parsed + } + + sessions, err := parser.FindSessions() + if err != nil { + return s.toolError(id, fmt.Sprintf(assets.TextDesc(assets.TextDescKeyMCPFindSessionsFailed), 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, assets.TextDesc(assets.TextDescKeyMCPNoSessions)) + } + + var sb strings.Builder + fmt.Fprintf(&sb, assets.TextDesc(assets.TextDescKeyMCPSessionsFoundFormat)+"%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(assets.TextDesc(assets.TextDescKeyMCPBoundaryViolation), err)) + } + + entryType, _ := args["type"].(string) + content, _ := args["content"].(string) + + if entryType == "" || content == "" { + return s.toolError(id, assets.TextDesc(assets.TextDescKeyMCPTypeContentRequired)) + } + + // 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(assets.TextDesc(assets.TextDescKeyMCPWatchCompletedFormat), completedTask)+token.NewlineLF+ + assets.TextDesc(assets.TextDescKeyMCPReviewStatus)) + } + + 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(assets.TextDesc(assets.TextDescKeyMCPWriteFailed), 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(assets.TextDesc(assets.TextDescKeyMCPWroteFormat), entryType, fileName)+token.NewlineLF+ + assets.TextDesc(assets.TextDescKeyMCPReviewStatus)) +} + +// 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(assets.TextDesc(assets.TextDescKeyMCPBoundaryViolation), 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(assets.TextDesc(assets.TextDescKeyMCPLoadContext), 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(assets.TextDesc(assets.TextDescKeyMCPWriteFailed), 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, assets.TextDesc(assets.TextDescKeyMCPCompactClean)) + } + + fmt.Fprintf(&sb, "%s"+assets.TextDesc(assets.TextDescKeyMCPCompactedFormat)+"%s", + token.NewlineLF, changes, token.NewlineLF) + sb.WriteString(assets.TextDesc(assets.TextDescKeyMCPReviewStatus)) + + 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(assets.TextDesc(assets.TextDescKeyMCPLoadContext), err)) + } + + tasksFile := ctx.File(ctxCfg.Task) + if tasksFile == nil { + return s.toolOK(id, assets.TextDesc(assets.TextDescKeyMCPNoTasks)) + } + + 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( + assets.TextDesc(assets.TextDescKeyMCPNextTaskFormat), pendingIdx, task.Content(match))) + } + + return s.toolOK(id, assets.TextDesc(assets.TextDescKeyMCPAllTasksComplete)) +} + +// 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(assets.TextDesc(assets.TextDescKeyMCPLoadContext), 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( + assets.TextDesc(assets.TextDescKeyMCPCheckTaskFormat)+token.NewlineLF+ + assets.TextDesc(assets.TextDescKeyMCPCheckTaskHint), 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, assets.TextDesc(assets.TextDescKeyMCPEventTypeRequired)) + } + + switch eventType { + case "start": + s.session = newSessionState(s.contextDir) + if caller, ok := args["caller"].(string); ok && caller != "" { + return s.toolOK(id, fmt.Sprintf( + assets.TextDesc(assets.TextDescKeyMCPSessionStartedCallerFormat), caller, s.contextDir)) + } + return s.toolOK(id, fmt.Sprintf( + assets.TextDesc(assets.TextDescKeyMCPSessionStartedFormat), s.contextDir)) + + case "end": + pending := s.session.pendingCount() + var sb strings.Builder + sb.WriteString(assets.TextDesc(assets.TextDescKeyMCPSessionEnding)) + sb.WriteString(token.NewlineLF) + + if pending > 0 { + fmt.Fprintf(&sb, assets.TextDesc(assets.TextDescKeyMCPPendingUpdatesFormat)+"%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(assets.TextDesc(assets.TextDescKeyMCPReviewPending)) + } else { + sb.WriteString(assets.TextDesc(assets.TextDescKeyMCPNoPending)) + } + + fmt.Fprintf(&sb, "%s"+assets.TextDesc(assets.TextDescKeyMCPSessionStatsFormat), + token.NewlineLF, s.session.toolCalls, totalAdds(s.session.addsPerformed)) + + return s.toolOK(id, sb.String()) + + default: + return s.toolError(id, + fmt.Sprintf(assets.TextDesc(assets.TextDescKeyMCPUnknownEventType), 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(assets.TextDesc(assets.TextDescKeyMCPReadRemindersFailed), readErr)) + } + + if len(reminders) == 0 { + return s.toolOK(id, assets.TextDesc(assets.TextDescKeyMCPNoReminders)) + } + + today := time.Now().Format("2006-01-02") + var sb strings.Builder + fmt.Fprintf(&sb, assets.TextDesc(assets.TextDescKeyMCPRemindersFormat)+"%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..eef82a4f 100644 --- a/internal/mcp/types.go +++ b/internal/mcp/types.go @@ -6,16 +6,26 @@ package mcp -import "io" +import ( + "io" + "sync" +) // Server is an MCP server that exposes ctx context over JSON-RPC 2.0. // // It reads JSON-RPC requests from stdin and writes responses to stdout, // following the Model Context Protocol specification. +// +// Thread-safety: outMu serialises all writes to out (main loop + poller +// goroutine). The main loop itself is single-threaded, so request +// dispatch and session mutations need no additional locking. type Server struct { contextDir string version string tokenBudget int out io.Writer + outMu sync.Mutex // guards all writes to out 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 + + + + +