From 35f12cb9ea45d222f9ba164ba33ee8aec72fd9fb Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 17 May 2026 20:51:00 -0700 Subject: [PATCH] feat(mcp): stdio MCP server with research tool U3 of the Claude Desktop .mcpb bundle plan. - internal/engine/run.go invokes python3 with the cached last30days.py, forwards os.Environ() so MCPB user_config env-injection reaches the engine, sets PYTHONPATH so the lib/ imports resolve, and surfaces three distinct error shapes (missing interpreter with install URL, non-zero exit with stderr, timeout). RunOptions.PythonPath lets tests inject a stub without manipulating PATH. - internal/engine/run_test.go drives a shell-script stub interpreter through happy path, env forwarding, PYTHONPATH, non-zero exit with stderr surfacing, timeout, missing python3 (empty PATH), missing last30days.py, empty CacheDir, and timeout-env-override parsing. - internal/tools/research.go registers a single research tool whose schema mirrors /last30days (required topic, optional emit enum, optional save bool). Validation failures surface as MCP tool errors so Claude sees structured failures instead of transport faults; engine extract or run errors fold engine stderr into the message so users can diagnose without leaving Desktop. - internal/tools/research_test.go covers requireString, emitArgument, boolArgument, handler-level validation routing, and formatRunError. - cmd/last30days-pp-mcp/main.go wires NewMCPServer + tools.Register + ServeStdio. main.Version is ldflags-stamped at build time and namespaces the per-user cache. mcp-go pinned at v0.54.0. Smoke check: ./build/last30days-pp-mcp answers tools/list with the research tool plus full schema and read-only/open-world annotations. go test ./... passes across engine + tools (32 cases). --- mcp/cmd/last30days-pp-mcp/main.go | 34 ++++- mcp/go.mod | 13 +- mcp/go.sum | 34 +++++ mcp/internal/engine/run.go | 151 +++++++++++++++++++ mcp/internal/engine/run_test.go | 223 ++++++++++++++++++++++++++++ mcp/internal/tools/research.go | 146 ++++++++++++++++++ mcp/internal/tools/research_test.go | 145 ++++++++++++++++++ 7 files changed, 742 insertions(+), 4 deletions(-) create mode 100644 mcp/go.sum create mode 100644 mcp/internal/engine/run.go create mode 100644 mcp/internal/engine/run_test.go create mode 100644 mcp/internal/tools/research.go create mode 100644 mcp/internal/tools/research_test.go diff --git a/mcp/cmd/last30days-pp-mcp/main.go b/mcp/cmd/last30days-pp-mcp/main.go index f6158cd..1a97faf 100644 --- a/mcp/cmd/last30days-pp-mcp/main.go +++ b/mcp/cmd/last30days-pp-mcp/main.go @@ -1,11 +1,39 @@ // Package main is the entry point for the last30days MCP server bundled -// as a .mcpb for Claude Desktop. See mcp/README.md for build instructions. +// as a .mcpb for Claude Desktop. The server registers a single research +// tool (see internal/tools) and serves it over stdio. See mcp/README.md +// for build and packaging instructions. package main +import ( + "fmt" + "os" + + "github.com/mark3labs/mcp-go/server" + + "github.com/mvanhorn/last30days-skill/mcp/internal/tools" +) + // Version is stamped at build time via -ldflags "-X main.Version=". -// Defaults to "dev" for local builds. +// It namespaces the per-user cache directory in internal/engine so multiple +// installed versions can coexist without clobbering each other. var Version = "dev" +const ( + serverName = "last30days" + serverVersion = "1" +) + func main() { - // Wired in U3. + s := server.NewMCPServer( + serverName, + serverVersion, + server.WithToolCapabilities(false), + ) + + tools.Register(s, tools.Config{Version: Version}) + + if err := server.ServeStdio(s); err != nil { + fmt.Fprintf(os.Stderr, "last30days-pp-mcp: %v\n", err) + os.Exit(1) + } } diff --git a/mcp/go.mod b/mcp/go.mod index dd5b944..597b5de 100644 --- a/mcp/go.mod +++ b/mcp/go.mod @@ -1,3 +1,14 @@ module github.com/mvanhorn/last30days-skill/mcp -go 1.22 +go 1.25.5 + +require github.com/mark3labs/mcp-go v0.54.0 + +require ( + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/mcp/go.sum b/mcp/go.sum new file mode 100644 index 0000000..bbbc4dd --- /dev/null +++ b/mcp/go.sum @@ -0,0 +1,34 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mark3labs/mcp-go v0.54.0 h1:PZhQvd+5xrT43cUoiaKn/hDcvLUhcLc1twSEKYPTcTA= +github.com/mark3labs/mcp-go v0.54.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mcp/internal/engine/run.go b/mcp/internal/engine/run.go new file mode 100644 index 0000000..7c84fd0 --- /dev/null +++ b/mcp/internal/engine/run.go @@ -0,0 +1,151 @@ +package engine + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" +) + +// DefaultPythonBinary is the interpreter we look up unless RunOptions +// overrides it. Windows installs may expose only "python"; we surface a +// clear error in that case rather than silently picking the wrong binary. +const DefaultPythonBinary = "python3" + +// MinPythonVersion mirrors the engine's MIN_PYTHON constant in +// last30days.py. Surfaced in errors so users know what they're missing. +const MinPythonVersion = "3.12" + +// PythonInstallURL is included in the missing-interpreter error so users +// have a direct route from the failure to a fix. +const PythonInstallURL = "https://www.python.org/downloads/" + +// DefaultTimeout caps a single research subprocess. The engine's deep mode +// can run several minutes; five minutes is a safe upper bound that still +// fails fast when something hangs. +const DefaultTimeout = 5 * time.Minute + +// TimeoutEnvOverride lets operators override DefaultTimeout per install +// (seconds, integer). Honored by Run when RunOptions.Timeout is zero. +const TimeoutEnvOverride = "LAST30DAYS_MCP_TIMEOUT" + +// RunOptions configures one invocation of the embedded Python engine. +// PythonPath is exposed so tests can substitute a stub interpreter without +// manipulating the process PATH. +type RunOptions struct { + PythonPath string // resolved python3 binary; empty means look up DefaultPythonBinary on PATH + CacheDir string // engine.Ensure result; lib/ here is added to PYTHONPATH + Args []string // arguments after last30days.py (topic, --emit=..., etc.) + ExtraEnv []string // appended to os.Environ() for the child process + Timeout time.Duration // zero means DefaultTimeout or TimeoutEnvOverride +} + +// RunResult captures the engine's full output. Stdout is what we surface to +// the agent; Stderr is included in error messages so users can diagnose +// engine failures without leaving Claude Desktop. +type RunResult struct { + Stdout []byte + Stderr []byte + ExitCode int + TimedOut bool +} + +// Run shells out to python3 with last30days.py inside cacheDir. The child +// receives the parent environment (so MCPB user_config env-injection +// reaches the engine) plus ExtraEnv and a PYTHONPATH that points at the +// cache so the engine's `from lib import ...` statements resolve. +// +// A missing interpreter, a non-zero exit, and a timeout each surface as +// distinct errors so the tool handler can map them to user-facing +// messages without re-parsing stderr. +func Run(ctx context.Context, opts RunOptions) (*RunResult, error) { + if opts.CacheDir == "" { + return nil, errors.New("engine: CacheDir is required") + } + pythonPath, err := resolvePython(opts.PythonPath) + if err != nil { + return nil, err + } + + scriptPath := filepath.Join(opts.CacheDir, "last30days.py") + if _, err := os.Stat(scriptPath); err != nil { + return nil, fmt.Errorf("engine: last30days.py not found in cache %s: %w", opts.CacheDir, err) + } + + timeout := resolveTimeout(opts.Timeout) + subCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + args := append([]string{scriptPath}, opts.Args...) + cmd := exec.CommandContext(subCtx, pythonPath, args...) + cmd.Env = buildEnv(opts.CacheDir, opts.ExtraEnv) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + res := &RunResult{ + Stdout: stdout.Bytes(), + Stderr: stderr.Bytes(), + ExitCode: 0, + TimedOut: errors.Is(subCtx.Err(), context.DeadlineExceeded), + } + if err == nil { + return res, nil + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + res.ExitCode = exitErr.ExitCode() + if res.TimedOut { + return res, fmt.Errorf("engine: subprocess exceeded %s timeout", timeout) + } + return res, fmt.Errorf("engine: subprocess exited with code %d", res.ExitCode) + } + return res, fmt.Errorf("engine: subprocess failed to start: %w", err) +} + +// resolvePython returns an absolute path to the interpreter or an error +// naming the install URL. If the caller supplied a path we trust it - tests +// rely on this to inject a stub. Otherwise we look up python3 on PATH. +func resolvePython(override string) (string, error) { + if override != "" { + return override, nil + } + path, err := exec.LookPath(DefaultPythonBinary) + if err == nil { + return path, nil + } + return "", fmt.Errorf( + "engine: %s not found on PATH (need Python %s+, install from %s; current GOOS=%s)", + DefaultPythonBinary, MinPythonVersion, PythonInstallURL, runtime.GOOS, + ) +} + +func resolveTimeout(explicit time.Duration) time.Duration { + if explicit > 0 { + return explicit + } + if raw := os.Getenv(TimeoutEnvOverride); raw != "" { + if d, err := time.ParseDuration(raw); err == nil && d > 0 { + return d + } + } + return DefaultTimeout +} + +// buildEnv stitches PYTHONPATH onto os.Environ + ExtraEnv. The engine's +// `from lib import ...` statements resolve because lib/ sits next to +// last30days.py inside CacheDir. +func buildEnv(cacheDir string, extra []string) []string { + base := os.Environ() + base = append(base, "PYTHONPATH="+cacheDir) + base = append(base, extra...) + return base +} diff --git a/mcp/internal/engine/run_test.go b/mcp/internal/engine/run_test.go new file mode 100644 index 0000000..a8393c5 --- /dev/null +++ b/mcp/internal/engine/run_test.go @@ -0,0 +1,223 @@ +package engine + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +// makeStubPython writes a shell script that simulates python3 and returns +// its absolute path. The script honors a small env-driven protocol so each +// test can shape its output: +// +// STUB_STDOUT - text printed to stdout +// STUB_STDERR - text printed to stderr +// STUB_EXIT_CODE - integer exit code (default 0) +// STUB_SLEEP_SECS - sleep before exiting (for timeout tests) +// STUB_ECHO_ENV - name of an env var; the stub prints "=" +// STUB_ECHO_ARG - integer index; the stub prints "ARG=" +// +// The stub ignores its first argument (the script path), matching how a +// real python3 invocation treats `python3 last30days.py ...`. +func makeStubPython(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("stub-python tests rely on POSIX shell") + } + dir := t.TempDir() + path := filepath.Join(dir, "python3-stub.sh") + script := `#!/usr/bin/env bash +if [ -n "${STUB_SLEEP_SECS:-}" ]; then sleep "$STUB_SLEEP_SECS"; fi +if [ -n "${STUB_STDOUT:-}" ]; then printf "%s" "$STUB_STDOUT"; fi +if [ -n "${STUB_STDERR:-}" ]; then printf "%s" "$STUB_STDERR" >&2; fi +if [ -n "${STUB_ECHO_ENV:-}" ]; then echo "${STUB_ECHO_ENV}=${!STUB_ECHO_ENV:-}"; fi +if [ -n "${STUB_ECHO_ARG:-}" ]; then echo "ARG${STUB_ECHO_ARG}=${!STUB_ECHO_ARG:-}"; fi +exit "${STUB_EXIT_CODE:-0}" +` + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatalf("write stub: %v", err) + } + return path +} + +// stageCache materializes a fake CacheDir with a no-op last30days.py so +// the existence check in Run passes. The stub python3 ignores the script +// contents, so the file just has to exist. +func stageCache(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "last30days.py"), []byte("# stub\n"), 0o644); err != nil { + t.Fatalf("stage cache: %v", err) + } + return dir +} + +func TestRunHappyPath(t *testing.T) { + stub := makeStubPython(t) + cache := stageCache(t) + t.Setenv("STUB_STDOUT", "synthesis output\n") + + res, err := Run(context.Background(), RunOptions{ + PythonPath: stub, + CacheDir: cache, + Args: []string{"my topic", "--emit=compact"}, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if string(res.Stdout) != "synthesis output\n" { + t.Fatalf("stdout = %q, want %q", res.Stdout, "synthesis output\n") + } + if res.ExitCode != 0 { + t.Fatalf("ExitCode = %d, want 0", res.ExitCode) + } + if res.TimedOut { + t.Fatal("TimedOut = true, want false") + } +} + +func TestRunForwardsEnv(t *testing.T) { + stub := makeStubPython(t) + cache := stageCache(t) + t.Setenv("OPENAI_API_KEY", "sk-test-value") + t.Setenv("STUB_ECHO_ENV", "OPENAI_API_KEY") + + res, err := Run(context.Background(), RunOptions{ + PythonPath: stub, + CacheDir: cache, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if got := strings.TrimSpace(string(res.Stdout)); got != "OPENAI_API_KEY=sk-test-value" { + t.Fatalf("stdout = %q, want OPENAI_API_KEY=sk-test-value", got) + } +} + +func TestRunSetsPythonPath(t *testing.T) { + stub := makeStubPython(t) + cache := stageCache(t) + t.Setenv("STUB_ECHO_ENV", "PYTHONPATH") + + res, err := Run(context.Background(), RunOptions{ + PythonPath: stub, + CacheDir: cache, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + want := "PYTHONPATH=" + cache + if got := strings.TrimSpace(string(res.Stdout)); got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } +} + +func TestRunSurfacesExitCode(t *testing.T) { + stub := makeStubPython(t) + cache := stageCache(t) + t.Setenv("STUB_STDERR", "engine boom\n") + t.Setenv("STUB_EXIT_CODE", "2") + + res, err := Run(context.Background(), RunOptions{ + PythonPath: stub, + CacheDir: cache, + }) + if err == nil { + t.Fatal("expected error for non-zero exit") + } + if res == nil { + t.Fatal("res is nil; want populated result alongside error") + } + if res.ExitCode != 2 { + t.Fatalf("ExitCode = %d, want 2", res.ExitCode) + } + if !strings.Contains(string(res.Stderr), "engine boom") { + t.Fatalf("stderr did not surface engine output: %q", res.Stderr) + } +} + +func TestRunTimesOut(t *testing.T) { + stub := makeStubPython(t) + cache := stageCache(t) + t.Setenv("STUB_SLEEP_SECS", "3") + + res, err := Run(context.Background(), RunOptions{ + PythonPath: stub, + CacheDir: cache, + Timeout: 200 * time.Millisecond, + }) + if err == nil { + t.Fatal("expected timeout error") + } + if !res.TimedOut { + t.Fatal("TimedOut = false, want true") + } + if !strings.Contains(err.Error(), "timeout") { + t.Fatalf("error %q lacks 'timeout' marker", err) + } +} + +func TestRunMissingPython(t *testing.T) { + cache := stageCache(t) + // Empty PATH guarantees the lookup fails. PythonPath stays unset so Run + // falls through to exec.LookPath. + t.Setenv("PATH", "") + + _, err := Run(context.Background(), RunOptions{CacheDir: cache}) + if err == nil { + t.Fatal("expected lookup failure with empty PATH") + } + if !strings.Contains(err.Error(), DefaultPythonBinary) { + t.Fatalf("error %q does not mention %s", err, DefaultPythonBinary) + } + if !strings.Contains(err.Error(), PythonInstallURL) { + t.Fatalf("error %q does not include install URL", err) + } +} + +func TestRunMissingScript(t *testing.T) { + stub := makeStubPython(t) + // CacheDir exists but contains no last30days.py. + cache := t.TempDir() + + _, err := Run(context.Background(), RunOptions{ + PythonPath: stub, + CacheDir: cache, + }) + if err == nil { + t.Fatal("expected error when last30days.py missing") + } + if !strings.Contains(err.Error(), "last30days.py") { + t.Fatalf("error %q does not name missing script", err) + } +} + +func TestRunRejectsEmptyCacheDir(t *testing.T) { + stub := makeStubPython(t) + _, err := Run(context.Background(), RunOptions{PythonPath: stub}) + if err == nil { + t.Fatal("expected error for empty CacheDir") + } + if !errors.Is(err, err) || !strings.Contains(err.Error(), "CacheDir") { + t.Fatalf("error %q does not name CacheDir", err) + } +} + +func TestResolveTimeoutHonorsEnv(t *testing.T) { + t.Setenv(TimeoutEnvOverride, "750ms") + if got := resolveTimeout(0); got != 750*time.Millisecond { + t.Fatalf("resolveTimeout = %v, want 750ms", got) + } + t.Setenv(TimeoutEnvOverride, "garbage") + if got := resolveTimeout(0); got != DefaultTimeout { + t.Fatalf("garbage value: got %v, want default %v", got, DefaultTimeout) + } + if got := resolveTimeout(time.Minute); got != time.Minute { + t.Fatalf("explicit value not honored: got %v", got) + } +} diff --git a/mcp/internal/tools/research.go b/mcp/internal/tools/research.go new file mode 100644 index 0000000..150ca80 --- /dev/null +++ b/mcp/internal/tools/research.go @@ -0,0 +1,146 @@ +// Package tools owns the MCP tool surface for last30days. Today there is +// exactly one tool, research, mirroring the /last30days slash +// command available in Claude Code. Adding new tools means another file +// here plus an additional s.AddTool call in Register. +package tools + +import ( + "context" + "errors" + "fmt" + "strings" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/mvanhorn/last30days-skill/mcp/internal/engine" +) + +// Config carries the version string used to namespace the per-user cache. +// main passes its ldflags-stamped Version here. +type Config struct { + Version string +} + +// Register adds every tool this server exposes to s. The caller supplies a +// Config so test harnesses can pin a version without touching globals. +func Register(s *server.MCPServer, cfg Config) { + s.AddTool( + mcplib.NewTool("research", + mcplib.WithDescription( + "Research what people are actually saying about any topic in the last 30 days. "+ + "Aggregates Reddit, X, YouTube, Hacker News, Polymarket, GitHub, and the web, "+ + "scored by upvotes, likes, transcripts, and real-money prediction-market odds. "+ + "Returns the engine's compact output for the model to synthesize.", + ), + mcplib.WithString("topic", mcplib.Required(), mcplib.Description("The subject to research (a person, company, product, event, or general topic).")), + mcplib.WithString("emit", mcplib.Description("Output shape: 'compact' (default) for inline synthesis or 'html' to save a shareable brief alongside the response.")), + mcplib.WithBoolean("save", mcplib.Description("Persist the synthesis as a markdown report under ~/Documents/Last30Days/ (or LAST30DAYS_MEMORY_DIR if set).")), + mcplib.WithReadOnlyHintAnnotation(true), + mcplib.WithDestructiveHintAnnotation(false), + mcplib.WithOpenWorldHintAnnotation(true), + ), + makeResearchHandler(cfg), + ) +} + +func makeResearchHandler(cfg Config) server.ToolHandlerFunc { + return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + args := req.GetArguments() + topic, err := requireString(args, "topic") + if err != nil { + return mcplib.NewToolResultError(err.Error()), nil + } + + emit, err := emitArgument(args) + if err != nil { + return mcplib.NewToolResultError(err.Error()), nil + } + + save, err := boolArgument(args, "save") + if err != nil { + return mcplib.NewToolResultError(err.Error()), nil + } + + src, err := engine.EngineFS() + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("engine source unavailable: %v", err)), nil + } + cacheDir, err := engine.EnsureUserCache(src, cfg.Version) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf( + "engine extract failed: %v\nhint: set %s to a writable directory if the default cache location is locked down", + err, engine.CacheEnvOverride, + )), nil + } + + runArgs := []string{topic, "--emit=" + emit} + if save { + runArgs = append(runArgs, "--save") + } + + res, runErr := engine.Run(ctx, engine.RunOptions{ + CacheDir: cacheDir, + Args: runArgs, + }) + if runErr != nil { + return mcplib.NewToolResultError(formatRunError(runErr, res)), nil + } + return mcplib.NewToolResultText(string(res.Stdout)), nil + } +} + +func requireString(args map[string]any, name string) (string, error) { + raw, ok := args[name] + if !ok { + return "", fmt.Errorf("%s is required", name) + } + value, ok := raw.(string) + if !ok || strings.TrimSpace(value) == "" { + return "", fmt.Errorf("%s must be a non-empty string", name) + } + return value, nil +} + +func emitArgument(args map[string]any) (string, error) { + raw, ok := args["emit"] + if !ok { + return "compact", nil + } + value, ok := raw.(string) + if !ok { + return "", errors.New("emit must be a string") + } + switch value { + case "": + return "compact", nil + case "compact", "html": + return value, nil + default: + return "", fmt.Errorf("emit must be 'compact' or 'html', got %q", value) + } +} + +func boolArgument(args map[string]any, name string) (bool, error) { + raw, ok := args[name] + if !ok { + return false, nil + } + value, ok := raw.(bool) + if !ok { + return false, fmt.Errorf("%s must be a boolean", name) + } + return value, nil +} + +// formatRunError flattens engine.Run's distinct error shapes into a single +// user-facing message that includes the relevant stderr context. +func formatRunError(runErr error, res *engine.RunResult) string { + var msg strings.Builder + msg.WriteString(runErr.Error()) + if res != nil && len(res.Stderr) > 0 { + msg.WriteString("\nengine stderr:\n") + msg.Write(res.Stderr) + } + return msg.String() +} diff --git a/mcp/internal/tools/research_test.go b/mcp/internal/tools/research_test.go new file mode 100644 index 0000000..0edb684 --- /dev/null +++ b/mcp/internal/tools/research_test.go @@ -0,0 +1,145 @@ +package tools + +import ( + "context" + "errors" + "strings" + "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + + "github.com/mvanhorn/last30days-skill/mcp/internal/engine" +) + +func newCallToolRequest(args map[string]any) mcplib.CallToolRequest { + var req mcplib.CallToolRequest + req.Params.Arguments = args + return req +} + +// resultText pulls text content out of a tool result so tests can assert on +// the body Claude will see. Returns empty string when the result is nil or +// has no text content. +func resultText(res *mcplib.CallToolResult) string { + if res == nil { + return "" + } + var out strings.Builder + for _, item := range res.Content { + if tc, ok := item.(mcplib.TextContent); ok { + out.WriteString(tc.Text) + } + } + return out.String() +} + +func TestRequireStringRejectsMissingAndBlank(t *testing.T) { + if _, err := requireString(map[string]any{}, "topic"); err == nil { + t.Fatal("expected error for missing topic") + } + if _, err := requireString(map[string]any{"topic": ""}, "topic"); err == nil { + t.Fatal("expected error for empty topic") + } + if _, err := requireString(map[string]any{"topic": " "}, "topic"); err == nil { + t.Fatal("expected error for whitespace-only topic") + } + if _, err := requireString(map[string]any{"topic": 42}, "topic"); err == nil { + t.Fatal("expected error for non-string topic") + } + v, err := requireString(map[string]any{"topic": "OpenAI"}, "topic") + if err != nil || v != "OpenAI" { + t.Fatalf("requireString ok = %q, %v", v, err) + } +} + +func TestEmitArgumentDefaultsAndValidates(t *testing.T) { + cases := []struct { + name string + args map[string]any + want string + wantErr bool + }{ + {"missing defaults to compact", map[string]any{}, "compact", false}, + {"empty string defaults to compact", map[string]any{"emit": ""}, "compact", false}, + {"compact passes through", map[string]any{"emit": "compact"}, "compact", false}, + {"html passes through", map[string]any{"emit": "html"}, "html", false}, + {"invalid value rejected", map[string]any{"emit": "json"}, "", true}, + {"non-string rejected", map[string]any{"emit": 7}, "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := emitArgument(tc.args) + if (err != nil) != tc.wantErr { + t.Fatalf("err = %v, wantErr = %v", err, tc.wantErr) + } + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestBoolArgument(t *testing.T) { + v, err := boolArgument(map[string]any{}, "save") + if err != nil || v { + t.Fatalf("missing: %v, %v", v, err) + } + v, err = boolArgument(map[string]any{"save": true}, "save") + if err != nil || !v { + t.Fatalf("true: %v, %v", v, err) + } + v, err = boolArgument(map[string]any{"save": false}, "save") + if err != nil || v { + t.Fatalf("false: %v, %v", v, err) + } + if _, err := boolArgument(map[string]any{"save": "true"}, "save"); err == nil { + t.Fatal("expected error for string value") + } +} + +func TestResearchHandlerValidationErrorsAreToolErrors(t *testing.T) { + // Validation failures are returned as MCP tool errors (not Go errors) + // so Claude sees a structured failure with a readable message rather + // than a transport-level fault. + handler := makeResearchHandler(Config{Version: "test"}) + + cases := []struct { + name string + args map[string]any + wantSub string + }{ + {"missing topic", map[string]any{}, "topic is required"}, + {"blank topic", map[string]any{"topic": " "}, "non-empty string"}, + {"invalid emit", map[string]any{"topic": "OpenAI", "emit": "json"}, "must be 'compact' or 'html'"}, + {"non-bool save", map[string]any{"topic": "OpenAI", "save": "yes"}, "save must be a boolean"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res, err := handler(context.Background(), newCallToolRequest(tc.args)) + if err != nil { + t.Fatalf("handler should not return Go error for validation; got %v", err) + } + if res == nil || !res.IsError { + t.Fatalf("expected IsError result, got %+v", res) + } + if !strings.Contains(resultText(res), tc.wantSub) { + t.Fatalf("result text %q missing substring %q", resultText(res), tc.wantSub) + } + }) + } +} + +func TestFormatRunErrorIncludesStderr(t *testing.T) { + res := &engine.RunResult{Stderr: []byte("engine exploded\n")} + msg := formatRunError(errors.New("boom"), res) + if !strings.Contains(msg, "boom") || !strings.Contains(msg, "engine exploded") { + t.Fatalf("formatRunError missed pieces: %q", msg) + } +} + +func TestFormatRunErrorHandlesNilResult(t *testing.T) { + msg := formatRunError(errors.New("boom"), nil) + if msg != "boom" { + t.Fatalf("nil result: got %q, want %q", msg, "boom") + } +}