diff --git a/.dockerignore b/.dockerignore index d9bf227..736f541 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,3 @@ -.git .gitignore .vscode bin diff --git a/Dockerfile b/Dockerfile index 41b5c41..003e303 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,17 @@ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/amcs-server ./cmd/amcs-server +RUN set -eu; \ + VERSION_TAG="$(git describe --tags --exact-match 2>/dev/null || echo dev)"; \ + COMMIT_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"; \ + BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"; \ + CGO_ENABLED=0 GOOS=linux go build -trimpath \ + -ldflags="-s -w \ + -X git.warky.dev/wdevs/amcs/internal/buildinfo.Version=${VERSION_TAG} \ + -X git.warky.dev/wdevs/amcs/internal/buildinfo.TagName=${VERSION_TAG} \ + -X git.warky.dev/wdevs/amcs/internal/buildinfo.Commit=${COMMIT_SHA} \ + -X git.warky.dev/wdevs/amcs/internal/buildinfo.BuildDate=${BUILD_DATE}" \ + -o /out/amcs-server ./cmd/amcs-server FROM debian:bookworm-slim diff --git a/Makefile b/Makefile index abec7bf..0f7bc18 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,15 @@ BIN_DIR := bin SERVER_BIN := $(BIN_DIR)/amcs-server CMD_SERVER := ./cmd/amcs-server +BUILDINFO_PKG := git.warky.dev/wdevs/amcs/internal/buildinfo +VERSION_TAG ?= $(shell git describe --tags --exact-match 2>/dev/null || echo dev) +COMMIT_SHA ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +LDFLAGS := -s -w \ + -X $(BUILDINFO_PKG).Version=$(VERSION_TAG) \ + -X $(BUILDINFO_PKG).TagName=$(VERSION_TAG) \ + -X $(BUILDINFO_PKG).Commit=$(COMMIT_SHA) \ + -X $(BUILDINFO_PKG).BuildDate=$(BUILD_DATE) .PHONY: all build clean migrate @@ -8,7 +17,7 @@ all: build build: @mkdir -p $(BIN_DIR) - go build -o $(SERVER_BIN) $(CMD_SERVER) + go build -ldflags "$(LDFLAGS)" -o $(SERVER_BIN) $(CMD_SERVER) migrate: ./scripts/migrate.sh diff --git a/README.md b/README.md index cbe9976..8849c75 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ A Go MCP server for capturing and retrieving thoughts, memory, and project conte | `archive_thought` | Soft delete | | `create_project` | Register a named project | | `list_projects` | List projects with thought counts | -| `get_project_context` | Recent + semantic context for a project | -| `set_active_project` | Set session project scope | +| `get_project_context` | Recent + semantic context for a project; uses explicit `project` or the active session project | +| `set_active_project` | Set session project scope; requires a stateful MCP session | | `get_active_project` | Get current session project | | `summarize_thoughts` | LLM prose summary over a filtered set | | `recall_context` | Semantic + recency context block for injection | @@ -54,18 +54,129 @@ A Go MCP server for capturing and retrieving thoughts, memory, and project conte | `add_guardrail` | Store a reusable agent guardrail (constraint or safety rule) | | `remove_guardrail` | Delete an agent guardrail by id | | `list_guardrails` | List all agent guardrails, optionally filtered by tag or severity | -| `add_project_skill` | Link an agent skill to a project | -| `remove_project_skill` | Unlink an agent skill from a project | -| `list_project_skills` | List all skills linked to a project | -| `add_project_guardrail` | Link an agent guardrail to a project | -| `remove_project_guardrail` | Unlink an agent guardrail from a project | -| `list_project_guardrails` | List all guardrails linked to a project | +| `add_project_skill` | Link an agent skill to a project; pass `project` explicitly if your client does not preserve MCP sessions | +| `remove_project_skill` | Unlink an agent skill from a project; pass `project` explicitly if your client does not preserve MCP sessions | +| `list_project_skills` | List all skills linked to a project; pass `project` explicitly if your client does not preserve MCP sessions | +| `add_project_guardrail` | Link an agent guardrail to a project; pass `project` explicitly if your client does not preserve MCP sessions | +| `remove_project_guardrail` | Unlink an agent guardrail from a project; pass `project` explicitly if your client does not preserve MCP sessions | +| `list_project_guardrails` | List all guardrails linked to a project; pass `project` explicitly if your client does not preserve MCP sessions | +| `get_version_info` | Return the server build version information, including version, tag name, commit, and build date | + +## MCP Error Contract + +AMCS returns structured JSON-RPC errors for common MCP failures. Clients should branch on both `error.code` and `error.data.type` instead of parsing the human-readable message. + +### Stable error codes + +| Code | `data.type` | Meaning | +|---|---|---| +| `-32602` | `invalid_arguments` | MCP argument/schema validation failed before the tool handler ran | +| `-32602` | `invalid_input` | Tool-level input validation failed inside the handler | +| `-32050` | `session_required` | Tool requires a stateful MCP session | +| `-32051` | `project_required` | No explicit `project` was provided and no active session project was available | +| `-32052` | `project_not_found` | The referenced project does not exist | +| `-32053` | `invalid_id` | A UUID-like identifier was malformed | +| `-32054` | `entity_not_found` | A referenced entity such as a thought or contact does not exist | + +### Error data shape + +AMCS may include these fields in `error.data`: + +- `type` — stable machine-readable error type +- `field` — single argument name such as `name`, `project`, or `thought_id` +- `fields` — multiple argument names for one-of or mutually-exclusive validation +- `value` — offending value when safe to expose +- `detail` — validation detail such as `required`, `invalid`, `one_of_required`, `mutually_exclusive`, or a schema validation message +- `hint` — remediation guidance +- `entity` — entity name for generic not-found errors + +Example schema-level error: + +```json +{ + "code": -32602, + "message": "invalid tool arguments", + "data": { + "type": "invalid_arguments", + "field": "name", + "detail": "validating root: required: missing properties: [\"name\"]", + "hint": "check the name argument" + } +} +``` + +Example tool-level error: + +```json +{ + "code": -32051, + "message": "project is required; pass project explicitly or call set_active_project in this MCP session first", + "data": { + "type": "project_required", + "field": "project", + "hint": "pass project explicitly or call set_active_project in this MCP session first" + } +} +``` + +### Client example + +Go client example handling AMCS MCP errors: + +```go +result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "get_project_context", + Arguments: map[string]any{}, +}) +if err != nil { + var rpcErr *jsonrpc.Error + if errors.As(err, &rpcErr) { + var data struct { + Type string `json:"type"` + Field string `json:"field"` + Hint string `json:"hint"` + } + _ = json.Unmarshal(rpcErr.Data, &data) + + switch { + case rpcErr.Code == -32051 && data.Type == "project_required": + // Retry with an explicit project, or call set_active_project first. + case rpcErr.Code == -32602 && data.Type == "invalid_arguments": + // Ask the caller to fix the malformed arguments. + } + } +} +_ = result +``` + +## Build Versioning + +AMCS embeds build metadata into the binary at build time. + +- `version` is generated from the current git tag when building from a tagged commit +- `tag_name` is the repo tag name, for example `v1.0.1` +- `build_date` is the UTC build timestamp in RFC3339 format +- `commit` is the short git commit SHA + +For untagged builds, `version` and `tag_name` fall back to `dev`. + +Use `get_version_info` to retrieve the runtime build metadata: + +```json +{ + "server_name": "amcs", + "version": "v1.0.1", + "tag_name": "v1.0.1", + "commit": "abc1234", + "build_date": "2026-03-31T14:22:10Z" +} +``` ## Agent Skills and Guardrails Skills and guardrails are reusable agent behaviour instructions and constraints that can be attached to projects. -**At the start of every project session, always call `list_project_skills` and `list_project_guardrails` first.** Use the returned skills and guardrails to guide agent behaviour for that project. Only generate or create new skills/guardrails if none are returned. +**At the start of every project session, always call `list_project_skills` and `list_project_guardrails` first.** Use the returned skills and guardrails to guide agent behaviour for that project. Only generate or create new skills/guardrails if none are returned. If your MCP client does not preserve sessions across calls, pass `project` explicitly instead of relying on `set_active_project`. ### Skills @@ -102,6 +213,7 @@ Config is YAML-driven. Copy `configs/config.example.yaml` and set: - `auth.mode` — `api_keys` or `oauth_client_credentials` - `auth.keys` — API keys for MCP access via `x-brain-key` or `Authorization: Bearer ` when `auth.mode=api_keys` - `auth.oauth.clients` — client registry when `auth.mode=oauth_client_credentials` +- `mcp.version` is build-generated and should not be set in config **OAuth Client Credentials flow** (`auth.mode=oauth_client_credentials`): @@ -230,7 +342,7 @@ Returns `{"file": {...}, "uri": "amcs://files/"}`. Pass `thought_id`/`projec `content_base64` and `content_uri` are mutually exclusive in both tools. -**Load a file** — returns metadata, base64 content, and an embedded MCP binary resource (`amcs://files/{id}`): +**Load a file** — returns metadata, base64 content, and an embedded MCP binary resource (`amcs://files/{id}`). The `id` field accepts either the bare stored file UUID or the full `amcs://files/{id}` URI: ```json { "id": "stored-file-uuid" } diff --git a/configs/config.example.yaml b/configs/config.example.yaml index 4888828..1559190 100644 --- a/configs/config.example.yaml +++ b/configs/config.example.yaml @@ -10,8 +10,8 @@ server: mcp: path: "/mcp" server_name: "amcs" - version: "0.1.0" transport: "streamable_http" + session_timeout: "10m" auth: header_name: "x-brain-key" diff --git a/configs/dev.yaml b/configs/dev.yaml index bd65c25..09c7740 100644 --- a/configs/dev.yaml +++ b/configs/dev.yaml @@ -10,8 +10,8 @@ server: mcp: path: "/mcp" server_name: "amcs" - version: "0.1.0" transport: "streamable_http" + session_timeout: "10m" auth: header_name: "x-brain-key" diff --git a/configs/docker.yaml b/configs/docker.yaml index 7255d09..87151ed 100644 --- a/configs/docker.yaml +++ b/configs/docker.yaml @@ -10,8 +10,8 @@ server: mcp: path: "/mcp" server_name: "amcs" - version: "0.1.0" transport: "streamable_http" + session_timeout: "10m" auth: header_name: "x-brain-key" diff --git a/go.mod b/go.mod index fdd4491..d4b03a3 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.warky.dev/wdevs/amcs go 1.26.1 require ( + github.com/google/jsonschema-go v0.4.2 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.1 github.com/modelcontextprotocol/go-sdk v1.4.1 @@ -12,7 +13,6 @@ require ( ) require ( - github.com/google/jsonschema-go v0.4.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/internal/ai/compat/client.go b/internal/ai/compat/client.go index 7ec6fb7..37c55f6 100644 --- a/internal/ai/compat/client.go +++ b/internal/ai/compat/client.go @@ -493,7 +493,7 @@ func (c *Client) doJSON(ctx context.Context, path string, requestBody any, dest } payload, readErr := io.ReadAll(resp.Body) - resp.Body.Close() + _ = resp.Body.Close() if readErr != nil { lastErr = fmt.Errorf("%s read response: %w", c.name, readErr) if attempt < maxAttempts { @@ -587,7 +587,9 @@ func (c *Client) doChatCompletions(ctx context.Context, reqBody chatCompletionsR } func (c *Client) decodeChatCompletionsResponse(resp *http.Response) (chatCompletionsResponse, error) { - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() contentType := strings.ToLower(resp.Header.Get("Content-Type")) if strings.Contains(contentType, "text/event-stream") { diff --git a/internal/ai/compat/client_test.go b/internal/ai/compat/client_test.go index 80cab6d..5d3c93a 100644 --- a/internal/ai/compat/client_test.go +++ b/internal/ai/compat/client_test.go @@ -15,7 +15,9 @@ func TestExtractMetadataFromStreamingResponse(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() + defer func() { + _ = r.Body.Close() + }() var req chatCompletionsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -68,7 +70,9 @@ func TestExtractMetadataRetriesWithoutJSONMode(t *testing.T) { plainCalls := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() + defer func() { + _ = r.Body.Close() + }() var req chatCompletionsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -131,7 +135,9 @@ func TestExtractMetadataBypassesInvalidFallbackModelAfterFirstFailure(t *testing invalidFallbackCalls := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() + defer func() { + _ = r.Body.Close() + }() var req chatCompletionsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/internal/app/app.go b/internal/app/app.go index ead2a7e..9a846db 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -10,6 +10,7 @@ import ( "git.warky.dev/wdevs/amcs/internal/ai" "git.warky.dev/wdevs/amcs/internal/auth" + "git.warky.dev/wdevs/amcs/internal/buildinfo" "git.warky.dev/wdevs/amcs/internal/config" "git.warky.dev/wdevs/amcs/internal/mcpserver" "git.warky.dev/wdevs/amcs/internal/observability" @@ -23,6 +24,8 @@ func Run(ctx context.Context, configPath string) error { if err != nil { return err } + info := buildinfo.Current() + cfg.MCP.Version = info.Version logger, err := observability.NewLogger(cfg.Logging) if err != nil { @@ -32,6 +35,10 @@ func Run(ctx context.Context, configPath string) error { logger.Info("loaded configuration", slog.String("path", loadedFrom), slog.String("provider", cfg.AI.Provider), + slog.String("version", info.Version), + slog.String("tag_name", info.TagName), + slog.String("build_date", info.BuildDate), + slog.String("commit", info.Commit), ) db, err := store.New(ctx, cfg.Database) @@ -112,9 +119,14 @@ func Run(ctx context.Context, configPath string) error { }() } + handler, err := routes(logger, cfg, info, db, provider, keyring, oauthRegistry, tokenStore, authCodes, dynClients, activeProjects) + if err != nil { + return err + } + server := &http.Server{ Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port), - Handler: routes(logger, cfg, db, provider, keyring, oauthRegistry, tokenStore, authCodes, dynClients, activeProjects), + Handler: handler, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout, IdleTimeout: cfg.Server.IdleTimeout, @@ -144,7 +156,7 @@ func Run(ctx context.Context, configPath string) error { } } -func routes(logger *slog.Logger, cfg *config.Config, db *store.DB, provider ai.Provider, keyring *auth.Keyring, oauthRegistry *auth.OAuthRegistry, tokenStore *auth.TokenStore, authCodes *auth.AuthCodeStore, dynClients *auth.DynamicClientStore, activeProjects *session.ActiveProjects) http.Handler { +func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *store.DB, provider ai.Provider, keyring *auth.Keyring, oauthRegistry *auth.OAuthRegistry, tokenStore *auth.TokenStore, authCodes *auth.AuthCodeStore, dynClients *auth.DynamicClientStore, activeProjects *session.ActiveProjects) (http.Handler, error) { mux := http.NewServeMux() authMiddleware := auth.Middleware(cfg.Auth, keyring, oauthRegistry, tokenStore, logger) filesTool := tools.NewFilesTool(db, activeProjects) @@ -160,6 +172,7 @@ func routes(logger *slog.Logger, cfg *config.Config, db *store.DB, provider ai.P Delete: tools.NewDeleteTool(db), Archive: tools.NewArchiveTool(db), Projects: tools.NewProjectsTool(db, activeProjects), + Version: tools.NewVersionTool(cfg.MCP.ServerName, info), Context: tools.NewContextTool(db, provider, cfg.Search, activeProjects), Recall: tools.NewRecallTool(db, provider, cfg.Search, activeProjects), Summarize: tools.NewSummarizeTool(db, provider, cfg.Search, activeProjects), @@ -176,7 +189,10 @@ func routes(logger *slog.Logger, cfg *config.Config, db *store.DB, provider ai.P Skills: tools.NewSkillsTool(db, activeProjects), } - mcpHandler := mcpserver.New(cfg.MCP, logger, toolSet) + mcpHandler, err := mcpserver.New(cfg.MCP, logger, toolSet, activeProjects.Clear) + if err != nil { + return nil, fmt.Errorf("build mcp handler: %w", err) + } mux.Handle(cfg.MCP.Path, authMiddleware(mcpHandler)) mux.Handle("/files", authMiddleware(fileHandler(filesTool))) mux.Handle("/files/{id}", authMiddleware(fileHandler(filesTool))) @@ -268,7 +284,7 @@ func routes(logger *slog.Logger, cfg *config.Config, db *store.DB, provider ai.P observability.Recover(logger), observability.AccessLog(logger), observability.Timeout(cfg.Server.WriteTimeout), - ) + ), nil } func runMetadataRetryPass(ctx context.Context, db *store.DB, provider ai.Provider, cfg *config.Config, activeProjects *session.ActiveProjects, logger *slog.Logger) { diff --git a/internal/app/files.go b/internal/app/files.go index 90da972..7d78496 100644 --- a/internal/app/files.go +++ b/internal/app/files.go @@ -93,7 +93,9 @@ func parseMultipartUpload(r *http.Request) (tools.SaveFileDecodedInput, error) { if err != nil { return tools.SaveFileDecodedInput{}, errors.New("multipart upload requires a file field named \"file\"") } - defer file.Close() + defer func() { + _ = file.Close() + }() content, err := io.ReadAll(file) if err != nil { diff --git a/internal/app/llm_test.go b/internal/app/llm_test.go index 706f840..26891a5 100644 --- a/internal/app/llm_test.go +++ b/internal/app/llm_test.go @@ -15,7 +15,9 @@ func TestServeLLMInstructions(t *testing.T) { serveLLMInstructions(rec, req) res := rec.Result() - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() if res.StatusCode != http.StatusOK { t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusOK) diff --git a/internal/app/oauth.go b/internal/app/oauth.go index e0f8c23..8f6e7e8 100644 --- a/internal/app/oauth.go +++ b/internal/app/oauth.go @@ -320,7 +320,7 @@ func issueToken(w http.ResponseWriter, keyID string, tokenStore *auth.TokenStore func serveAuthorizePage(w http.ResponseWriter, clientName, clientID, redirectURI, state, codeChallenge, codeChallengeMethod, scope string) { e := html.EscapeString w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintf(w, ` + _, _ = fmt.Fprintf(w, ` diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 4395e00..72c6bcc 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -89,21 +89,6 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg } } -func extractToken(r *http.Request, headerName string) string { - token := strings.TrimSpace(r.Header.Get(headerName)) - if token != "" { - return token - } - - authHeader := strings.TrimSpace(r.Header.Get("Authorization")) - scheme, credentials, ok := strings.Cut(authHeader, " ") - if !ok || !strings.EqualFold(scheme, "Bearer") { - return "" - } - - return strings.TrimSpace(credentials) -} - func extractBearer(r *http.Request) string { authHeader := strings.TrimSpace(r.Header.Get("Authorization")) scheme, credentials, ok := strings.Cut(authHeader, " ") diff --git a/internal/auth/oauth_registry_test.go b/internal/auth/oauth_registry_test.go index 752be3f..f4a9fb9 100644 --- a/internal/auth/oauth_registry_test.go +++ b/internal/auth/oauth_registry_test.go @@ -60,7 +60,6 @@ func TestMiddlewareAllowsOAuthBasicAuthAndSetsContext(t *testing.T) { } } - func TestMiddlewareRejectsOAuthMissingOrInvalidCredentials(t *testing.T) { oauthRegistry, err := NewOAuthRegistry([]config.OAuthClient{{ ID: "oauth-client", diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..430db73 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,41 @@ +package buildinfo + +import "strings" + +var ( + Version = "dev" + TagName = "dev" + Commit = "unknown" + BuildDate = "unknown" +) + +type Info struct { + Version string `json:"version"` + TagName string `json:"tag_name"` + Commit string `json:"commit"` + BuildDate string `json:"build_date"` +} + +func Current() Info { + info := Info{ + Version: strings.TrimSpace(Version), + TagName: strings.TrimSpace(TagName), + Commit: strings.TrimSpace(Commit), + BuildDate: strings.TrimSpace(BuildDate), + } + + if info.Version == "" { + info.Version = "dev" + } + if info.TagName == "" { + info.TagName = info.Version + } + if info.Commit == "" { + info.Commit = "unknown" + } + if info.BuildDate == "" { + info.BuildDate = "unknown" + } + + return info +} diff --git a/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go new file mode 100644 index 0000000..1681373 --- /dev/null +++ b/internal/buildinfo/buildinfo_test.go @@ -0,0 +1,32 @@ +package buildinfo + +import "testing" + +func TestCurrentAppliesFallbacks(t *testing.T) { + originalVersion, originalTagName, originalCommit, originalBuildDate := Version, TagName, Commit, BuildDate + t.Cleanup(func() { + Version = originalVersion + TagName = originalTagName + Commit = originalCommit + BuildDate = originalBuildDate + }) + + Version = "" + TagName = "" + Commit = "" + BuildDate = "" + + info := Current() + if info.Version != "dev" { + t.Fatalf("version = %q, want %q", info.Version, "dev") + } + if info.TagName != "dev" { + t.Fatalf("tag_name = %q, want %q", info.TagName, "dev") + } + if info.Commit != "unknown" { + t.Fatalf("commit = %q, want %q", info.Commit, "unknown") + } + if info.BuildDate != "unknown" { + t.Fatalf("build_date = %q, want %q", info.BuildDate, "unknown") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 5cecd2e..b36b936 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,10 +31,11 @@ type ServerConfig struct { } type MCPConfig struct { - Path string `yaml:"path"` - ServerName string `yaml:"server_name"` - Version string `yaml:"version"` - Transport string `yaml:"transport"` + Path string `yaml:"path"` + ServerName string `yaml:"server_name"` + Version string `yaml:"version"` + Transport string `yaml:"transport"` + SessionTimeout time.Duration `yaml:"session_timeout"` } type AuthConfig struct { diff --git a/internal/config/loader.go b/internal/config/loader.go index cef1ce9..e0cc71b 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "git.warky.dev/wdevs/amcs/internal/buildinfo" "gopkg.in/yaml.v3" ) @@ -46,6 +47,7 @@ func ResolvePath(explicitPath string) string { } func defaultConfig() Config { + info := buildinfo.Current() return Config{ Server: ServerConfig{ Host: "0.0.0.0", @@ -55,10 +57,11 @@ func defaultConfig() Config { IdleTimeout: 60 * time.Second, }, MCP: MCPConfig{ - Path: "/mcp", - ServerName: "amcs", - Version: "0.1.0", - Transport: "streamable_http", + Path: "/mcp", + ServerName: "amcs", + Version: info.Version, + Transport: "streamable_http", + SessionTimeout: 10 * time.Minute, }, Auth: AuthConfig{ HeaderName: "x-brain-key", diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index f288801..ac1025b 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" ) func TestResolvePathPrecedence(t *testing.T) { @@ -37,6 +38,7 @@ server: port: 8080 mcp: path: "/mcp" + session_timeout: "30m" auth: keys: - id: "test" @@ -80,6 +82,9 @@ logging: if cfg.Server.Port != 9090 { t.Fatalf("server port = %d, want 9090", cfg.Server.Port) } + if cfg.MCP.SessionTimeout != 30*time.Minute { + t.Fatalf("mcp session timeout = %v, want 30m", cfg.MCP.SessionTimeout) + } } func TestLoadAppliesOllamaEnvOverrides(t *testing.T) { diff --git a/internal/config/validate.go b/internal/config/validate.go index 020203b..ccb2cc9 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -33,6 +33,9 @@ func (c Config) Validate() error { if strings.TrimSpace(c.MCP.Path) == "" { return fmt.Errorf("invalid config: mcp.path is required") } + if c.MCP.SessionTimeout <= 0 { + return fmt.Errorf("invalid config: mcp.session_timeout must be greater than zero") + } switch c.AI.Provider { case "litellm", "ollama", "openrouter": diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index 06bce4d..8cea09c 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -1,11 +1,14 @@ package config -import "testing" +import ( + "testing" + "time" +) func validConfig() Config { return Config{ Server: ServerConfig{Port: 8080}, - MCP: MCPConfig{Path: "/mcp"}, + MCP: MCPConfig{Path: "/mcp", SessionTimeout: 10 * time.Minute}, Auth: AuthConfig{ Keys: []APIKey{{ID: "test", Value: "secret"}}, }, @@ -121,3 +124,12 @@ func TestValidateRejectsInvalidMetadataRetryConfig(t *testing.T) { t.Fatal("Validate() error = nil, want error for invalid metadata retry config") } } + +func TestValidateRejectsInvalidMCPSessionTimeout(t *testing.T) { + cfg := validConfig() + cfg.MCP.SessionTimeout = 0 + + if err := cfg.Validate(); err == nil { + t.Fatal("Validate() error = nil, want error for invalid mcp session timeout") + } +} diff --git a/internal/mcperrors/mcperrors.go b/internal/mcperrors/mcperrors.go new file mode 100644 index 0000000..a7caff2 --- /dev/null +++ b/internal/mcperrors/mcperrors.go @@ -0,0 +1,30 @@ +package mcperrors + +const ( + CodeSessionRequired = -32050 + CodeProjectRequired = -32051 + CodeProjectNotFound = -32052 + CodeInvalidID = -32053 + CodeEntityNotFound = -32054 +) + +const ( + TypeInvalidArguments = "invalid_arguments" + TypeInvalidInput = "invalid_input" + TypeSessionRequired = "session_required" + TypeProjectRequired = "project_required" + TypeProjectNotFound = "project_not_found" + TypeInvalidID = "invalid_id" + TypeEntityNotFound = "entity_not_found" +) + +type Data struct { + Type string `json:"type"` + Field string `json:"field,omitempty"` + Fields []string `json:"fields,omitempty"` + Value string `json:"value,omitempty"` + Detail string `json:"detail,omitempty"` + Hint string `json:"hint,omitempty"` + Project string `json:"project,omitempty"` + Entity string `json:"entity,omitempty"` +} diff --git a/internal/mcpserver/error_integration_test.go b/internal/mcpserver/error_integration_test.go new file mode 100644 index 0000000..3b0fb7f --- /dev/null +++ b/internal/mcpserver/error_integration_test.go @@ -0,0 +1,154 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "git.warky.dev/wdevs/amcs/internal/config" + "git.warky.dev/wdevs/amcs/internal/mcperrors" + "git.warky.dev/wdevs/amcs/internal/session" + "git.warky.dev/wdevs/amcs/internal/tools" +) + +func TestToolValidationErrorsRoundTripAsStructuredJSONRPC(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil) + + projects := tools.NewProjectsTool(nil, session.NewActiveProjects()) + contextTool := tools.NewContextTool(nil, nil, toolsSearchConfig(), session.NewActiveProjects()) + + if err := addTool(server, nil, &mcp.Tool{ + Name: "create_project", + Description: "Create a named project container for thoughts.", + }, projects.Create); err != nil { + t.Fatalf("add create_project tool: %v", err) + } + if err := addTool(server, nil, &mcp.Tool{ + Name: "get_project_context", + Description: "Get recent and semantic context for a project.", + }, contextTool.Handle); err != nil { + t.Fatalf("add get_project_context tool: %v", err) + } + + ct, st := mcp.NewInMemoryTransports() + _, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("connect server: %v", err) + } + + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "0.0.1"}, nil) + cs, err := client.Connect(context.Background(), ct, nil) + if err != nil { + t.Fatalf("connect client: %v", err) + } + defer func() { + _ = cs.Close() + }() + + t.Run("required_field", func(t *testing.T) { + _, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "create_project", + Arguments: map[string]any{"name": ""}, + }) + if err == nil { + t.Fatal("CallTool(create_project) error = nil, want error") + } + + rpcErr, data := requireWireError(t, err) + if rpcErr.Code != jsonrpc.CodeInvalidParams { + t.Fatalf("create_project code = %d, want %d", rpcErr.Code, jsonrpc.CodeInvalidParams) + } + if data.Type != mcperrors.TypeInvalidInput { + t.Fatalf("create_project data.type = %q, want %q", data.Type, mcperrors.TypeInvalidInput) + } + if data.Field != "name" { + t.Fatalf("create_project data.field = %q, want %q", data.Field, "name") + } + }) + + t.Run("schema_required_field", func(t *testing.T) { + _, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "create_project", + Arguments: map[string]any{}, + }) + if err == nil { + t.Fatal("CallTool(create_project missing field) error = nil, want error") + } + + rpcErr, data := requireWireError(t, err) + if rpcErr.Code != jsonrpc.CodeInvalidParams { + t.Fatalf("create_project schema code = %d, want %d", rpcErr.Code, jsonrpc.CodeInvalidParams) + } + if data.Type != mcperrors.TypeInvalidArguments { + t.Fatalf("create_project schema data.type = %q, want %q", data.Type, mcperrors.TypeInvalidArguments) + } + if data.Field != "name" { + t.Fatalf("create_project schema data.field = %q, want %q", data.Field, "name") + } + if data.Detail == "" { + t.Fatal("create_project schema data.detail = empty, want validation detail") + } + }) + + t.Run("project_required", func(t *testing.T) { + _, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_project_context", + Arguments: map[string]any{}, + }) + if err == nil { + t.Fatal("CallTool(get_project_context) error = nil, want error") + } + + rpcErr, data := requireWireError(t, err) + if rpcErr.Code != mcperrors.CodeProjectRequired { + t.Fatalf("get_project_context code = %d, want %d", rpcErr.Code, mcperrors.CodeProjectRequired) + } + if data.Type != mcperrors.TypeProjectRequired { + t.Fatalf("get_project_context data.type = %q, want %q", data.Type, mcperrors.TypeProjectRequired) + } + if data.Field != "project" { + t.Fatalf("get_project_context data.field = %q, want %q", data.Field, "project") + } + if data.Hint == "" { + t.Fatal("get_project_context data.hint = empty, want guidance") + } + }) +} + +type wireErrorData struct { + Type string `json:"type"` + Field string `json:"field,omitempty"` + Fields []string `json:"fields,omitempty"` + Detail string `json:"detail,omitempty"` + Hint string `json:"hint,omitempty"` +} + +func requireWireError(t *testing.T, err error) (*jsonrpc.Error, wireErrorData) { + t.Helper() + + var rpcErr *jsonrpc.Error + if !errors.As(err, &rpcErr) { + t.Fatalf("error type = %T, want *jsonrpc.Error", err) + } + + var data wireErrorData + if len(rpcErr.Data) > 0 { + if unmarshalErr := json.Unmarshal(rpcErr.Data, &data); unmarshalErr != nil { + t.Fatalf("unmarshal wire error data: %v", unmarshalErr) + } + } + + return rpcErr, data +} + +func toolsSearchConfig() config.SearchConfig { + return config.SearchConfig{ + DefaultLimit: 10, + MaxLimit: 50, + DefaultThreshold: 0.7, + } +} diff --git a/internal/mcpserver/eventstore.go b/internal/mcpserver/eventstore.go new file mode 100644 index 0000000..922b2cd --- /dev/null +++ b/internal/mcpserver/eventstore.go @@ -0,0 +1,42 @@ +package mcpserver + +import ( + "context" + "iter" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type cleanupEventStore struct { + base mcp.EventStore + onSessionClosed func(string) +} + +func newCleanupEventStore(base mcp.EventStore, onSessionClosed func(string)) mcp.EventStore { + return &cleanupEventStore{ + base: base, + onSessionClosed: onSessionClosed, + } +} + +func (s *cleanupEventStore) Open(ctx context.Context, sessionID, streamID string) error { + return s.base.Open(ctx, sessionID, streamID) +} + +func (s *cleanupEventStore) Append(ctx context.Context, sessionID, streamID string, data []byte) error { + return s.base.Append(ctx, sessionID, streamID, data) +} + +func (s *cleanupEventStore) After(ctx context.Context, sessionID, streamID string, index int) iter.Seq2[[]byte, error] { + return s.base.After(ctx, sessionID, streamID, index) +} + +func (s *cleanupEventStore) SessionClosed(ctx context.Context, sessionID string) error { + if err := s.base.SessionClosed(ctx, sessionID); err != nil { + return err + } + if s.onSessionClosed != nil { + s.onSessionClosed(sessionID) + } + return nil +} diff --git a/internal/mcpserver/eventstore_test.go b/internal/mcpserver/eventstore_test.go new file mode 100644 index 0000000..1f3de32 --- /dev/null +++ b/internal/mcpserver/eventstore_test.go @@ -0,0 +1,30 @@ +package mcpserver + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "git.warky.dev/wdevs/amcs/internal/session" +) + +func TestCleanupEventStoreSessionClosedClearsActiveProject(t *testing.T) { + activeProjects := session.NewActiveProjects() + activeProjects.Set("session-1", uuid.New()) + + store := newCleanupEventStore(mcp.NewMemoryEventStore(nil), activeProjects.Clear) + + if _, ok := activeProjects.Get("session-1"); !ok { + t.Fatal("active project missing before SessionClosed") + } + + if err := store.SessionClosed(context.Background(), "session-1"); err != nil { + t.Fatalf("SessionClosed() error = %v", err) + } + + if _, ok := activeProjects.Get("session-1"); ok { + t.Fatal("active project still present after SessionClosed") + } +} diff --git a/internal/mcpserver/schema.go b/internal/mcpserver/schema.go index e946315..18bfae8 100644 --- a/internal/mcpserver/schema.go +++ b/internal/mcpserver/schema.go @@ -6,15 +6,31 @@ import ( "fmt" "log/slog" "reflect" + "regexp" + "strings" "time" "github.com/google/jsonschema-go/jsonschema" "github.com/google/uuid" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" + + "git.warky.dev/wdevs/amcs/internal/mcperrors" ) const maxLoggedArgBytes = 512 +var sensitiveToolArgKeys = map[string]struct{}{ + "client_secret": {}, + "content": {}, + "content_base64": {}, + "content_path": {}, + "email": {}, + "notes": {}, + "phone": {}, + "value": {}, +} + var toolSchemaOptions = &jsonschema.ForOptions{ TypeSchemas: map[reflect.Type]*jsonschema.Schema{ reflect.TypeFor[uuid.UUID](): { @@ -24,19 +40,92 @@ var toolSchemaOptions = &jsonschema.ForOptions{ }, } -func addTool[In any, Out any](server *mcp.Server, logger *slog.Logger, tool *mcp.Tool, handler func(context.Context, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error)) { +var ( + missingPropertyPattern = regexp.MustCompile(`missing properties: \["([^"]+)"\]`) + propertyPathPattern = regexp.MustCompile(`validating /properties/([^:]+):`) + structFieldPattern = regexp.MustCompile(`\.([A-Za-z0-9_]+) of type`) +) + +func addTool[In any, Out any](server *mcp.Server, logger *slog.Logger, tool *mcp.Tool, handler func(context.Context, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error)) error { if err := setToolSchemas[In, Out](tool); err != nil { - panic(fmt.Sprintf("configure MCP tool %q schemas: %v", tool.Name, err)) + return fmt.Errorf("configure MCP tool %q schemas: %w", tool.Name, err) } - mcp.AddTool(server, tool, logToolCall(logger, tool.Name, handler)) + + inputResolved, err := resolveToolSchema(tool.InputSchema) + if err != nil { + return fmt.Errorf("resolve MCP tool %q input schema: %w", tool.Name, err) + } + outputResolved, err := resolveToolSchema(tool.OutputSchema) + if err != nil { + return fmt.Errorf("resolve MCP tool %q output schema: %w", tool.Name, err) + } + + server.AddTool(tool, logToolCall(logger, tool.Name, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var input json.RawMessage + if req != nil && req.Params != nil && req.Params.Arguments != nil { + input = req.Params.Arguments + } + + input, err = applyToolSchema(input, inputResolved) + if err != nil { + return nil, invalidArgumentsError(err) + } + + var in In + if input != nil { + if err := json.Unmarshal(input, &in); err != nil { + return nil, invalidArgumentsError(err) + } + } + + result, out, err := handler(ctx, req, in) + if err != nil { + if wireErr, ok := err.(*jsonrpc.Error); ok { + return nil, wireErr + } + var errResult mcp.CallToolResult + errResult.SetError(err) + return &errResult, nil + } + + if result == nil { + result = &mcp.CallToolResult{} + } + + if outputResolved == nil { + return result, nil + } + + outValue := normalizeTypedNil[Out](out) + if outValue == nil { + return result, nil + } + + outBytes, err := json.Marshal(outValue) + if err != nil { + return nil, fmt.Errorf("marshaling output: %w", err) + } + outJSON, err := applyToolSchema(json.RawMessage(outBytes), outputResolved) + if err != nil { + return nil, fmt.Errorf("validating tool output: %w", err) + } + + result.StructuredContent = outJSON + if result.Content == nil { + result.Content = []mcp.Content{&mcp.TextContent{Text: string(outJSON)}} + } + + return result, nil + })) + return nil } -func logToolCall[In any, Out any](logger *slog.Logger, toolName string, handler func(context.Context, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error)) func(context.Context, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error) { +func logToolCall(logger *slog.Logger, toolName string, handler mcp.ToolHandler) mcp.ToolHandler { if logger == nil { return handler } - return func(ctx context.Context, req *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) { + return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { start := time.Now() attrs := []any{slog.String("tool", toolName)} if req != nil && req.Params != nil { @@ -44,23 +133,27 @@ func logToolCall[In any, Out any](logger *slog.Logger, toolName string, handler } logger.Info("mcp tool started", attrs...) - result, out, err := handler(ctx, req, in) + result, err := handler(ctx, req) completionAttrs := append([]any{}, attrs...) completionAttrs = append(completionAttrs, slog.String("duration", formatLogDuration(time.Since(start)))) if err != nil { completionAttrs = append(completionAttrs, slog.String("error", err.Error())) logger.Error("mcp tool completed", completionAttrs...) - return result, out, err + return result, err } logger.Info("mcp tool completed", completionAttrs...) - return result, out, nil + return result, nil } } func truncateArgs(args any) string { - b, err := json.Marshal(args) + redacted, err := redactArguments(args) + if err != nil { + return "" + } + b, err := json.Marshal(redacted) if err != nil { return "" } @@ -70,6 +163,52 @@ func truncateArgs(args any) string { return string(b[:maxLoggedArgBytes]) + fmt.Sprintf("… (%d bytes total)", len(b)) } +func redactArguments(args any) (any, error) { + if args == nil { + return nil, nil + } + + b, err := json.Marshal(args) + if err != nil { + return nil, err + } + + var decoded any + if err := json.Unmarshal(b, &decoded); err != nil { + return nil, err + } + + return redactJSONValue("", decoded), nil +} + +func redactJSONValue(key string, value any) any { + if isSensitiveToolArgKey(key) { + return "" + } + + switch typed := value.(type) { + case map[string]any: + redacted := make(map[string]any, len(typed)) + for childKey, childValue := range typed { + redacted[childKey] = redactJSONValue(childKey, childValue) + } + return redacted + case []any: + redacted := make([]any, len(typed)) + for i, item := range typed { + redacted[i] = redactJSONValue("", item) + } + return redacted + default: + return value + } +} + +func isSensitiveToolArgKey(key string) bool { + _, ok := sensitiveToolArgKeys[strings.ToLower(strings.TrimSpace(key))] + return ok +} + func formatLogDuration(d time.Duration) string { if d < 0 { d = -d @@ -101,3 +240,104 @@ func setToolSchemas[In any, Out any](tool *mcp.Tool) error { return nil } + +func resolveToolSchema(schema any) (*jsonschema.Resolved, error) { + if schema == nil { + return nil, nil + } + + if typed, ok := schema.(*jsonschema.Schema); ok { + return typed.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) + } + + var remarshalTarget *jsonschema.Schema + if err := remarshalJSON(schema, &remarshalTarget); err != nil { + return nil, err + } + return remarshalTarget.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) +} + +func applyToolSchema(data json.RawMessage, resolved *jsonschema.Resolved) (json.RawMessage, error) { + if resolved == nil { + return data, nil + } + + value := make(map[string]any) + if len(data) > 0 { + if err := json.Unmarshal(data, &value); err != nil { + return nil, fmt.Errorf("unmarshaling arguments: %w", err) + } + } + if err := resolved.ApplyDefaults(&value); err != nil { + return nil, fmt.Errorf("applying schema defaults: %w", err) + } + if err := resolved.Validate(&value); err != nil { + return nil, err + } + + normalized, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("marshalling with defaults: %w", err) + } + return normalized, nil +} + +func remarshalJSON(src any, dst any) error { + b, err := json.Marshal(src) + if err != nil { + return err + } + return json.Unmarshal(b, dst) +} + +func normalizeTypedNil[T any](value T) any { + rt := reflect.TypeFor[T]() + if rt.Kind() == reflect.Pointer { + var zero T + if any(value) == any(zero) { + return reflect.Zero(rt.Elem()).Interface() + } + } + return any(value) +} + +func invalidArgumentsError(err error) error { + detail := err.Error() + field := validationField(detail) + + payload := mcperrors.Data{ + Type: mcperrors.TypeInvalidArguments, + Detail: detail, + } + if field != "" { + payload.Field = field + payload.Hint = "check the " + field + " argument" + } + + data, marshalErr := json.Marshal(payload) + if marshalErr != nil { + return &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: "invalid tool arguments", + } + } + + return &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: "invalid tool arguments", + Data: data, + } +} + +func validationField(detail string) string { + if matches := missingPropertyPattern.FindStringSubmatch(detail); len(matches) == 2 { + return matches[1] + } + if matches := propertyPathPattern.FindStringSubmatch(detail); len(matches) == 2 { + return matches[1] + } + if matches := structFieldPattern.FindStringSubmatch(detail); len(matches) == 2 { + return strings.ToLower(matches[1]) + } + return "" +} diff --git a/internal/mcpserver/schema_test.go b/internal/mcpserver/schema_test.go index ec37986..4f99882 100644 --- a/internal/mcpserver/schema_test.go +++ b/internal/mcpserver/schema_test.go @@ -1,6 +1,9 @@ package mcpserver import ( + "context" + "encoding/json" + "strings" "testing" "github.com/google/jsonschema-go/jsonschema" @@ -40,3 +43,59 @@ func TestSetToolSchemasUsesStringUUIDsInListOutput(t *testing.T) { t.Fatalf("id schema format = %q, want %q", idSchema.Format, "uuid") } } + +func TestTruncateArgsRedactsSensitiveFields(t *testing.T) { + args := json.RawMessage(`{ + "query": "todo from yesterday", + "content": "private thought body", + "content_base64": "c2VjcmV0LWJpbmFyeQ==", + "email": "user@example.com", + "nested": { + "notes": "private note", + "phone": "+1-555-0100", + "keep": "visible" + } + }`) + + got := truncateArgs(args) + + for _, secret := range []string{ + "private thought body", + "c2VjcmV0LWJpbmFyeQ==", + "user@example.com", + "private note", + "+1-555-0100", + } { + if strings.Contains(got, secret) { + t.Fatalf("truncateArgs leaked sensitive value %q in %s", secret, got) + } + } + + for _, expected := range []string{ + `"query":"todo from yesterday"`, + `"keep":"visible"`, + `"content":"\u003credacted\u003e"`, + `"content_base64":"\u003credacted\u003e"`, + `"email":"\u003credacted\u003e"`, + `"notes":"\u003credacted\u003e"`, + `"phone":"\u003credacted\u003e"`, + } { + if !strings.Contains(got, expected) { + t.Fatalf("truncateArgs(%s) missing %q", got, expected) + } + } +} + +func TestAddToolReturnsSchemaErrorInsteadOfPanicking(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil) + + err := addTool(server, nil, &mcp.Tool{Name: "broken"}, func(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, chan int, error) { + return nil, nil, nil + }) + if err == nil { + t.Fatal("addTool() error = nil, want schema inference error") + } + if !strings.Contains(err.Error(), `configure MCP tool "broken" schemas`) { + t.Fatalf("addTool() error = %q, want tool context", err.Error()) + } +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index fda582b..0580ab3 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -3,7 +3,6 @@ package mcpserver import ( "log/slog" "net/http" - "time" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -12,6 +11,7 @@ import ( ) type ToolSet struct { + Version *tools.VersionTool Capture *tools.CaptureTool Search *tools.SearchTool List *tools.ListTool @@ -37,355 +37,480 @@ type ToolSet struct { Skills *tools.SkillsTool } -func New(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet) http.Handler { +func New(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet, onSessionClosed func(string)) (http.Handler, error) { server := mcp.NewServer(&mcp.Implementation{ Name: cfg.ServerName, Version: cfg.Version, }, nil) - addTool(server, logger, &mcp.Tool{ + for _, register := range []func(*mcp.Server, *slog.Logger, ToolSet) error{ + registerSystemTools, + registerThoughtTools, + registerProjectTools, + registerFileTools, + registerMaintenanceTools, + registerHouseholdTools, + registerCalendarTools, + registerMealTools, + registerCRMTools, + registerSkillTools, + } { + if err := register(server, logger, toolSet); err != nil { + return nil, err + } + } + + opts := &mcp.StreamableHTTPOptions{ + JSONResponse: true, + SessionTimeout: cfg.SessionTimeout, + } + if onSessionClosed != nil { + opts.EventStore = newCleanupEventStore(mcp.NewMemoryEventStore(nil), onSessionClosed) + } + + return mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return server + }, opts), nil +} + +func registerSystemTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error { + if err := addTool(server, logger, &mcp.Tool{ + Name: "get_version_info", + Description: "Return the server build version information, including version, tag name, commit, and build date.", + }, toolSet.Version.GetInfo); err != nil { + return err + } + return nil +} + +func registerThoughtTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error { + if err := addTool(server, logger, &mcp.Tool{ Name: "capture_thought", Description: "Store a thought with generated embeddings and extracted metadata.", - }, toolSet.Capture.Handle) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Capture.Handle); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "search_thoughts", Description: "Search stored thoughts by semantic similarity.", - }, toolSet.Search.Handle) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Search.Handle); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "list_thoughts", Description: "List recent thoughts with optional metadata filters.", - }, toolSet.List.Handle) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.List.Handle); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "thought_stats", Description: "Get counts and top metadata buckets across stored thoughts.", - }, toolSet.Stats.Handle) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Stats.Handle); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "get_thought", Description: "Retrieve a full thought by id.", - }, toolSet.Get.Handle) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Get.Handle); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "update_thought", Description: "Update thought content or merge metadata.", - }, toolSet.Update.Handle) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Update.Handle); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "delete_thought", Description: "Hard-delete a thought by id.", - }, toolSet.Delete.Handle) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Delete.Handle); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "archive_thought", Description: "Archive a thought so it is hidden from default search and listing.", - }, toolSet.Archive.Handle) - - addTool(server, logger, &mcp.Tool{ - Name: "create_project", - Description: "Create a named project container for thoughts.", - }, toolSet.Projects.Create) - - addTool(server, logger, &mcp.Tool{ - Name: "list_projects", - Description: "List projects and their current thought counts.", - }, toolSet.Projects.List) - - addTool(server, logger, &mcp.Tool{ - Name: "set_active_project", - Description: "Set the active project for the current MCP session.", - }, toolSet.Projects.SetActive) - - addTool(server, logger, &mcp.Tool{ - Name: "get_active_project", - Description: "Return the active project for the current MCP session.", - }, toolSet.Projects.GetActive) - - addTool(server, logger, &mcp.Tool{ - Name: "get_project_context", - Description: "Get recent and semantic context for a project.", - }, toolSet.Context.Handle) - - addTool(server, logger, &mcp.Tool{ - Name: "recall_context", - Description: "Recall semantically relevant and recent context.", - }, toolSet.Recall.Handle) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Archive.Handle); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "summarize_thoughts", Description: "Summarize a filtered or searched set of thoughts.", - }, toolSet.Summarize.Handle) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Summarize.Handle); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ + Name: "recall_context", + Description: "Recall semantically relevant and recent context.", + }, toolSet.Recall.Handle); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "link_thoughts", Description: "Create a typed relationship between two thoughts.", - }, toolSet.Links.Link) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Links.Link); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "related_thoughts", Description: "Retrieve explicit links and semantic neighbors for a thought.", - }, toolSet.Links.Related) + }, toolSet.Links.Related); err != nil { + return err + } + return nil +} +func registerProjectTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error { + if err := addTool(server, logger, &mcp.Tool{ + Name: "create_project", + Description: "Create a named project container for thoughts.", + }, toolSet.Projects.Create); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ + Name: "list_projects", + Description: "List projects and their current thought counts.", + }, toolSet.Projects.List); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ + Name: "set_active_project", + Description: "Set the active project for the current MCP session. Requires a stateful MCP client that reuses the same session across calls.", + }, toolSet.Projects.SetActive); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ + Name: "get_active_project", + Description: "Return the active project for the current MCP session. If your client does not preserve MCP sessions, pass project explicitly to project-scoped tools instead.", + }, toolSet.Projects.GetActive); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ + Name: "get_project_context", + Description: "Get recent and semantic context for a project. Uses the explicit project when provided, otherwise the active MCP session project.", + }, toolSet.Context.Handle); err != nil { + return err + } + return nil +} + +func registerFileTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error { server.AddResourceTemplate(&mcp.ResourceTemplate{ Name: "stored_file", URITemplate: "amcs://files/{id}", Description: "A stored file. Read a file's raw binary content by its id. Use load_file for metadata.", }, toolSet.Files.ReadResource) - addTool(server, logger, &mcp.Tool{ + if err := addTool(server, logger, &mcp.Tool{ Name: "upload_file", Description: "Stage a file and get an amcs://files/{id} resource URI. Provide content_path (absolute server-side path, no size limit) or content_base64 (≤10 MB). Optionally link immediately with thought_id/project, or omit them and pass the returned URI to save_file later.", - }, toolSet.Files.Upload) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Files.Upload); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "save_file", Description: "Store a file and optionally link it to a thought. Supply either content_base64 (≤10 MB) or content_uri (amcs://files/{id} from a prior upload_file or POST /files call). For files larger than 10 MB, use upload_file with content_path first.", - }, toolSet.Files.Save) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Files.Save); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "load_file", Description: "Load a previously stored file by id and return its metadata and base64 content.", - }, toolSet.Files.Load) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Files.Load); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "list_files", Description: "List stored files, optionally filtered by thought, project, or kind.", - }, toolSet.Files.List) + }, toolSet.Files.List); err != nil { + return err + } + return nil +} - addTool(server, logger, &mcp.Tool{ +func registerMaintenanceTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error { + if err := addTool(server, logger, &mcp.Tool{ Name: "backfill_embeddings", Description: "Generate missing embeddings for stored thoughts using the active embedding model.", - }, toolSet.Backfill.Handle) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Backfill.Handle); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "reparse_thought_metadata", Description: "Re-extract and normalize metadata for stored thoughts from their content.", - }, toolSet.Reparse.Handle) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Reparse.Handle); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "retry_failed_metadata", Description: "Retry metadata extraction for thoughts still marked pending or failed.", - }, toolSet.RetryMetadata.Handle) - - // Household Knowledge - addTool(server, logger, &mcp.Tool{ - Name: "add_household_item", - Description: "Store a household fact (paint color, appliance details, measurement, document, etc.).", - }, toolSet.Household.AddItem) - - addTool(server, logger, &mcp.Tool{ - Name: "search_household_items", - Description: "Search household items by name, category, or location.", - }, toolSet.Household.SearchItems) - - addTool(server, logger, &mcp.Tool{ - Name: "get_household_item", - Description: "Retrieve a household item by id.", - }, toolSet.Household.GetItem) - - addTool(server, logger, &mcp.Tool{ - Name: "add_vendor", - Description: "Add a service provider (plumber, electrician, landscaper, etc.).", - }, toolSet.Household.AddVendor) - - addTool(server, logger, &mcp.Tool{ - Name: "list_vendors", - Description: "List household service vendors, optionally filtered by service type.", - }, toolSet.Household.ListVendors) - - // Home Maintenance - addTool(server, logger, &mcp.Tool{ + }, toolSet.RetryMetadata.Handle); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "add_maintenance_task", Description: "Create a recurring or one-time home maintenance task.", - }, toolSet.Maintenance.AddTask) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Maintenance.AddTask); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "log_maintenance", Description: "Log completed maintenance work; automatically updates the task's next due date.", - }, toolSet.Maintenance.LogWork) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Maintenance.LogWork); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "get_upcoming_maintenance", Description: "List maintenance tasks due within the next N days.", - }, toolSet.Maintenance.GetUpcoming) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Maintenance.GetUpcoming); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "search_maintenance_history", Description: "Search the maintenance log by task name, category, or date range.", - }, toolSet.Maintenance.SearchHistory) + }, toolSet.Maintenance.SearchHistory); err != nil { + return err + } + return nil +} - // Family Calendar - addTool(server, logger, &mcp.Tool{ +func registerHouseholdTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error { + if err := addTool(server, logger, &mcp.Tool{ + Name: "add_household_item", + Description: "Store a household fact (paint color, appliance details, measurement, document, etc.).", + }, toolSet.Household.AddItem); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ + Name: "search_household_items", + Description: "Search household items by name, category, or location.", + }, toolSet.Household.SearchItems); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ + Name: "get_household_item", + Description: "Retrieve a household item by id.", + }, toolSet.Household.GetItem); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ + Name: "add_vendor", + Description: "Add a service provider (plumber, electrician, landscaper, etc.).", + }, toolSet.Household.AddVendor); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ + Name: "list_vendors", + Description: "List household service vendors, optionally filtered by service type.", + }, toolSet.Household.ListVendors); err != nil { + return err + } + return nil +} + +func registerCalendarTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error { + if err := addTool(server, logger, &mcp.Tool{ Name: "add_family_member", Description: "Add a family member to the household.", - }, toolSet.Calendar.AddMember) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Calendar.AddMember); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "list_family_members", Description: "List all family members.", - }, toolSet.Calendar.ListMembers) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Calendar.ListMembers); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "add_activity", Description: "Schedule a one-time or recurring family activity.", - }, toolSet.Calendar.AddActivity) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Calendar.AddActivity); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "get_week_schedule", Description: "Get all activities scheduled for a given week.", - }, toolSet.Calendar.GetWeekSchedule) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Calendar.GetWeekSchedule); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "search_activities", Description: "Search activities by title, type, or family member.", - }, toolSet.Calendar.SearchActivities) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Calendar.SearchActivities); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "add_important_date", Description: "Track a birthday, anniversary, deadline, or other important date.", - }, toolSet.Calendar.AddImportantDate) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Calendar.AddImportantDate); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "get_upcoming_dates", Description: "Get important dates coming up in the next N days.", - }, toolSet.Calendar.GetUpcomingDates) + }, toolSet.Calendar.GetUpcomingDates); err != nil { + return err + } + return nil +} - // Meal Planning - addTool(server, logger, &mcp.Tool{ +func registerMealTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error { + if err := addTool(server, logger, &mcp.Tool{ Name: "add_recipe", Description: "Save a recipe with ingredients and instructions.", - }, toolSet.Meals.AddRecipe) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Meals.AddRecipe); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "search_recipes", Description: "Search recipes by name, cuisine, tags, or ingredient.", - }, toolSet.Meals.SearchRecipes) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Meals.SearchRecipes); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "update_recipe", Description: "Update an existing recipe.", - }, toolSet.Meals.UpdateRecipe) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Meals.UpdateRecipe); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "create_meal_plan", Description: "Set the meal plan for a week; replaces any existing plan for that week.", - }, toolSet.Meals.CreateMealPlan) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Meals.CreateMealPlan); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "get_meal_plan", Description: "Get the meal plan for a given week.", - }, toolSet.Meals.GetMealPlan) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Meals.GetMealPlan); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "generate_shopping_list", Description: "Auto-generate a shopping list from the meal plan for a given week.", - }, toolSet.Meals.GenerateShoppingList) + }, toolSet.Meals.GenerateShoppingList); err != nil { + return err + } + return nil +} - // Professional CRM - addTool(server, logger, &mcp.Tool{ +func registerCRMTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error { + if err := addTool(server, logger, &mcp.Tool{ Name: "add_professional_contact", Description: "Add a professional contact to the CRM.", - }, toolSet.CRM.AddContact) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.CRM.AddContact); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "search_contacts", Description: "Search professional contacts by name, company, title, notes, or tags.", - }, toolSet.CRM.SearchContacts) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.CRM.SearchContacts); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "log_interaction", Description: "Log an interaction with a professional contact.", - }, toolSet.CRM.LogInteraction) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.CRM.LogInteraction); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "get_contact_history", Description: "Get full history (interactions and opportunities) for a contact.", - }, toolSet.CRM.GetHistory) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.CRM.GetHistory); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "create_opportunity", Description: "Create a deal, project, or opportunity linked to a contact.", - }, toolSet.CRM.CreateOpportunity) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.CRM.CreateOpportunity); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "get_follow_ups_due", Description: "List contacts with a follow-up date due within the next N days.", - }, toolSet.CRM.GetFollowUpsDue) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.CRM.GetFollowUpsDue); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "link_thought_to_contact", Description: "Append a stored thought to a contact's notes.", - }, toolSet.CRM.LinkThought) + }, toolSet.CRM.LinkThought); err != nil { + return err + } + return nil +} - // Agent Skills - addTool(server, logger, &mcp.Tool{ +func registerSkillTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error { + if err := addTool(server, logger, &mcp.Tool{ Name: "add_skill", Description: "Store a reusable agent skill (behavioural instruction or capability prompt).", - }, toolSet.Skills.AddSkill) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Skills.AddSkill); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "remove_skill", Description: "Delete an agent skill by id.", - }, toolSet.Skills.RemoveSkill) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Skills.RemoveSkill); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "list_skills", Description: "List all agent skills, optionally filtered by tag.", - }, toolSet.Skills.ListSkills) - - // Agent Guardrails - addTool(server, logger, &mcp.Tool{ + }, toolSet.Skills.ListSkills); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "add_guardrail", Description: "Store a reusable agent guardrail (constraint or safety rule).", - }, toolSet.Skills.AddGuardrail) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Skills.AddGuardrail); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "remove_guardrail", Description: "Delete an agent guardrail by id.", - }, toolSet.Skills.RemoveGuardrail) - - addTool(server, logger, &mcp.Tool{ + }, toolSet.Skills.RemoveGuardrail); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "list_guardrails", Description: "List all agent guardrails, optionally filtered by tag or severity.", - }, toolSet.Skills.ListGuardrails) - - // Project Skills & Guardrails - addTool(server, logger, &mcp.Tool{ + }, toolSet.Skills.ListGuardrails); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "add_project_skill", - Description: "Link an agent skill to a project.", - }, toolSet.Skills.AddProjectSkill) - - addTool(server, logger, &mcp.Tool{ + Description: "Link an agent skill to a project. Pass project explicitly when your client does not preserve MCP sessions.", + }, toolSet.Skills.AddProjectSkill); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "remove_project_skill", - Description: "Unlink an agent skill from a project.", - }, toolSet.Skills.RemoveProjectSkill) - - addTool(server, logger, &mcp.Tool{ + Description: "Unlink an agent skill from a project. Pass project explicitly when your client does not preserve MCP sessions.", + }, toolSet.Skills.RemoveProjectSkill); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "list_project_skills", - Description: "List all skills linked to a project. Call this at the start of a project session to load existing agent behaviour instructions before generating new ones.", - }, toolSet.Skills.ListProjectSkills) - - addTool(server, logger, &mcp.Tool{ + Description: "List all skills linked to a project. Call this at the start of a project session to load existing agent behaviour instructions before generating new ones. Pass project explicitly when your client does not preserve MCP sessions.", + }, toolSet.Skills.ListProjectSkills); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "add_project_guardrail", - Description: "Link an agent guardrail to a project.", - }, toolSet.Skills.AddProjectGuardrail) - - addTool(server, logger, &mcp.Tool{ + Description: "Link an agent guardrail to a project. Pass project explicitly when your client does not preserve MCP sessions.", + }, toolSet.Skills.AddProjectGuardrail); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "remove_project_guardrail", - Description: "Unlink an agent guardrail from a project.", - }, toolSet.Skills.RemoveProjectGuardrail) - - addTool(server, logger, &mcp.Tool{ + Description: "Unlink an agent guardrail from a project. Pass project explicitly when your client does not preserve MCP sessions.", + }, toolSet.Skills.RemoveProjectGuardrail); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ Name: "list_project_guardrails", - Description: "List all guardrails linked to a project. Call this at the start of a project session to load existing agent constraints before generating new ones.", - }, toolSet.Skills.ListProjectGuardrails) - - return mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { - return server - }, &mcp.StreamableHTTPOptions{ - JSONResponse: true, - SessionTimeout: 10 * time.Minute, - }) + Description: "List all guardrails linked to a project. Call this at the start of a project session to load existing agent constraints before generating new ones. Pass project explicitly when your client does not preserve MCP sessions.", + }, toolSet.Skills.ListProjectGuardrails); err != nil { + return err + } + return nil } diff --git a/internal/mcpserver/streamable_integration_test.go b/internal/mcpserver/streamable_integration_test.go new file mode 100644 index 0000000..83d0ef1 --- /dev/null +++ b/internal/mcpserver/streamable_integration_test.go @@ -0,0 +1,137 @@ +package mcpserver + +import ( + "context" + "net/http/httptest" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "git.warky.dev/wdevs/amcs/internal/buildinfo" + "git.warky.dev/wdevs/amcs/internal/config" + "git.warky.dev/wdevs/amcs/internal/mcperrors" + "git.warky.dev/wdevs/amcs/internal/tools" +) + +func TestStreamableHTTPReturnsStructuredToolErrors(t *testing.T) { + handler, err := New(config.MCPConfig{ + ServerName: "test", + Version: "0.0.1", + SessionTimeout: time.Minute, + }, nil, streamableTestToolSet(), nil) + if err != nil { + t.Fatalf("mcpserver.New() error = %v", err) + } + + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "0.0.1"}, nil) + cs, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, nil) + if err != nil { + t.Fatalf("connect client: %v", err) + } + defer func() { + _ = cs.Close() + }() + + t.Run("schema_validation", func(t *testing.T) { + _, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "create_project", + Arguments: map[string]any{}, + }) + if err == nil { + t.Fatal("CallTool(create_project) error = nil, want error") + } + + rpcErr, data := requireWireError(t, err) + if rpcErr.Code != jsonrpc.CodeInvalidParams { + t.Fatalf("create_project code = %d, want %d", rpcErr.Code, jsonrpc.CodeInvalidParams) + } + if data.Type != mcperrors.TypeInvalidArguments { + t.Fatalf("create_project data.type = %q, want %q", data.Type, mcperrors.TypeInvalidArguments) + } + if data.Field != "name" { + t.Fatalf("create_project data.field = %q, want %q", data.Field, "name") + } + }) + + t.Run("project_required", func(t *testing.T) { + _, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_project_context", + Arguments: map[string]any{}, + }) + if err == nil { + t.Fatal("CallTool(get_project_context) error = nil, want error") + } + + rpcErr, data := requireWireError(t, err) + if rpcErr.Code != mcperrors.CodeProjectRequired { + t.Fatalf("get_project_context code = %d, want %d", rpcErr.Code, mcperrors.CodeProjectRequired) + } + if data.Type != mcperrors.TypeProjectRequired { + t.Fatalf("get_project_context data.type = %q, want %q", data.Type, mcperrors.TypeProjectRequired) + } + if data.Field != "project" { + t.Fatalf("get_project_context data.field = %q, want %q", data.Field, "project") + } + }) + + t.Run("version_info", func(t *testing.T) { + result, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_version_info", + Arguments: map[string]any{}, + }) + if err != nil { + t.Fatalf("CallTool(get_version_info) error = %v", err) + } + + got, ok := result.StructuredContent.(map[string]any) + if !ok { + t.Fatalf("structured content type = %T, want map[string]any", result.StructuredContent) + } + if got["server_name"] != "test" { + t.Fatalf("server_name = %#v, want %q", got["server_name"], "test") + } + if got["version"] != "0.0.1" { + t.Fatalf("version = %#v, want %q", got["version"], "0.0.1") + } + if got["tag_name"] != "v0.0.1" { + t.Fatalf("tag_name = %#v, want %q", got["tag_name"], "v0.0.1") + } + if got["build_date"] != "2026-03-31T00:00:00Z" { + t.Fatalf("build_date = %#v, want %q", got["build_date"], "2026-03-31T00:00:00Z") + } + }) +} + +func streamableTestToolSet() ToolSet { + return ToolSet{ + Version: tools.NewVersionTool("test", buildinfo.Info{Version: "0.0.1", TagName: "v0.0.1", Commit: "test", BuildDate: "2026-03-31T00:00:00Z"}), + Capture: new(tools.CaptureTool), + Search: new(tools.SearchTool), + List: new(tools.ListTool), + Stats: new(tools.StatsTool), + Get: new(tools.GetTool), + Update: new(tools.UpdateTool), + Delete: new(tools.DeleteTool), + Archive: new(tools.ArchiveTool), + Projects: new(tools.ProjectsTool), + Context: new(tools.ContextTool), + Recall: new(tools.RecallTool), + Summarize: new(tools.SummarizeTool), + Links: new(tools.LinksTool), + Files: new(tools.FilesTool), + Backfill: new(tools.BackfillTool), + Reparse: new(tools.ReparseMetadataTool), + RetryMetadata: new(tools.RetryMetadataTool), + Household: new(tools.HouseholdTool), + Maintenance: new(tools.MaintenanceTool), + Calendar: new(tools.CalendarTool), + Meals: new(tools.MealsTool), + CRM: new(tools.CRMTool), + Skills: new(tools.SkillsTool), + } +} diff --git a/internal/store/files.go b/internal/store/files.go index fe07106..8b9b8d5 100644 --- a/internal/store/files.go +++ b/internal/store/files.go @@ -136,7 +136,9 @@ func (db *DB) AddThoughtAttachment(ctx context.Context, thoughtID uuid.UUID, att if err != nil { return fmt.Errorf("begin transaction: %w", err) } - defer tx.Rollback(ctx) + defer func() { + _ = tx.Rollback(ctx) + }() var metadataBytes []byte if err := tx.QueryRow(ctx, `select metadata from thoughts where guid = $1 for update`, thoughtID).Scan(&metadataBytes); err != nil { diff --git a/internal/store/thoughts.go b/internal/store/thoughts.go index e1a09bb..2c18df4 100644 --- a/internal/store/thoughts.go +++ b/internal/store/thoughts.go @@ -25,7 +25,9 @@ func (db *DB) InsertThought(ctx context.Context, thought thoughttypes.Thought, e if err != nil { return thoughttypes.Thought{}, fmt.Errorf("begin transaction: %w", err) } - defer tx.Rollback(ctx) + defer func() { + _ = tx.Rollback(ctx) + }() row := tx.QueryRow(ctx, ` insert into thoughts (content, metadata, project_id) @@ -240,7 +242,9 @@ func (db *DB) UpdateThought(ctx context.Context, id uuid.UUID, content string, e if err != nil { return thoughttypes.Thought{}, fmt.Errorf("begin transaction: %w", err) } - defer tx.Rollback(ctx) + defer func() { + _ = tx.Rollback(ctx) + }() tag, err := tx.Exec(ctx, ` update thoughts diff --git a/internal/tools/calendar.go b/internal/tools/calendar.go index 089c68e..348cf54 100644 --- a/internal/tools/calendar.go +++ b/internal/tools/calendar.go @@ -35,7 +35,7 @@ type AddFamilyMemberOutput struct { func (t *CalendarTool) AddMember(ctx context.Context, _ *mcp.CallToolRequest, in AddFamilyMemberInput) (*mcp.CallToolResult, AddFamilyMemberOutput, error) { if strings.TrimSpace(in.Name) == "" { - return nil, AddFamilyMemberOutput{}, errInvalidInput("name is required") + return nil, AddFamilyMemberOutput{}, errRequiredField("name") } member, err := t.store.AddFamilyMember(ctx, ext.FamilyMember{ Name: strings.TrimSpace(in.Name), @@ -89,7 +89,7 @@ type AddActivityOutput struct { func (t *CalendarTool) AddActivity(ctx context.Context, _ *mcp.CallToolRequest, in AddActivityInput) (*mcp.CallToolResult, AddActivityOutput, error) { if strings.TrimSpace(in.Title) == "" { - return nil, AddActivityOutput{}, errInvalidInput("title is required") + return nil, AddActivityOutput{}, errRequiredField("title") } activity, err := t.store.AddActivity(ctx, ext.Activity{ FamilyMemberID: in.FamilyMemberID, @@ -170,7 +170,7 @@ type AddImportantDateOutput struct { func (t *CalendarTool) AddImportantDate(ctx context.Context, _ *mcp.CallToolRequest, in AddImportantDateInput) (*mcp.CallToolResult, AddImportantDateOutput, error) { if strings.TrimSpace(in.Title) == "" { - return nil, AddImportantDateOutput{}, errInvalidInput("title is required") + return nil, AddImportantDateOutput{}, errRequiredField("title") } reminder := in.ReminderDaysBefore if reminder <= 0 { diff --git a/internal/tools/capture.go b/internal/tools/capture.go index e334975..bd90bba 100644 --- a/internal/tools/capture.go +++ b/internal/tools/capture.go @@ -43,7 +43,7 @@ func NewCaptureTool(db *store.DB, provider ai.Provider, capture config.CaptureCo func (t *CaptureTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in CaptureInput) (*mcp.CallToolResult, CaptureOutput, error) { content := strings.TrimSpace(in.Content) if content == "" { - return nil, CaptureOutput{}, errInvalidInput("content is required") + return nil, CaptureOutput{}, errRequiredField("content") } project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false) diff --git a/internal/tools/common.go b/internal/tools/common.go index c2567b2..4230d52 100644 --- a/internal/tools/common.go +++ b/internal/tools/common.go @@ -1,9 +1,13 @@ package tools import ( + "encoding/json" "fmt" + "strings" "git.warky.dev/wdevs/amcs/internal/config" + "git.warky.dev/wdevs/amcs/internal/mcperrors" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) func normalizeLimit(limit int, cfg config.SearchConfig) int { @@ -26,6 +30,116 @@ func normalizeThreshold(value float64, fallback float64) float64 { return value } -func errInvalidInput(message string) error { - return fmt.Errorf("invalid input: %s", message) +const ( + codeSessionRequired = mcperrors.CodeSessionRequired + codeProjectRequired = mcperrors.CodeProjectRequired + codeProjectNotFound = mcperrors.CodeProjectNotFound + codeInvalidID = mcperrors.CodeInvalidID + codeEntityNotFound = mcperrors.CodeEntityNotFound +) + +type mcpErrorData = mcperrors.Data + +func newMCPError(code int64, message string, data mcpErrorData) error { + rpcErr := &jsonrpc.Error{ + Code: code, + Message: message, + } + + payload, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("marshal mcp error data: %w", err) + } + rpcErr.Data = payload + + return rpcErr +} + +func errInvalidInput(message string) error { + return newMCPError( + jsonrpc.CodeInvalidParams, + "invalid input: "+message, + mcpErrorData{ + Type: mcperrors.TypeInvalidInput, + }, + ) +} + +func errRequiredField(field string) error { + return newMCPError( + jsonrpc.CodeInvalidParams, + field+" is required", + mcpErrorData{ + Type: mcperrors.TypeInvalidInput, + Field: field, + Detail: "required", + Hint: "provide " + field, + }, + ) +} + +func errInvalidField(field string, message string, hint string) error { + return newMCPError( + jsonrpc.CodeInvalidParams, + message, + mcpErrorData{ + Type: mcperrors.TypeInvalidInput, + Field: field, + Detail: "invalid", + Hint: hint, + }, + ) +} + +func errOneOfRequired(fields ...string) error { + return newMCPError( + jsonrpc.CodeInvalidParams, + joinFields(fields)+" is required", + mcpErrorData{ + Type: mcperrors.TypeInvalidInput, + Fields: fields, + Detail: "one_of_required", + Hint: "provide one of: " + strings.Join(fields, ", "), + }, + ) +} + +func errMutuallyExclusiveFields(fields ...string) error { + return newMCPError( + jsonrpc.CodeInvalidParams, + "provide "+joinFields(fields)+", not both", + mcpErrorData{ + Type: mcperrors.TypeInvalidInput, + Fields: fields, + Detail: "mutually_exclusive", + Hint: "provide only one of: " + strings.Join(fields, ", "), + }, + ) +} + +func errEntityNotFound(entity string, field string, value string) error { + return newMCPError( + codeEntityNotFound, + entity+" not found", + mcpErrorData{ + Type: mcperrors.TypeEntityNotFound, + Entity: entity, + Field: field, + Value: value, + Detail: "not_found", + }, + ) +} + +func joinFields(fields []string) string { + switch len(fields) { + case 0: + return "field" + case 1: + return fields[0] + case 2: + return fields[0] + " or " + fields[1] + default: + return strings.Join(fields[:len(fields)-1], ", ") + ", or " + fields[len(fields)-1] + } } diff --git a/internal/tools/common_test.go b/internal/tools/common_test.go new file mode 100644 index 0000000..70018e3 --- /dev/null +++ b/internal/tools/common_test.go @@ -0,0 +1,84 @@ +package tools + +import ( + "testing" + + "git.warky.dev/wdevs/amcs/internal/mcperrors" +) + +func TestErrRequiredFieldReturnsFieldMetadata(t *testing.T) { + rpcErr, data := requireRPCError(t, errRequiredField("name")) + if data.Type != mcperrors.TypeInvalidInput { + t.Fatalf("errRequiredField() type = %q, want %q", data.Type, mcperrors.TypeInvalidInput) + } + if data.Field != "name" { + t.Fatalf("errRequiredField() field = %q, want %q", data.Field, "name") + } + if data.Detail != "required" { + t.Fatalf("errRequiredField() detail = %q, want %q", data.Detail, "required") + } + if rpcErr.Message != "name is required" { + t.Fatalf("errRequiredField() message = %q, want %q", rpcErr.Message, "name is required") + } +} + +func TestErrInvalidFieldReturnsFieldMetadata(t *testing.T) { + rpcErr, data := requireRPCError(t, errInvalidField("severity", "severity must be one of: low, medium, high, critical", "pass one of: low, medium, high, critical")) + if data.Field != "severity" { + t.Fatalf("errInvalidField() field = %q, want %q", data.Field, "severity") + } + if data.Detail != "invalid" { + t.Fatalf("errInvalidField() detail = %q, want %q", data.Detail, "invalid") + } + if data.Hint == "" { + t.Fatal("errInvalidField() hint = empty, want guidance") + } + if rpcErr.Message == "" { + t.Fatal("errInvalidField() message = empty, want non-empty") + } +} + +func TestErrOneOfRequiredReturnsFieldsMetadata(t *testing.T) { + rpcErr, data := requireRPCError(t, errOneOfRequired("content_base64", "content_uri")) + if data.Detail != "one_of_required" { + t.Fatalf("errOneOfRequired() detail = %q, want %q", data.Detail, "one_of_required") + } + if len(data.Fields) != 2 || data.Fields[0] != "content_base64" || data.Fields[1] != "content_uri" { + t.Fatalf("errOneOfRequired() fields = %#v, want [content_base64 content_uri]", data.Fields) + } + if rpcErr.Message != "content_base64 or content_uri is required" { + t.Fatalf("errOneOfRequired() message = %q, want %q", rpcErr.Message, "content_base64 or content_uri is required") + } +} + +func TestErrMutuallyExclusiveFieldsReturnsFieldsMetadata(t *testing.T) { + rpcErr, data := requireRPCError(t, errMutuallyExclusiveFields("content_uri", "content_base64")) + if data.Detail != "mutually_exclusive" { + t.Fatalf("errMutuallyExclusiveFields() detail = %q, want %q", data.Detail, "mutually_exclusive") + } + if len(data.Fields) != 2 || data.Fields[0] != "content_uri" || data.Fields[1] != "content_base64" { + t.Fatalf("errMutuallyExclusiveFields() fields = %#v, want [content_uri content_base64]", data.Fields) + } + if rpcErr.Message != "provide content_uri or content_base64, not both" { + t.Fatalf("errMutuallyExclusiveFields() message = %q, want %q", rpcErr.Message, "provide content_uri or content_base64, not both") + } +} + +func TestErrEntityNotFoundReturnsEntityMetadata(t *testing.T) { + rpcErr, data := requireRPCError(t, errEntityNotFound("thought", "thought_id", "123")) + if rpcErr.Code != codeEntityNotFound { + t.Fatalf("errEntityNotFound() code = %d, want %d", rpcErr.Code, codeEntityNotFound) + } + if data.Type != mcperrors.TypeEntityNotFound { + t.Fatalf("errEntityNotFound() type = %q, want %q", data.Type, mcperrors.TypeEntityNotFound) + } + if data.Entity != "thought" { + t.Fatalf("errEntityNotFound() entity = %q, want %q", data.Entity, "thought") + } + if data.Field != "thought_id" { + t.Fatalf("errEntityNotFound() field = %q, want %q", data.Field, "thought_id") + } + if data.Value != "123" { + t.Fatalf("errEntityNotFound() value = %q, want %q", data.Value, "123") + } +} diff --git a/internal/tools/crm.go b/internal/tools/crm.go index 03f72b4..861feb7 100644 --- a/internal/tools/crm.go +++ b/internal/tools/crm.go @@ -2,11 +2,13 @@ package tools import ( "context" + "errors" "fmt" "strings" "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/modelcontextprotocol/go-sdk/mcp" "git.warky.dev/wdevs/amcs/internal/store" @@ -24,15 +26,15 @@ func NewCRMTool(db *store.DB) *CRMTool { // add_professional_contact type AddContactInput struct { - Name string `json:"name" jsonschema:"contact's full name"` - Company string `json:"company,omitempty"` - Title string `json:"title,omitempty" jsonschema:"job title"` - Email string `json:"email,omitempty"` - Phone string `json:"phone,omitempty"` - LinkedInURL string `json:"linkedin_url,omitempty"` - HowWeMet string `json:"how_we_met,omitempty"` - Tags []string `json:"tags,omitempty"` - Notes string `json:"notes,omitempty"` + Name string `json:"name" jsonschema:"contact's full name"` + Company string `json:"company,omitempty"` + Title string `json:"title,omitempty" jsonschema:"job title"` + Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` + LinkedInURL string `json:"linkedin_url,omitempty"` + HowWeMet string `json:"how_we_met,omitempty"` + Tags []string `json:"tags,omitempty"` + Notes string `json:"notes,omitempty"` FollowUpDate *time.Time `json:"follow_up_date,omitempty"` } @@ -42,7 +44,7 @@ type AddContactOutput struct { func (t *CRMTool) AddContact(ctx context.Context, _ *mcp.CallToolRequest, in AddContactInput) (*mcp.CallToolResult, AddContactOutput, error) { if strings.TrimSpace(in.Name) == "" { - return nil, AddContactOutput{}, errInvalidInput("name is required") + return nil, AddContactOutput{}, errRequiredField("name") } if in.Tags == nil { in.Tags = []string{} @@ -104,7 +106,7 @@ type LogInteractionOutput struct { func (t *CRMTool) LogInteraction(ctx context.Context, _ *mcp.CallToolRequest, in LogInteractionInput) (*mcp.CallToolResult, LogInteractionOutput, error) { if strings.TrimSpace(in.Summary) == "" { - return nil, LogInteractionOutput{}, errInvalidInput("summary is required") + return nil, LogInteractionOutput{}, errRequiredField("summary") } occurredAt := time.Now() if in.OccurredAt != nil { @@ -160,7 +162,7 @@ type CreateOpportunityOutput struct { func (t *CRMTool) CreateOpportunity(ctx context.Context, _ *mcp.CallToolRequest, in CreateOpportunityInput) (*mcp.CallToolResult, CreateOpportunityOutput, error) { if strings.TrimSpace(in.Title) == "" { - return nil, CreateOpportunityOutput{}, errInvalidInput("title is required") + return nil, CreateOpportunityOutput{}, errRequiredField("title") } stage := strings.TrimSpace(in.Stage) if stage == "" { @@ -216,7 +218,10 @@ type LinkThoughtToContactOutput struct { func (t *CRMTool) LinkThought(ctx context.Context, _ *mcp.CallToolRequest, in LinkThoughtToContactInput) (*mcp.CallToolResult, LinkThoughtToContactOutput, error) { thought, err := t.store.GetThought(ctx, in.ThoughtID) if err != nil { - return nil, LinkThoughtToContactOutput{}, fmt.Errorf("thought not found: %w", err) + if errors.Is(err, pgx.ErrNoRows) { + return nil, LinkThoughtToContactOutput{}, errEntityNotFound("thought", "thought_id", in.ThoughtID.String()) + } + return nil, LinkThoughtToContactOutput{}, err } appendText := fmt.Sprintf("\n\n[Linked thought %s]: %s", thought.ID, thought.Content) @@ -226,6 +231,9 @@ func (t *CRMTool) LinkThought(ctx context.Context, _ *mcp.CallToolRequest, in Li contact, err := t.store.GetContact(ctx, in.ContactID) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, LinkThoughtToContactOutput{}, errEntityNotFound("contact", "contact_id", in.ContactID.String()) + } return nil, LinkThoughtToContactOutput{}, err } return nil, LinkThoughtToContactOutput{Contact: contact}, nil diff --git a/internal/tools/files.go b/internal/tools/files.go index 471c5f2..c5cacd7 100644 --- a/internal/tools/files.go +++ b/internal/tools/files.go @@ -52,7 +52,7 @@ type SaveFileOutput struct { } type LoadFileInput struct { - ID string `json:"id" jsonschema:"the stored file id"` + ID string `json:"id" jsonschema:"the stored file id or amcs://files/{id} URI"` } type LoadFileOutput struct { @@ -95,7 +95,7 @@ func (t *FilesTool) Upload(ctx context.Context, req *mcp.CallToolRequest, in Upl b64 := strings.TrimSpace(in.ContentBase64) if path != "" && b64 != "" { - return nil, UploadFileOutput{}, errInvalidInput("provide content_path or content_base64, not both") + return nil, UploadFileOutput{}, errMutuallyExclusiveFields("content_path", "content_base64") } var content []byte @@ -103,7 +103,11 @@ func (t *FilesTool) Upload(ctx context.Context, req *mcp.CallToolRequest, in Upl if path != "" { if !filepath.IsAbs(path) { - return nil, UploadFileOutput{}, errInvalidInput("content_path must be an absolute path") + return nil, UploadFileOutput{}, errInvalidField( + "content_path", + "content_path must be an absolute path", + "pass an absolute path on the server filesystem", + ) } var err error content, err = os.ReadFile(path) @@ -112,7 +116,7 @@ func (t *FilesTool) Upload(ctx context.Context, req *mcp.CallToolRequest, in Upl } } else { if b64 == "" { - return nil, UploadFileOutput{}, errInvalidInput("content_path or content_base64 is required") + return nil, UploadFileOutput{}, errOneOfRequired("content_path", "content_base64") } if len(b64) > maxBase64ToolBytes { return nil, UploadFileOutput{}, errInvalidInput( @@ -123,7 +127,11 @@ func (t *FilesTool) Upload(ctx context.Context, req *mcp.CallToolRequest, in Upl var err error content, err = decodeBase64(raw) if err != nil { - return nil, UploadFileOutput{}, errInvalidInput("content_base64 must be valid base64") + return nil, UploadFileOutput{}, errInvalidField( + "content_base64", + "content_base64 must be valid base64", + "pass valid base64 data or a data URL", + ) } mediaTypeFromSource = dataURLMediaType } @@ -149,7 +157,7 @@ func (t *FilesTool) Save(ctx context.Context, req *mcp.CallToolRequest, in SaveF b64 := strings.TrimSpace(in.ContentBase64) if uri != "" && b64 != "" { - return nil, SaveFileOutput{}, errInvalidInput("provide content_uri or content_base64, not both") + return nil, SaveFileOutput{}, errMutuallyExclusiveFields("content_uri", "content_base64") } if len(b64) > maxBase64ToolBytes { return nil, SaveFileOutput{}, errInvalidInput( @@ -162,28 +170,44 @@ func (t *FilesTool) Save(ctx context.Context, req *mcp.CallToolRequest, in SaveF if uri != "" { if !strings.HasPrefix(uri, fileURIPrefix) { - return nil, SaveFileOutput{}, errInvalidInput("content_uri must be an amcs://files/{id} URI") + return nil, SaveFileOutput{}, errInvalidField( + "content_uri", + "content_uri must be an amcs://files/{id} URI", + "pass an amcs://files/{id} URI returned by upload_file or POST /files", + ) } rawID := strings.TrimPrefix(uri, fileURIPrefix) id, err := parseUUID(rawID) if err != nil { - return nil, SaveFileOutput{}, errInvalidInput("content_uri contains an invalid file id") + return nil, SaveFileOutput{}, errInvalidField( + "content_uri", + "content_uri contains an invalid file id", + "pass a valid amcs://files/{id} URI", + ) } file, err := t.store.GetStoredFile(ctx, id) if err != nil { - return nil, SaveFileOutput{}, errInvalidInput("content_uri references a file that does not exist") + return nil, SaveFileOutput{}, errInvalidField( + "content_uri", + "content_uri references a file that does not exist", + "upload the file first or pass an existing amcs://files/{id} URI", + ) } content = file.Content mediaTypeFromSource = file.MediaType } else { contentBase64, mediaTypeFromDataURL := splitDataURL(b64) if contentBase64 == "" { - return nil, SaveFileOutput{}, errInvalidInput("content_base64 or content_uri is required") + return nil, SaveFileOutput{}, errOneOfRequired("content_base64", "content_uri") } var err error content, err = decodeBase64(contentBase64) if err != nil { - return nil, SaveFileOutput{}, errInvalidInput("content_base64 must be valid base64") + return nil, SaveFileOutput{}, errInvalidField( + "content_base64", + "content_base64 must be valid base64", + "pass valid base64 data or a data URL", + ) } mediaTypeFromSource = mediaTypeFromDataURL } @@ -205,7 +229,7 @@ func (t *FilesTool) Save(ctx context.Context, req *mcp.CallToolRequest, in SaveF const fileURIPrefix = "amcs://files/" func (t *FilesTool) GetRaw(ctx context.Context, rawID string) (thoughttypes.StoredFile, error) { - id, err := parseUUID(strings.TrimSpace(rawID)) + id, err := parseStoredFileID(rawID) if err != nil { return thoughttypes.StoredFile{}, err } @@ -213,7 +237,7 @@ func (t *FilesTool) GetRaw(ctx context.Context, rawID string) (thoughttypes.Stor } func (t *FilesTool) Load(ctx context.Context, _ *mcp.CallToolRequest, in LoadFileInput) (*mcp.CallToolResult, LoadFileOutput, error) { - id, err := parseUUID(in.ID) + id, err := parseStoredFileID(in.ID) if err != nil { return nil, LoadFileOutput{}, err } @@ -243,8 +267,7 @@ func (t *FilesTool) Load(ctx context.Context, _ *mcp.CallToolRequest, in LoadFil } func (t *FilesTool) ReadResource(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { - rawID := strings.TrimPrefix(req.Params.URI, fileURIPrefix) - id, err := parseUUID(strings.TrimSpace(rawID)) + id, err := parseStoredFileID(req.Params.URI) if err != nil { return nil, mcp.ResourceNotFoundError(req.Params.URI) } @@ -309,7 +332,7 @@ func (t *FilesTool) List(ctx context.Context, req *mcp.CallToolRequest, in ListF func (t *FilesTool) SaveDecoded(ctx context.Context, req *mcp.CallToolRequest, in SaveFileDecodedInput) (SaveFileOutput, error) { name := strings.TrimSpace(in.Name) if name == "" { - return SaveFileOutput{}, errInvalidInput("name is required") + return SaveFileOutput{}, errRequiredField("name") } if len(in.Content) == 0 { return SaveFileOutput{}, errInvalidInput("decoded file content must not be empty") @@ -492,3 +515,9 @@ func normalizeFileLimit(limit int) int { return limit } } + +func parseStoredFileID(raw string) (uuid.UUID, error) { + value := strings.TrimSpace(raw) + value = strings.TrimPrefix(value, fileURIPrefix) + return parseUUID(value) +} diff --git a/internal/tools/files_test.go b/internal/tools/files_test.go index 556bb75..400a5b1 100644 --- a/internal/tools/files_test.go +++ b/internal/tools/files_test.go @@ -1,6 +1,10 @@ package tools -import "testing" +import ( + "testing" + + "github.com/google/uuid" +) func TestDecodeBase64AcceptsWhitespaceAndMultipleVariants(t *testing.T) { tests := []struct { @@ -27,3 +31,45 @@ func TestDecodeBase64AcceptsWhitespaceAndMultipleVariants(t *testing.T) { }) } } + +func TestParseStoredFileIDAcceptsUUIDAndURI(t *testing.T) { + id := uuid.New() + + tests := []struct { + name string + input string + want uuid.UUID + }{ + {name: "bare uuid", input: id.String(), want: id}, + {name: "resource uri", input: fileURIPrefix + id.String(), want: id}, + {name: "resource uri with surrounding whitespace", input: " " + fileURIPrefix + id.String() + " ", want: id}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := parseStoredFileID(tc.input) + if err != nil { + t.Fatalf("parseStoredFileID(%q) error = %v", tc.input, err) + } + if got != tc.want { + t.Fatalf("parseStoredFileID(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestParseStoredFileIDRejectsInvalidValues(t *testing.T) { + tests := []string{ + "", + "not-a-uuid", + fileURIPrefix + "not-a-uuid", + } + + for _, input := range tests { + t.Run(input, func(t *testing.T) { + if _, err := parseStoredFileID(input); err == nil { + t.Fatalf("parseStoredFileID(%q) = nil error, want error", input) + } + }) + } +} diff --git a/internal/tools/helpers.go b/internal/tools/helpers.go index 306147a..5fef029 100644 --- a/internal/tools/helpers.go +++ b/internal/tools/helpers.go @@ -9,22 +9,41 @@ import ( "github.com/jackc/pgx/v5" "github.com/modelcontextprotocol/go-sdk/mcp" + "git.warky.dev/wdevs/amcs/internal/mcperrors" "git.warky.dev/wdevs/amcs/internal/session" "git.warky.dev/wdevs/amcs/internal/store" thoughttypes "git.warky.dev/wdevs/amcs/internal/types" ) func parseUUID(id string) (uuid.UUID, error) { - parsed, err := uuid.Parse(strings.TrimSpace(id)) + trimmed := strings.TrimSpace(id) + parsed, err := uuid.Parse(trimmed) if err != nil { - return uuid.Nil, fmt.Errorf("invalid id %q: %w", id, err) + return uuid.Nil, newMCPError( + codeInvalidID, + fmt.Sprintf("invalid id %q", id), + mcpErrorData{ + Type: mcperrors.TypeInvalidID, + Field: "id", + Value: trimmed, + Detail: err.Error(), + Hint: "pass a valid UUID", + }, + ) } return parsed, nil } func sessionID(req *mcp.CallToolRequest) (string, error) { if req == nil || req.Session == nil || req.Session.ID() == "" { - return "", fmt.Errorf("tool requires an MCP session") + return "", newMCPError( + codeSessionRequired, + "tool requires an MCP session; use a stateful MCP client for session-scoped operations", + mcpErrorData{ + Type: mcperrors.TypeSessionRequired, + Hint: "use a stateful MCP client for session-scoped operations", + }, + ) } return req.Session.ID(), nil } @@ -45,7 +64,15 @@ func resolveProject(ctx context.Context, db *store.DB, sessions *session.ActiveP if projectRef == "" { if required { - return nil, fmt.Errorf("project is required") + return nil, newMCPError( + codeProjectRequired, + "project is required; pass project explicitly or call set_active_project in this MCP session first", + mcpErrorData{ + Type: mcperrors.TypeProjectRequired, + Field: "project", + Hint: "pass project explicitly or call set_active_project in this MCP session first", + }, + ) } return nil, nil } @@ -53,7 +80,15 @@ func resolveProject(ctx context.Context, db *store.DB, sessions *session.ActiveP project, err := db.GetProject(ctx, projectRef) if err != nil { if err == pgx.ErrNoRows { - return nil, fmt.Errorf("project %q not found", projectRef) + return nil, newMCPError( + codeProjectNotFound, + fmt.Sprintf("project %q not found", projectRef), + mcpErrorData{ + Type: mcperrors.TypeProjectNotFound, + Field: "project", + Project: projectRef, + }, + ) } return nil, err } diff --git a/internal/tools/helpers_test.go b/internal/tools/helpers_test.go new file mode 100644 index 0000000..f315e7f --- /dev/null +++ b/internal/tools/helpers_test.go @@ -0,0 +1,107 @@ +package tools + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "git.warky.dev/wdevs/amcs/internal/mcperrors" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" +) + +func TestResolveProjectRequiredErrorGuidesCaller(t *testing.T) { + _, err := resolveProject(context.Background(), nil, nil, nil, "", true) + if err == nil { + t.Fatal("resolveProject() error = nil, want error") + } + + rpcErr, data := requireRPCError(t, err) + if rpcErr.Code != codeProjectRequired { + t.Fatalf("resolveProject() code = %d, want %d", rpcErr.Code, codeProjectRequired) + } + if data.Type != mcperrors.TypeProjectRequired { + t.Fatalf("resolveProject() type = %q, want %q", data.Type, mcperrors.TypeProjectRequired) + } + if data.Field != "project" { + t.Fatalf("resolveProject() field = %q, want %q", data.Field, "project") + } + if data.Hint == "" { + t.Fatal("resolveProject() hint = empty, want guidance") + } +} + +func TestSessionIDErrorGuidesCaller(t *testing.T) { + _, err := sessionID(nil) + if err == nil { + t.Fatal("sessionID() error = nil, want error") + } + + rpcErr, data := requireRPCError(t, err) + if rpcErr.Code != codeSessionRequired { + t.Fatalf("sessionID() code = %d, want %d", rpcErr.Code, codeSessionRequired) + } + if data.Type != mcperrors.TypeSessionRequired { + t.Fatalf("sessionID() type = %q, want %q", data.Type, mcperrors.TypeSessionRequired) + } + if data.Hint == "" { + t.Fatal("sessionID() hint = empty, want guidance") + } +} + +func TestParseUUIDReturnsTypedError(t *testing.T) { + _, err := parseUUID("not-a-uuid") + if err == nil { + t.Fatal("parseUUID() error = nil, want error") + } + + rpcErr, data := requireRPCError(t, err) + if rpcErr.Code != codeInvalidID { + t.Fatalf("parseUUID() code = %d, want %d", rpcErr.Code, codeInvalidID) + } + if data.Type != mcperrors.TypeInvalidID { + t.Fatalf("parseUUID() type = %q, want %q", data.Type, mcperrors.TypeInvalidID) + } + if data.Field != "id" { + t.Fatalf("parseUUID() field = %q, want %q", data.Field, "id") + } + if data.Value != "not-a-uuid" { + t.Fatalf("parseUUID() value = %q, want %q", data.Value, "not-a-uuid") + } + if data.Detail == "" { + t.Fatal("parseUUID() detail = empty, want parse failure detail") + } +} + +func TestErrInvalidInputReturnsTypedError(t *testing.T) { + err := errInvalidInput("name is required") + if err == nil { + t.Fatal("errInvalidInput() error = nil, want error") + } + + rpcErr, data := requireRPCError(t, err) + if rpcErr.Code != jsonrpc.CodeInvalidParams { + t.Fatalf("errInvalidInput() code = %d, want %d", rpcErr.Code, jsonrpc.CodeInvalidParams) + } + if data.Type != mcperrors.TypeInvalidInput { + t.Fatalf("errInvalidInput() type = %q, want %q", data.Type, mcperrors.TypeInvalidInput) + } +} + +func requireRPCError(t *testing.T, err error) (*jsonrpc.Error, mcpErrorData) { + t.Helper() + + var rpcErr *jsonrpc.Error + if !errors.As(err, &rpcErr) { + t.Fatalf("error type = %T, want *jsonrpc.Error", err) + } + + var data mcpErrorData + if len(rpcErr.Data) > 0 { + if unmarshalErr := json.Unmarshal(rpcErr.Data, &data); unmarshalErr != nil { + t.Fatalf("unmarshal error data: %v", unmarshalErr) + } + } + + return rpcErr, data +} diff --git a/internal/tools/household.go b/internal/tools/household.go index 75652e3..ff90dfc 100644 --- a/internal/tools/household.go +++ b/internal/tools/household.go @@ -35,7 +35,7 @@ type AddHouseholdItemOutput struct { func (t *HouseholdTool) AddItem(ctx context.Context, _ *mcp.CallToolRequest, in AddHouseholdItemInput) (*mcp.CallToolResult, AddHouseholdItemOutput, error) { if strings.TrimSpace(in.Name) == "" { - return nil, AddHouseholdItemOutput{}, errInvalidInput("name is required") + return nil, AddHouseholdItemOutput{}, errRequiredField("name") } if in.Details == nil { in.Details = map[string]any{} @@ -112,7 +112,7 @@ type AddVendorOutput struct { func (t *HouseholdTool) AddVendor(ctx context.Context, _ *mcp.CallToolRequest, in AddVendorInput) (*mcp.CallToolResult, AddVendorOutput, error) { if strings.TrimSpace(in.Name) == "" { - return nil, AddVendorOutput{}, errInvalidInput("name is required") + return nil, AddVendorOutput{}, errRequiredField("name") } vendor, err := t.store.AddVendor(ctx, ext.HouseholdVendor{ Name: strings.TrimSpace(in.Name), diff --git a/internal/tools/links.go b/internal/tools/links.go index 2430016..1cde072 100644 --- a/internal/tools/links.go +++ b/internal/tools/links.go @@ -62,7 +62,7 @@ func (t *LinksTool) Link(ctx context.Context, _ *mcp.CallToolRequest, in LinkInp } relation := strings.TrimSpace(in.Relation) if relation == "" { - return nil, LinkOutput{}, errInvalidInput("relation is required") + return nil, LinkOutput{}, errRequiredField("relation") } if _, err := t.store.GetThought(ctx, fromID); err != nil { return nil, LinkOutput{}, err diff --git a/internal/tools/maintenance.go b/internal/tools/maintenance.go index dd25b53..301305a 100644 --- a/internal/tools/maintenance.go +++ b/internal/tools/maintenance.go @@ -37,7 +37,7 @@ type AddMaintenanceTaskOutput struct { func (t *MaintenanceTool) AddTask(ctx context.Context, _ *mcp.CallToolRequest, in AddMaintenanceTaskInput) (*mcp.CallToolResult, AddMaintenanceTaskOutput, error) { if strings.TrimSpace(in.Name) == "" { - return nil, AddMaintenanceTaskOutput{}, errInvalidInput("name is required") + return nil, AddMaintenanceTaskOutput{}, errRequiredField("name") } priority := strings.TrimSpace(in.Priority) if priority == "" { diff --git a/internal/tools/meals.go b/internal/tools/meals.go index 58584a0..5a07a9c 100644 --- a/internal/tools/meals.go +++ b/internal/tools/meals.go @@ -41,7 +41,7 @@ type AddRecipeOutput struct { func (t *MealsTool) AddRecipe(ctx context.Context, _ *mcp.CallToolRequest, in AddRecipeInput) (*mcp.CallToolResult, AddRecipeOutput, error) { if strings.TrimSpace(in.Name) == "" { - return nil, AddRecipeOutput{}, errInvalidInput("name is required") + return nil, AddRecipeOutput{}, errRequiredField("name") } if in.Ingredients == nil { in.Ingredients = []ext.Ingredient{} @@ -116,7 +116,7 @@ type UpdateRecipeOutput struct { func (t *MealsTool) UpdateRecipe(ctx context.Context, _ *mcp.CallToolRequest, in UpdateRecipeInput) (*mcp.CallToolResult, UpdateRecipeOutput, error) { if strings.TrimSpace(in.Name) == "" { - return nil, UpdateRecipeOutput{}, errInvalidInput("name is required") + return nil, UpdateRecipeOutput{}, errRequiredField("name") } if in.Ingredients == nil { in.Ingredients = []ext.Ingredient{} diff --git a/internal/tools/projects.go b/internal/tools/projects.go index de6f462..4d0998b 100644 --- a/internal/tools/projects.go +++ b/internal/tools/projects.go @@ -52,7 +52,7 @@ func NewProjectsTool(db *store.DB, sessions *session.ActiveProjects) *ProjectsTo func (t *ProjectsTool) Create(ctx context.Context, _ *mcp.CallToolRequest, in CreateProjectInput) (*mcp.CallToolResult, CreateProjectOutput, error) { name := strings.TrimSpace(in.Name) if name == "" { - return nil, CreateProjectOutput{}, errInvalidInput("name is required") + return nil, CreateProjectOutput{}, errRequiredField("name") } project, err := t.store.CreateProject(ctx, name, strings.TrimSpace(in.Description)) if err != nil { diff --git a/internal/tools/recall.go b/internal/tools/recall.go index eab6015..fa824b0 100644 --- a/internal/tools/recall.go +++ b/internal/tools/recall.go @@ -39,7 +39,7 @@ func NewRecallTool(db *store.DB, provider ai.Provider, search config.SearchConfi func (t *RecallTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in RecallInput) (*mcp.CallToolResult, RecallOutput, error) { query := strings.TrimSpace(in.Query) if query == "" { - return nil, RecallOutput{}, errInvalidInput("query is required") + return nil, RecallOutput{}, errRequiredField("query") } project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false) diff --git a/internal/tools/search.go b/internal/tools/search.go index ca6684d..db05ab3 100644 --- a/internal/tools/search.go +++ b/internal/tools/search.go @@ -39,7 +39,7 @@ func NewSearchTool(db *store.DB, provider ai.Provider, search config.SearchConfi func (t *SearchTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in SearchInput) (*mcp.CallToolResult, SearchOutput, error) { query := strings.TrimSpace(in.Query) if query == "" { - return nil, SearchOutput{}, errInvalidInput("query is required") + return nil, SearchOutput{}, errRequiredField("query") } limit := normalizeLimit(in.Limit, t.search) diff --git a/internal/tools/skills.go b/internal/tools/skills.go index 18fee06..7564bc3 100644 --- a/internal/tools/skills.go +++ b/internal/tools/skills.go @@ -36,10 +36,10 @@ type AddSkillOutput struct { func (t *SkillsTool) AddSkill(ctx context.Context, _ *mcp.CallToolRequest, in AddSkillInput) (*mcp.CallToolResult, AddSkillOutput, error) { if strings.TrimSpace(in.Name) == "" { - return nil, AddSkillOutput{}, errInvalidInput("name is required") + return nil, AddSkillOutput{}, errRequiredField("name") } if strings.TrimSpace(in.Content) == "" { - return nil, AddSkillOutput{}, errInvalidInput("content is required") + return nil, AddSkillOutput{}, errRequiredField("content") } if in.Tags == nil { in.Tags = []string{} @@ -110,10 +110,10 @@ type AddGuardrailOutput struct { func (t *SkillsTool) AddGuardrail(ctx context.Context, _ *mcp.CallToolRequest, in AddGuardrailInput) (*mcp.CallToolResult, AddGuardrailOutput, error) { if strings.TrimSpace(in.Name) == "" { - return nil, AddGuardrailOutput{}, errInvalidInput("name is required") + return nil, AddGuardrailOutput{}, errRequiredField("name") } if strings.TrimSpace(in.Content) == "" { - return nil, AddGuardrailOutput{}, errInvalidInput("content is required") + return nil, AddGuardrailOutput{}, errRequiredField("content") } severity := strings.TrimSpace(in.Severity) if severity == "" { @@ -122,7 +122,11 @@ func (t *SkillsTool) AddGuardrail(ctx context.Context, _ *mcp.CallToolRequest, i switch severity { case "low", "medium", "high", "critical": default: - return nil, AddGuardrailOutput{}, errInvalidInput("severity must be one of: low, medium, high, critical") + return nil, AddGuardrailOutput{}, errInvalidField( + "severity", + "severity must be one of: low, medium, high, critical", + "pass one of: low, medium, high, critical", + ) } if in.Tags == nil { in.Tags = []string{} @@ -231,7 +235,7 @@ type ListProjectSkillsInput struct { } type ListProjectSkillsOutput struct { - ProjectID uuid.UUID `json:"project_id"` + ProjectID uuid.UUID `json:"project_id"` Skills []ext.AgentSkill `json:"skills"` } @@ -302,7 +306,7 @@ type ListProjectGuardrailsInput struct { } type ListProjectGuardrailsOutput struct { - ProjectID uuid.UUID `json:"project_id"` + ProjectID uuid.UUID `json:"project_id"` Guardrails []ext.AgentGuardrail `json:"guardrails"` } diff --git a/internal/tools/version.go b/internal/tools/version.go new file mode 100644 index 0000000..19787a1 --- /dev/null +++ b/internal/tools/version.go @@ -0,0 +1,45 @@ +package tools + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "git.warky.dev/wdevs/amcs/internal/buildinfo" +) + +type VersionTool struct { + serverName string + info buildinfo.Info +} + +type GetVersionInfoInput struct{} + +type GetVersionInfoOutput struct { + ServerName string `json:"server_name"` + Version string `json:"version"` + TagName string `json:"tag_name"` + Commit string `json:"commit"` + BuildDate string `json:"build_date"` +} + +func NewVersionTool(serverName string, info buildinfo.Info) *VersionTool { + return &VersionTool{ + serverName: serverName, + info: info, + } +} + +func (t *VersionTool) GetInfo(_ context.Context, _ *mcp.CallToolRequest, _ GetVersionInfoInput) (*mcp.CallToolResult, GetVersionInfoOutput, error) { + if t == nil { + return nil, GetVersionInfoOutput{}, nil + } + + return nil, GetVersionInfoOutput{ + ServerName: t.serverName, + Version: t.info.Version, + TagName: t.info.TagName, + Commit: t.info.Commit, + BuildDate: t.info.BuildDate, + }, nil +} diff --git a/internal/tools/version_test.go b/internal/tools/version_test.go new file mode 100644 index 0000000..1848b21 --- /dev/null +++ b/internal/tools/version_test.go @@ -0,0 +1,38 @@ +package tools + +import ( + "context" + "testing" + + "git.warky.dev/wdevs/amcs/internal/buildinfo" +) + +func TestVersionToolReturnsBuildInformation(t *testing.T) { + tool := NewVersionTool("amcs", buildinfo.Info{ + Version: "v1.2.3", + TagName: "v1.2.3", + Commit: "abc1234", + BuildDate: "2026-03-31T12:34:56Z", + }) + + _, out, err := tool.GetInfo(context.Background(), nil, GetVersionInfoInput{}) + if err != nil { + t.Fatalf("GetInfo() error = %v", err) + } + + if out.ServerName != "amcs" { + t.Fatalf("server_name = %q, want %q", out.ServerName, "amcs") + } + if out.Version != "v1.2.3" { + t.Fatalf("version = %q, want %q", out.Version, "v1.2.3") + } + if out.TagName != "v1.2.3" { + t.Fatalf("tag_name = %q, want %q", out.TagName, "v1.2.3") + } + if out.Commit != "abc1234" { + t.Fatalf("commit = %q, want %q", out.Commit, "abc1234") + } + if out.BuildDate != "2026-03-31T12:34:56Z" { + t.Fatalf("build_date = %q, want %q", out.BuildDate, "2026-03-31T12:34:56Z") + } +} diff --git a/internal/types/extensions.go b/internal/types/extensions.go index 7c967cd..ecfcd9b 100644 --- a/internal/types/extensions.go +++ b/internal/types/extensions.go @@ -48,13 +48,13 @@ type MaintenanceTask struct { } type MaintenanceLog struct { - ID uuid.UUID `json:"id"` - TaskID uuid.UUID `json:"task_id"` - CompletedAt time.Time `json:"completed_at"` - PerformedBy string `json:"performed_by,omitempty"` - Cost *float64 `json:"cost,omitempty"` - Notes string `json:"notes,omitempty"` - NextAction string `json:"next_action,omitempty"` + ID uuid.UUID `json:"id"` + TaskID uuid.UUID `json:"task_id"` + CompletedAt time.Time `json:"completed_at"` + PerformedBy string `json:"performed_by,omitempty"` + Cost *float64 `json:"cost,omitempty"` + Notes string `json:"notes,omitempty"` + NextAction string `json:"next_action,omitempty"` } type MaintenanceLogWithTask struct { diff --git a/llm/memory.md b/llm/memory.md index 47ffb78..f37682d 100644 --- a/llm/memory.md +++ b/llm/memory.md @@ -69,4 +69,4 @@ At the start of every project session, after setting the active project: ## Short Operational Form -Use AMCS memory in project scope when the current work matches a known project. If no clear project matches, global notebook memory is allowed for non-project-specific information. At the start of every project session call `list_project_skills` and `list_project_guardrails` and apply what is returned; only create new skills or guardrails if none exist. Store durable notes with `capture_thought`. For binary files or files larger than 10 MB, call `upload_file` with `content_path` to stage the file and get an `amcs://files/{id}` URI, then pass that URI to `save_file` as `content_uri` to link it to a thought. For small files, use `save_file` or `upload_file` with `content_base64` directly. Browse stored files with `list_files`, and load them with `load_file` only when their contents are needed. Stored files can also be read as raw binary via MCP resources at `amcs://files/{id}`. Never store project-specific memory globally when a matching project exists, and never store memory in the wrong project. If project matching is ambiguous, ask the user. +Use AMCS memory in project scope when the current work matches a known project. If no clear project matches, global notebook memory is allowed for non-project-specific information. At the start of every project session call `list_project_skills` and `list_project_guardrails` and apply what is returned; only create new skills or guardrails if none exist. If your MCP client does not preserve sessions across calls, pass `project` explicitly instead of relying on `set_active_project`. Store durable notes with `capture_thought`. For binary files or files larger than 10 MB, call `upload_file` with `content_path` to stage the file and get an `amcs://files/{id}` URI, then pass that URI to `save_file` as `content_uri` to link it to a thought. For small files, use `save_file` or `upload_file` with `content_base64` directly. Browse stored files with `list_files`, and load them with `load_file` only when their contents are needed. Stored files can also be read as raw binary via MCP resources at `amcs://files/{id}`. Never store project-specific memory globally when a matching project exists, and never store memory in the wrong project. If project matching is ambiguous, ask the user.