diff --git a/.context/ARCHITECTURE.md b/.context/ARCHITECTURE.md index 29a160f6..4867273b 100644 --- a/.context/ARCHITECTURE.md +++ b/.context/ARCHITECTURE.md @@ -51,7 +51,7 @@ upward from them. The `rc` package mediates config resolution; | `internal/crypto` | AES-256-GCM encryption (stdlib only) | `Encrypt()`, `Decrypt()`, `GenerateKey()` | | `internal/sysinfo` | OS metrics with platform build tags | `Collect()`, `Evaluate()`, `MaxSeverity()` | - + ### Core Packages | Package | Purpose | Key Exports | @@ -66,6 +66,7 @@ upward from them. The `rc` package mediates config resolution; | `internal/claude` | Claude Code integration types and skill access | `Skills()`, `SkillContent()` | | `internal/notify` | Webhook notifications with encrypted URL storage | `Send()`, `LoadWebhook()`, `SaveWebhook()` | | `internal/journal/state` | Journal processing pipeline state (JSON) | `Load()`, `Save()`, `Mark*()` | +| `internal/mcp` | MCP server (JSON-RPC 2.0 over stdin/stdout) | `NewServer()`, `Serve()` | | `internal/memory` | Memory bridge: discover, mirror, diff MEMORY.md | `DiscoverMemoryPath()`, `Sync()`, `Diff()` | @@ -105,6 +106,7 @@ upward from them. The `rc` package mediates config resolution; | `system` | System diagnostics, resource monitoring, hook plumbing | | `task` | Task archival and snapshots | | `watch` | Monitor stdin for context-update tags and apply them | +| `mcp` | MCP server for AI tool integration (stdin/stdout JSON-RPC) | ## Data Flow Diagrams diff --git a/docs/cli/index.md b/docs/cli/index.md index e40453e0..fa7d8177 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -77,6 +77,7 @@ own guards and no-op gracefully. | [`ctx why`](tools.md#ctx-why) | Read the philosophy behind ctx | | [`ctx site`](tools.md#ctx-site) | Site management (feed generation) | | [`ctx doctor`](doctor.md#ctx-doctor) | Structural health check (hooks, drift, config) | +| [`ctx mcp`](mcp.md#ctx-mcp) | MCP server for AI tool integration (stdin/stdout) | | [`ctx config`](config.md#ctx-config) | Manage runtime configuration profiles | | [`ctx system`](system.md#ctx-system) | System diagnostics and hook commands | diff --git a/docs/cli/mcp.md b/docs/cli/mcp.md new file mode 100644 index 00000000..585bf98a --- /dev/null +++ b/docs/cli/mcp.md @@ -0,0 +1,154 @@ +--- +# / ctx: https://ctx.ist +# ,'`./ do you remember? +# `.,'\ +# \ Copyright 2026-present Context contributors. +# SPDX-License-Identifier: Apache-2.0 + +title: MCP Server +icon: lucide/plug +--- + +## `ctx mcp` + +Run ctx as a [Model Context Protocol](https://modelcontextprotocol.io) +(MCP) server. MCP is a standard protocol that lets AI tools discover +and consume context from external sources via JSON-RPC 2.0 over +stdin/stdout. + +This makes ctx accessible to **any MCP-compatible AI tool** without +custom hooks or integrations: + +- Claude Desktop +- Cursor +- Windsurf +- VS Code Copilot +- Any tool supporting MCP + +### `ctx mcp serve` + +Start the MCP server. This command reads JSON-RPC 2.0 requests from +stdin and writes responses to stdout. It is intended to be launched +by MCP clients, not run directly. + +``` +ctx mcp serve +``` + +**Flags:** None. The server uses the configured context directory +(from `--context-dir`, `CTX_DIR`, `.ctxrc`, or the default `.context`). + +--- + +## Configuration + +### Claude Desktop + +Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "ctx": { + "command": "ctx", + "args": ["mcp", "serve"] + } + } +} +``` + +### Cursor + +Add to `.cursor/mcp.json` in your project: + +```json +{ + "mcpServers": { + "ctx": { + "command": "ctx", + "args": ["mcp", "serve"] + } + } +} +``` + +### VS Code (Copilot) + +Add to `.vscode/mcp.json`: + +```json +{ + "servers": { + "ctx": { + "command": "ctx", + "args": ["mcp", "serve"] + } + } +} +``` + +--- + +## Resources + +Resources expose context files as read-only content. Each resource +has a URI, name, and returns Markdown text. + +| URI | Name | Description | +|------------------------------|----------------|----------------------------------------------| +| `ctx://context/constitution` | constitution | Hard rules that must never be violated | +| `ctx://context/tasks` | tasks | Current work items and their status | +| `ctx://context/conventions` | conventions | Code patterns and standards | +| `ctx://context/architecture` | architecture | System architecture documentation | +| `ctx://context/decisions` | decisions | Architectural decisions with rationale | +| `ctx://context/learnings` | learnings | Gotchas, tips, and lessons learned | +| `ctx://context/glossary` | glossary | Project-specific terminology | +| `ctx://context/agent` | agent | All files assembled in priority read order | + +The `agent` resource assembles all non-empty context files into a +single Markdown document, ordered by the configured read priority. + +--- + +## Tools + +Tools expose ctx commands as callable operations. Each tool accepts +JSON arguments and returns text results. + +### `ctx_status` + +Show context health: file count, token estimate, and per-file summary. + +**Arguments:** None. + +### `ctx_add` + +Add a task, decision, learning, or convention to the context. + +| Argument | Type | Required | Description | +|----------------|--------|----------|------------------------------------------| +| `type` | string | Yes | Entry type: task, decision, learning, convention | +| `content` | string | Yes | Title or main content | +| `priority` | string | No | Priority level (tasks only): high, medium, low | +| `context` | string | Conditional | Context field (decisions and 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_complete` + +Mark a task as done by number or text match. + +| Argument | Type | Required | Description | +|----------|--------|----------|------------------------------------------| +| `query` | string | Yes | Task number (e.g. "1") or search text | + +### `ctx_drift` + +Detect stale or invalid context. Returns violations, warnings, and +passed checks. + +**Arguments:** None. + + diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index a22114af..38b50736 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -35,6 +35,7 @@ import ( "github.com/ActiveMemory/ctx/internal/cli/learnings" "github.com/ActiveMemory/ctx/internal/cli/load" "github.com/ActiveMemory/ctx/internal/cli/loop" + climcp "github.com/ActiveMemory/ctx/internal/cli/mcp" climemory "github.com/ActiveMemory/ctx/internal/cli/memory" "github.com/ActiveMemory/ctx/internal/cli/notify" "github.com/ActiveMemory/ctx/internal/cli/pad" @@ -89,6 +90,7 @@ func Initialize(cmd *cobra.Command) *cobra.Command { learnings.Cmd, task.Cmd, loop.Cmd, + climcp.Cmd, climemory.Cmd, notify.Cmd, pad.Cmd, diff --git a/internal/cli/add/run.go b/internal/cli/add/run.go index a6845847..9ca9a6c5 100644 --- a/internal/cli/add/run.go +++ b/internal/cli/add/run.go @@ -74,7 +74,11 @@ func WriteEntry(params EntryParams) error { return errUnknownType(fType) } - filePath := filepath.Join(rc.ContextDir(), fileName) + contextDir := params.ContextDir + if contextDir == "" { + contextDir = rc.ContextDir() + } + filePath := filepath.Join(contextDir, fileName) // Check if the file exists if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) { diff --git a/internal/cli/add/types.go b/internal/cli/add/types.go index 7fb644c6..d6ed4343 100644 --- a/internal/cli/add/types.go +++ b/internal/cli/add/types.go @@ -28,6 +28,7 @@ type EntryParams struct { Consequences string Lesson string Application string + ContextDir string } // addConfig holds all flags for the add command. diff --git a/internal/cli/complete/run.go b/internal/cli/complete/run.go index 1633fa46..3124bf36 100644 --- a/internal/cli/complete/run.go +++ b/internal/cli/complete/run.go @@ -21,32 +21,33 @@ import ( "github.com/ActiveMemory/ctx/internal/task" ) -// runComplete executes the complete command logic. -// -// Finds a task in TASKS.md by number or text match and marks it complete -// by changing "- [ ]" to "- [x]". +// CompleteTask finds a task in TASKS.md by number or text match and marks +// it complete by changing "- [ ]" to "- [x]". // // Parameters: -// - cmd: Cobra command for output messages -// - args: Command arguments; args[0] is the task number or search text +// - query: Task number (e.g. "1") or search text to match +// - contextDir: Path to .context/ directory; if empty, uses rc.ContextDir() // // Returns: +// - string: The text of the completed task // - error: Non-nil if the task is not found, multiple matches, or file // operations fail -func runComplete(cmd *cobra.Command, args []string) error { - query := args[0] +func CompleteTask(query, contextDir string) (string, error) { + if contextDir == "" { + contextDir = rc.ContextDir() + } - filePath := filepath.Join(rc.ContextDir(), config.FileTask) + filePath := filepath.Join(contextDir, config.FileTask) // Check if the file exists if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) { - return fmt.Errorf("TASKS.md not found. Run 'ctx init' first") + return "", fmt.Errorf("TASKS.md not found") } // Read existing content content, err := os.ReadFile(filepath.Clean(filePath)) if err != nil { - return fmt.Errorf("failed to read TASKS.md: %w", err) + return "", fmt.Errorf("failed to read TASKS.md: %w", err) } // Parse tasks and find matching one @@ -81,9 +82,8 @@ func runComplete(cmd *cobra.Command, args []string) error { strings.ToLower(taskText), strings.ToLower(query), ) { if matchedLine != -1 { - // Multiple matches - be more specific - return fmt.Errorf( - "multiple tasks match %q. Be more specific or use task number", + return "", fmt.Errorf( + "multiple tasks match %q; be more specific or use task number", query, ) } @@ -94,14 +94,7 @@ func runComplete(cmd *cobra.Command, args []string) error { } if matchedLine == -1 { - if isNumber { - return fmt.Errorf( - "task #%d not found. Use 'ctx status' to see tasks", taskNumber, - ) - } - return fmt.Errorf( - "no task matching %q found. Use 'ctx status' to see tasks", query, - ) + return "", fmt.Errorf("no task matching %q found", query) } // Mark the task as complete @@ -112,7 +105,17 @@ func runComplete(cmd *cobra.Command, args []string) error { // Write back newContent := strings.Join(lines, config.NewlineLF) if writeErr := os.WriteFile(filePath, []byte(newContent), config.PermFile); writeErr != nil { - return fmt.Errorf("failed to write TASKS.md: %w", writeErr) + return "", fmt.Errorf("failed to write TASKS.md: %w", writeErr) + } + + return matchedTask, nil +} + +// runComplete executes the complete command logic. +func runComplete(cmd *cobra.Command, args []string) error { + matchedTask, err := CompleteTask(args[0], "") + if err != nil { + return err } green := color.New(color.FgGreen).SprintFunc() diff --git a/internal/cli/mcp/mcp.go b/internal/cli/mcp/mcp.go new file mode 100644 index 00000000..bee258f7 --- /dev/null +++ b/internal/cli/mcp/mcp.go @@ -0,0 +1,44 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package mcp provides the CLI command for running the MCP server. +package mcp + +import ( + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/config" + internalmcp "github.com/ActiveMemory/ctx/internal/mcp" + "github.com/ActiveMemory/ctx/internal/rc" +) + +// Cmd returns the mcp command group. +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "Model Context Protocol server", + Long: "Run ctx as an MCP server over stdin/stdout.\n\nThe MCP server exposes context files as resources and ctx commands as tools,\nenabling any MCP-compatible AI tool to access project context.", + } + + cmd.AddCommand(serveCmd()) + + return cmd +} + +// serveCmd returns the mcp serve subcommand. +func serveCmd() *cobra.Command { + return &cobra.Command{ + Use: "serve", + Short: "Start the MCP server (stdin/stdout)", + Long: "Start the MCP server, communicating via JSON-RPC 2.0 over stdin/stdout.\n\nThis command is intended to be invoked by MCP clients (AI tools), not\nrun directly by users. Configure your AI tool to run 'ctx mcp serve'\nas an MCP server.", + Annotations: map[string]string{config.AnnotationSkipInit: "true"}, + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + srv := internalmcp.NewServer(rc.ContextDir()) + return srv.Serve() + }, + } +} diff --git a/internal/mcp/doc.go b/internal/mcp/doc.go new file mode 100644 index 00000000..a9f535bd --- /dev/null +++ b/internal/mcp/doc.go @@ -0,0 +1,60 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package mcp implements a Model Context Protocol (MCP) server for ctx. +// +// MCP is a standard protocol (JSON-RPC 2.0 over stdin/stdout) that allows +// AI tools to discover and consume context from external sources. This +// package exposes ctx's context files as MCP resources and ctx commands +// as MCP tools, enabling any MCP-compatible AI tool (Claude Desktop, +// Cursor, Windsurf, VS Code Copilot, etc.) to access project context +// without tool-specific integrations. +// +// # Architecture +// +// AI Tool → stdin → MCP Server → ctx internals +// AI Tool ← stdout ← MCP Server ← ctx internals +// +// The server communicates via JSON-RPC 2.0 over stdin/stdout. +// +// # Resources +// +// Resources expose context files as read-only content: +// +// ctx://context/tasks → TASKS.md +// ctx://context/decisions → DECISIONS.md +// ctx://context/conventions → CONVENTIONS.md +// ctx://context/constitution → CONSTITUTION.md +// ctx://context/architecture → ARCHITECTURE.md +// ctx://context/learnings → LEARNINGS.md +// ctx://context/glossary → GLOSSARY.md +// ctx://context/agent → All files assembled in read order +// +// # Tools +// +// 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 +// +// # Usage +// +// server := mcp.NewServer(contextDir) +// server.Serve() // blocks, reads stdin, writes stdout +// +// # Design Invariants +// +// This implementation preserves all six ctx design invariants: +// +// - Markdown-on-filesystem: all state remains in .context/ files +// - Zero runtime dependencies: no external services required +// - Deterministic assembly: same files + budget = same output +// - Human authority: tools propose changes through file writes +// - Local-first: no network required for core operation +// - No telemetry: no data leaves the local machine +package mcp diff --git a/internal/mcp/protocol.go b/internal/mcp/protocol.go new file mode 100644 index 00000000..d25a976c --- /dev/null +++ b/internal/mcp/protocol.go @@ -0,0 +1,186 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package mcp + +import "encoding/json" + +// JSON-RPC 2.0 message types for the Model Context Protocol. +// +// See: https://spec.modelcontextprotocol.io/ + +// Request represents a JSON-RPC 2.0 request message. +type Request struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +// Response represents a JSON-RPC 2.0 response message. +type Response struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Result interface{} `json:"result,omitempty"` + Error *RPCError `json:"error,omitempty"` +} + +// Notification represents a JSON-RPC 2.0 notification (no id). +type Notification struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params interface{} `json:"params,omitempty"` +} + +// RPCError represents a JSON-RPC 2.0 error object. +type RPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +// Standard JSON-RPC error codes. +const ( + errCodeParse = -32700 + errCodeInvalidReq = -32600 + errCodeNotFound = -32601 + errCodeInvalidArg = -32602 + errCodeInternal = -32603 +) + +// MCP protocol version. +const protocolVersion = "2024-11-05" + +// --- Initialization types --- + +// InitializeParams contains client information sent during initialization. +type InitializeParams struct { + ProtocolVersion string `json:"protocolVersion"` + Capabilities ClientCaps `json:"capabilities"` + ClientInfo AppInfo `json:"clientInfo"` +} + +// ClientCaps describes client capabilities. +type ClientCaps struct { + Roots *struct{} `json:"roots,omitempty"` + Sampling *struct{} `json:"sampling,omitempty"` +} + +// AppInfo identifies a client or server application. +type AppInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// InitializeResult is the server's response to initialize. +type InitializeResult struct { + ProtocolVersion string `json:"protocolVersion"` + Capabilities ServerCaps `json:"capabilities"` + ServerInfo AppInfo `json:"serverInfo"` +} + +// ServerCaps describes server capabilities. +type ServerCaps struct { + Resources *ResourcesCap `json:"resources,omitempty"` + Tools *ToolsCap `json:"tools,omitempty"` +} + +// ResourcesCap indicates the server supports resources. +type ResourcesCap struct { + Subscribe bool `json:"subscribe,omitempty"` + ListChanged bool `json:"listChanged,omitempty"` +} + +// ToolsCap indicates the server supports tools. +type ToolsCap struct { + ListChanged bool `json:"listChanged,omitempty"` +} + +// --- Resource types --- + +// Resource describes a single MCP resource. +type Resource struct { + URI string `json:"uri"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + MimeType string `json:"mimeType,omitempty"` +} + +// ResourceListResult is returned by resources/list. +type ResourceListResult struct { + Resources []Resource `json:"resources"` +} + +// ReadResourceParams is sent with resources/read. +type ReadResourceParams struct { + URI string `json:"uri"` +} + +// ResourceContent represents the content of a resource. +type ResourceContent struct { + URI string `json:"uri"` + MimeType string `json:"mimeType,omitempty"` + Text string `json:"text,omitempty"` +} + +// ReadResourceResult is returned by resources/read. +type ReadResourceResult struct { + Contents []ResourceContent `json:"contents"` +} + +// --- Tool types --- + +// ToolAnnotations provides hints about a tool's behavior. +type ToolAnnotations struct { + ReadOnlyHint bool `json:"readOnlyHint,omitempty"` + DestructiveHint bool `json:"destructiveHint,omitempty"` + IdempotentHint bool `json:"idempotentHint,omitempty"` +} + +// Tool describes a single MCP tool. +type Tool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema InputSchema `json:"inputSchema"` + Annotations *ToolAnnotations `json:"annotations,omitempty"` +} + +// InputSchema describes the JSON Schema for tool inputs. +type InputSchema struct { + Type string `json:"type"` + Properties map[string]Property `json:"properties,omitempty"` + Required []string `json:"required,omitempty"` +} + +// Property describes a single property in a JSON Schema. +type Property struct { + Type string `json:"type"` + Description string `json:"description,omitempty"` + Enum []string `json:"enum,omitempty"` +} + +// ToolListResult is returned by tools/list. +type ToolListResult struct { + Tools []Tool `json:"tools"` +} + +// CallToolParams is sent with tools/call. +type CallToolParams struct { + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments,omitempty"` +} + +// ToolContent represents a piece of tool output. +type ToolContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` +} + +// CallToolResult is returned by tools/call. +type CallToolResult struct { + Content []ToolContent `json:"content"` + IsError bool `json:"isError,omitempty"` +} diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go new file mode 100644 index 00000000..87a2eadb --- /dev/null +++ b/internal/mcp/resources.go @@ -0,0 +1,165 @@ +// / 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/config" + "github.com/ActiveMemory/ctx/internal/context" +) + +// resourceMapping maps a context file name to its MCP resource URI suffix +// and human-readable description. +type resourceMapping struct { + file string + name string + desc string +} + +// resourceTable defines all individual context file resources. +var resourceTable = []resourceMapping{ + {config.FileConstitution, "constitution", "Hard rules that must never be violated"}, + {config.FileTask, "tasks", "Current work items and their status"}, + {config.FileConvention, "conventions", "Code patterns and standards"}, + {config.FileArchitecture, "architecture", "System architecture documentation"}, + {config.FileDecision, "decisions", "Architectural decisions with rationale"}, + {config.FileLearning, "learnings", "Gotchas, tips, and lessons learned"}, + {config.FileGlossary, "glossary", "Project-specific terminology"}, + {config.FileAgentPlaybook, "playbook", "How agents should use this system"}, +} + +// resourceURI builds a resource URI from a suffix. +func resourceURI(name string) string { + return "ctx://context/" + name +} + +// handleResourcesList returns all available MCP resources. +func (s *Server) handleResourcesList(req Request) *Response { + resources := make([]Resource, 0, len(resourceTable)+1) + + // Individual context files. + for _, rm := range resourceTable { + resources = append(resources, Resource{ + URI: resourceURI(rm.name), + Name: rm.name, + MimeType: "text/markdown", + Description: rm.desc, + }) + } + + // Assembled context packet (all files in read order). + resources = append(resources, Resource{ + URI: resourceURI("agent"), + Name: "agent", + MimeType: "text/markdown", + Description: "All context files assembled in priority read order", + }) + + return s.ok(req.ID, ResourceListResult{Resources: resources}) +} + +// handleResourcesRead returns the content of a requested resource. +func (s *Server) handleResourcesRead(req Request) *Response { + var params ReadResourceParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return s.error(req.ID, errCodeInvalidArg, "invalid params") + } + + ctx, err := context.Load(s.contextDir) + if err != nil { + return s.error(req.ID, errCodeInternal, + fmt.Sprintf("failed to load context: %v", err)) + } + + // Check for individual file resources. + for _, rm := range resourceTable { + if params.URI == resourceURI(rm.name) { + return s.readContextFile(req.ID, ctx, rm.file, params.URI) + } + } + + // Assembled agent packet. + if params.URI == resourceURI("agent") { + return s.readAgentPacket(req.ID, ctx) + } + + return s.error(req.ID, errCodeInvalidArg, + fmt.Sprintf("unknown resource: %s", params.URI)) +} + +// readContextFile returns the content of a single context file. +func (s *Server) readContextFile( + id json.RawMessage, ctx *context.Context, fileName, uri string, +) *Response { + f := ctx.File(fileName) + if f == nil { + return s.error(id, errCodeInvalidArg, + fmt.Sprintf("file not found: %s", fileName)) + } + + return s.ok(id, ReadResourceResult{ + Contents: []ResourceContent{{ + URI: uri, + MimeType: "text/markdown", + Text: string(f.Content), + }}, + }) +} + +// readAgentPacket assembles all context files in read order into a +// single response, respecting the configured token budget. +// +// Files are added in priority order (FileReadOrder). When the token +// budget would be exceeded, remaining files are listed as "Also noted" +// summaries instead of included in full. +func (s *Server) readAgentPacket( + id json.RawMessage, ctx *context.Context, +) *Response { + var sb strings.Builder + sb.WriteString("# Context Packet\n\n") + + tokensUsed := context.EstimateTokensString("# Context Packet\n\n") + budget := s.tokenBudget + var skipped []string + + for _, fileName := range config.FileReadOrder { + f := ctx.File(fileName) + if f == nil || f.IsEmpty { + continue + } + + section := fmt.Sprintf("---\n## %s\n\n%s\n\n", fileName, string(f.Content)) + sectionTokens := context.EstimateTokensString(section) + + if budget > 0 && tokensUsed+sectionTokens > budget { + skipped = append(skipped, fileName) + continue + } + + sb.WriteString(section) + tokensUsed += sectionTokens + } + + if len(skipped) > 0 { + sb.WriteString("---\n## Also Noted\n\n") + for _, name := range skipped { + fmt.Fprintf(&sb, "- %s (omitted for budget)\n", name) + } + sb.WriteString("\n") + } + + return s.ok(id, ReadResourceResult{ + Contents: []ResourceContent{{ + URI: resourceURI("agent"), + MimeType: "text/markdown", + Text: sb.String(), + }}, + }) +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 00000000..ae8feb24 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,180 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package mcp + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + + "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/rc" +) + +// 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. +type Server struct { + contextDir string + version string + tokenBudget int + out io.Writer + in io.Reader +} + +// NewServer creates a new MCP server for the given context directory. +// +// Parameters: +// - contextDir: Path to the .context/ directory +// +// Returns: +// - *Server: A configured MCP server ready to serve +func NewServer(contextDir string) *Server { + return &Server{ + contextDir: contextDir, + version: config.BinaryVersion, + tokenBudget: rc.TokenBudget(), + out: os.Stdout, + in: os.Stdin, + } +} + +// Serve starts the MCP server, reading from stdin and writing to stdout. +// +// It blocks until stdin is closed or an unrecoverable error occurs. +// Each line from stdin is expected to be a JSON-RPC 2.0 request. +// +// Returns: +// - error: Non-nil if an I/O error prevents continued operation +func (s *Server) Serve() error { + scanner := bufio.NewScanner(s.in) + + // Increase scanner buffer for large messages (1MB). + const maxScanSize = 1 << 20 + scanner.Buffer(make([]byte, 0, maxScanSize), maxScanSize) + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + resp := s.handleMessage(line) + if resp == nil { + // Notification — no response required. + continue + } + + out, err := json.Marshal(resp) + if err != nil { + // Marshal failure is an internal error; try to report it. + s.writeError(nil, errCodeInternal, "failed to marshal response") + continue + } + if _, writeErr := s.out.Write(append(out, '\n')); writeErr != nil { + return writeErr + } + } + + return scanner.Err() +} + +// handleMessage dispatches a raw JSON-RPC message to the appropriate handler. +func (s *Server) handleMessage(data []byte) *Response { + var req Request + if err := json.Unmarshal(data, &req); err != nil { + return &Response{ + JSONRPC: "2.0", + Error: &RPCError{Code: errCodeParse, Message: "parse error"}, + } + } + + // Notifications have no ID and expect no response. + if req.ID == nil { + s.handleNotification(req) + return nil + } + + return s.dispatch(req) +} + +// dispatch routes a request to the correct handler based on method name. +func (s *Server) dispatch(req Request) *Response { + switch req.Method { + case "initialize": + return s.handleInitialize(req) + case "ping": + return s.ok(req.ID, struct{}{}) + case "resources/list": + return s.handleResourcesList(req) + case "resources/read": + return s.handleResourcesRead(req) + case "tools/list": + return s.handleToolsList(req) + case "tools/call": + return s.handleToolsCall(req) + default: + return s.error(req.ID, errCodeNotFound, + fmt.Sprintf("method not found: %s", req.Method)) + } +} + +// handleNotification processes notifications (no response needed). +func (s *Server) handleNotification(req Request) { + // MCP notifications we handle: + // - notifications/initialized: client confirms init complete + // - notifications/cancelled: client cancels a request + // All are no-ops for our stateless server. +} + +// handleInitialize responds to the MCP initialize handshake. +func (s *Server) handleInitialize(req Request) *Response { + result := InitializeResult{ + ProtocolVersion: protocolVersion, + Capabilities: ServerCaps{ + Resources: &ResourcesCap{}, + Tools: &ToolsCap{}, + }, + ServerInfo: AppInfo{ + Name: "ctx", + Version: s.version, + }, + } + return s.ok(req.ID, result) +} + +// ok builds a successful JSON-RPC response. +func (s *Server) ok(id json.RawMessage, result interface{}) *Response { + return &Response{ + JSONRPC: "2.0", + ID: id, + Result: result, + } +} + +// error builds a JSON-RPC error response. +func (s *Server) error(id json.RawMessage, code int, msg string) *Response { + return &Response{ + JSONRPC: "2.0", + ID: id, + Error: &RPCError{Code: code, Message: msg}, + } +} + +// writeError writes an error response directly to stdout. Used when the +// normal response flow cannot be used (e.g., marshal failure). +func (s *Server) writeError(id json.RawMessage, code int, msg string) { + resp := s.error(id, code, msg) + if out, err := json.Marshal(resp); err == nil { + // Best-effort: writeError is a last-resort fallback; nowhere + // to report a write failure from here. + _, _ = s.out.Write(append(out, '\n')) + } +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go new file mode 100644 index 00000000..85011be7 --- /dev/null +++ b/internal/mcp/server_test.go @@ -0,0 +1,437 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ActiveMemory/ctx/internal/config" +) + +func newTestServer(t *testing.T) (*Server, string) { + t.Helper() + dir := t.TempDir() + contextDir := filepath.Join(dir, ".context") + if err := os.MkdirAll(contextDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + files := map[string]string{ + config.FileConstitution: "# Constitution\n\n- Rule 1: Never break things\n", + config.FileTask: "# Tasks\n\n- [ ] Build MCP server\n- [ ] Write tests\n", + config.FileDecision: "# Decisions\n", + config.FileConvention: "# Conventions\n\n- Use Go idioms\n", + config.FileLearning: "# Learnings\n", + config.FileArchitecture: "# Architecture\n", + config.FileGlossary: "# Glossary\n", + config.FileAgentPlaybook: "# Agent Playbook\n\nRead context files first.\n", + } + for name, content := range files { + p := filepath.Join(contextDir, name) + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + srv := NewServer(contextDir) + return srv, contextDir +} + +func request(t *testing.T, srv *Server, method string, params interface{}) *Response { + t.Helper() + var rawParams json.RawMessage + if params != nil { + b, err := json.Marshal(params) + if err != nil { + t.Fatalf("marshal params: %v", err) + } + rawParams = b + } + idBytes, _ := json.Marshal(1) + req := Request{ + JSONRPC: "2.0", + ID: idBytes, + Method: method, + Params: rawParams, + } + line, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + var out bytes.Buffer + srv.in = bytes.NewReader(append(line, '\n')) + srv.out = &out + if serveErr := srv.Serve(); serveErr != nil { + t.Fatalf("serve: %v", serveErr) + } + var resp Response + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v (raw: %s)", err, out.String()) + } + return &resp +} + +func TestInitialize(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "initialize", InitializeParams{ + ProtocolVersion: protocolVersion, + ClientInfo: AppInfo{Name: "test", Version: "1.0"}, + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result InitializeResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + if result.ProtocolVersion != protocolVersion { + t.Errorf("protocol version = %q, want %q", result.ProtocolVersion, protocolVersion) + } + if result.ServerInfo.Name != "ctx" { + t.Errorf("server name = %q, want %q", result.ServerInfo.Name, "ctx") + } + if result.Capabilities.Resources == nil { + t.Error("expected resources capability") + } + if result.Capabilities.Tools == nil { + t.Error("expected tools capability") + } +} + +func TestPing(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "ping", nil) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } +} + +func TestMethodNotFound(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "nonexistent/method", nil) + if resp.Error == nil { + t.Fatal("expected error for unknown method") + } + if resp.Error.Code != errCodeNotFound { + t.Errorf("error code = %d, want %d", resp.Error.Code, errCodeNotFound) + } +} + +func TestResourcesList(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "resources/list", nil) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result ResourceListResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(result.Resources) != 9 { + t.Errorf("resource count = %d, want 9", len(result.Resources)) + } + found := false + for _, r := range result.Resources { + if r.URI == "ctx://context/agent" { + found = true + break + } + } + if !found { + t.Error("agent resource not found in list") + } +} + +func TestResourcesRead(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "resources/read", ReadResourceParams{ + URI: "ctx://context/tasks", + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result ReadResourceResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(result.Contents) != 1 { + t.Fatalf("contents count = %d, want 1", len(result.Contents)) + } + if !strings.Contains(result.Contents[0].Text, "Build MCP server") { + t.Errorf("expected tasks content, got: %s", result.Contents[0].Text) + } +} + +func TestResourcesReadAgent(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "resources/read", ReadResourceParams{ + URI: "ctx://context/agent", + }) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result ReadResourceResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + text := result.Contents[0].Text + if !strings.Contains(text, "Context Packet") { + t.Error("expected Context Packet header in agent resource") + } +} + +func TestResourcesReadUnknown(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "resources/read", ReadResourceParams{ + URI: "ctx://context/nonexistent", + }) + if resp.Error == nil { + t.Fatal("expected error for unknown resource") + } +} + +func TestToolsList(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "tools/list", nil) + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error.Message) + } + raw, _ := json.Marshal(resp.Result) + var result ToolListResult + 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)) + } + 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"} { + if !names[want] { + t.Errorf("missing tool: %s", want) + } + } +} + +func TestToolStatus(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_status", + }) + 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, "TASKS.md") { + t.Errorf("expected TASKS.md in status output, got: %s", text) + } +} + +func TestToolComplete(t *testing.T) { + srv, contextDir := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_complete", + Arguments: map[string]interface{}{"query": "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, config.FileTask)) + if err != nil { + t.Fatalf("read tasks: %v", err) + } + if !strings.Contains(string(content), "- [x] Build MCP server") { + t.Errorf("task not marked complete in file: %s", string(content)) + } +} + +func TestToolDrift(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_drift", + }) + 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, "Status:") { + t.Errorf("expected Status in drift output, got: %s", result.Content[0].Text) + } +} + +func TestToolAdd(t *testing.T) { + tests := []struct { + name string + args map[string]interface{} + wantErr bool + wantFile string + wantContains string + }{ + { + name: "add task", + args: map[string]interface{}{"type": "task", "content": "Test task"}, + wantFile: config.FileTask, + wantContains: "Test task", + }, + { + name: "add convention", + args: map[string]interface{}{"type": "convention", "content": "Use tabs"}, + wantFile: config.FileConvention, + wantContains: "Use tabs", + }, + { + name: "add decision", + args: map[string]interface{}{ + "type": "decision", + "content": "Use Redis", + "context": "Need caching", + "rationale": "Fast and simple", + "consequences": "Ops must manage Redis", + }, + wantFile: config.FileDecision, + wantContains: "Use Redis", + }, + { + name: "add learning", + args: map[string]interface{}{ + "type": "learning", + "content": "Go embed requires same package", + "context": "Tried parent dir", + "lesson": "Only same or child dirs", + "application": "Keep files in internal", + }, + wantFile: config.FileLearning, + wantContains: "Go embed", + }, + { + name: "decision missing rationale", + args: map[string]interface{}{"type": "decision", "content": "X", "context": "Y"}, + wantErr: true, + }, + { + name: "learning missing lesson", + args: map[string]interface{}{"type": "learning", "content": "X", "context": "Y"}, + wantErr: true, + }, + { + name: "missing content", + args: map[string]interface{}{"type": "task"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv, contextDir := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "ctx_add", + Arguments: tt.args, + }) + 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 tt.wantErr { + if !result.IsError { + t.Fatalf("expected tool error, got success: %s", result.Content[0].Text) + } + return + } + + if result.IsError { + t.Fatalf("unexpected tool error: %s", result.Content[0].Text) + } + + content, err := os.ReadFile(filepath.Join(contextDir, tt.wantFile)) + if err != nil { + t.Fatalf("read %s: %v", tt.wantFile, err) + } + if !strings.Contains(string(content), tt.wantContains) { + t.Errorf("expected %q in %s, got: %s", tt.wantContains, tt.wantFile, string(content)) + } + }) + } +} + +func TestToolUnknown(t *testing.T) { + srv, _ := newTestServer(t) + resp := request(t, srv, "tools/call", CallToolParams{ + Name: "nonexistent_tool", + }) + if resp.Error == nil { + t.Fatal("expected error for unknown tool") + } +} + +func TestNotification(t *testing.T) { + srv, _ := newTestServer(t) + req := Request{ + JSONRPC: "2.0", + Method: "notifications/initialized", + } + line, _ := json.Marshal(req) + var out bytes.Buffer + srv.in = bytes.NewReader(append(line, '\n')) + srv.out = &out + if err := srv.Serve(); err != nil { + t.Fatalf("serve: %v", err) + } + if out.Len() != 0 { + t.Errorf("expected no output for notification, got: %s", out.String()) + } +} + +func TestParseError(t *testing.T) { + srv, _ := newTestServer(t) + var out bytes.Buffer + srv.in = bytes.NewReader([]byte("not json\n")) + srv.out = &out + if err := srv.Serve(); err != nil { + t.Fatalf("serve: %v", err) + } + var resp Response + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Error == nil || resp.Error.Code != errCodeParse { + t.Errorf("expected parse error, got: %+v", resp.Error) + } +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go new file mode 100644 index 00000000..cedf8ed8 --- /dev/null +++ b/internal/mcp/tools.go @@ -0,0 +1,268 @@ +// / 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/cli/add" + "github.com/ActiveMemory/ctx/internal/cli/complete" + "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/context" + "github.com/ActiveMemory/ctx/internal/drift" +) + +// toolDefs defines all available MCP tools. +var toolDefs = []Tool{ + { + Name: "ctx_status", + Description: "Show context health: file count, token estimate, and file summaries", + InputSchema: InputSchema{Type: "object"}, + Annotations: &ToolAnnotations{ReadOnlyHint: true}, + }, + { + Name: "ctx_add", + Description: "Add a task, decision, learning, or convention to the context", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]Property{ + "type": { + Type: "string", + Description: "Entry type to add", + Enum: []string{"task", "decision", "learning", "convention"}, + }, + "content": { + Type: "string", + Description: "Title or main content of the entry", + }, + "priority": { + Type: "string", + Description: "Priority level (for tasks only)", + Enum: []string{"high", "medium", "low"}, + }, + "context": { + Type: "string", + Description: "Context field (required for decisions and 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_complete", + Description: "Mark a task as done by number or text match", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]Property{ + "query": { + Type: "string", + Description: "Task number (e.g. '1') or search text to match", + }, + }, + Required: []string{"query"}, + }, + Annotations: &ToolAnnotations{IdempotentHint: true}, + }, + { + Name: "ctx_drift", + Description: "Detect stale or invalid context: dead paths, missing files, staleness", + InputSchema: InputSchema{Type: "object"}, + Annotations: &ToolAnnotations{ReadOnlyHint: true}, + }, +} + +// handleToolsList returns all available MCP tools. +func (s *Server) handleToolsList(req Request) *Response { + return s.ok(req.ID, ToolListResult{Tools: toolDefs}) +} + +// handleToolsCall dispatches a tool call to the appropriate handler. +func (s *Server) handleToolsCall(req Request) *Response { + var params CallToolParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return s.error(req.ID, errCodeInvalidArg, "invalid params") + } + + switch params.Name { + case "ctx_status": + return s.toolStatus(req.ID) + case "ctx_add": + return s.toolAdd(req.ID, params.Arguments) + case "ctx_complete": + return s.toolComplete(req.ID, params.Arguments) + case "ctx_drift": + return s.toolDrift(req.ID) + default: + return s.error(req.ID, errCodeNotFound, + fmt.Sprintf("unknown tool: %s", params.Name)) + } +} + +// toolStatus loads context and returns a status summary. +func (s *Server) toolStatus(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)) + } + + var sb strings.Builder + fmt.Fprintf(&sb, "Context: %s\n", ctx.Dir) + fmt.Fprintf(&sb, "Files: %d\n", len(ctx.Files)) + fmt.Fprintf(&sb, "Tokens: ~%d\n\n", ctx.TotalTokens) + + for _, f := range ctx.Files { + status := "OK" + if f.IsEmpty { + status = "EMPTY" + } + fmt.Fprintf(&sb, " %-22s %6d tokens [%s]\n", + f.Name, f.Tokens, status) + } + + return s.toolOK(id, sb.String()) +} + +// toolAdd adds an entry to a context file. +func (s *Server) toolAdd( + id json.RawMessage, args map[string]interface{}, +) *Response { + entryType, _ := args["type"].(string) + content, _ := args["content"].(string) + + if entryType == "" || content == "" { + return s.toolError(id, "type and content are required") + } + + params := add.EntryParams{ + Type: entryType, + Content: content, + ContextDir: s.contextDir, + } + + // Optional fields. + if v, ok := args["priority"].(string); ok { + params.Priority = v + } + 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 + } + + // Validate required fields. + if vErr := add.ValidateEntry(params); vErr != nil { + return s.toolError(id, vErr.Error()) + } + + if wErr := add.WriteEntry(params); wErr != nil { + return s.toolError(id, fmt.Sprintf("write failed: %v", wErr)) + } + + fileName := config.FileType[strings.ToLower(entryType)] + return s.toolOK(id, fmt.Sprintf("Added %s to %s", entryType, fileName)) +} + +// toolComplete marks a task as done by number or text match. +func (s *Server) toolComplete( + id json.RawMessage, args map[string]interface{}, +) *Response { + query, _ := args["query"].(string) + if query == "" { + return s.toolError(id, "query is required") + } + + completedTask, err := complete.CompleteTask(query, s.contextDir) + if err != nil { + return s.toolError(id, err.Error()) + } + + return s.toolOK(id, fmt.Sprintf("Completed: %s", completedTask)) +} + +// toolDrift runs drift detection and returns the report. +func (s *Server) toolDrift(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)) + } + + report := drift.Detect(ctx) + + var sb strings.Builder + fmt.Fprintf(&sb, "Status: %s\n\n", report.Status()) + + if len(report.Violations) > 0 { + sb.WriteString("Violations:\n") + for _, v := range report.Violations { + fmt.Fprintf(&sb, " - [%s] %s: %s\n", + v.Type, v.File, v.Message) + } + sb.WriteString("\n") + } + + if len(report.Warnings) > 0 { + sb.WriteString("Warnings:\n") + for _, w := range report.Warnings { + fmt.Fprintf(&sb, " - [%s] %s: %s\n", + w.Type, w.File, w.Message) + } + sb.WriteString("\n") + } + + if len(report.Passed) > 0 { + sb.WriteString("Passed:\n") + for _, p := range report.Passed { + fmt.Fprintf(&sb, " - %s\n", p) + } + } + + return s.toolOK(id, sb.String()) +} + +// toolOK builds a successful tool result. +func (s *Server) toolOK(id json.RawMessage, text string) *Response { + return s.ok(id, CallToolResult{ + Content: []ToolContent{{Type: "text", Text: text}}, + }) +} + +// toolError builds a tool error result. +func (s *Server) toolError(id json.RawMessage, msg string) *Response { + return s.ok(id, CallToolResult{ + Content: []ToolContent{{Type: "text", Text: msg}}, + IsError: true, + }) +} diff --git a/site/404.html b/site/404.html index d03470c7..63ce405b 100644 --- a/site/404.html +++ b/site/404.html @@ -19,7 +19,7 @@ - + @@ -30,7 +30,7 @@ - + @@ -2458,6 +2458,8 @@ + + @@ -2703,6 +2705,36 @@ +