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 <topic> (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).
This commit is contained in:
@@ -1,11 +1,39 @@
|
|||||||
// Package main is the entry point for the last30days MCP server bundled
|
// 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
|
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=<tag>".
|
// Version is stamped at build time via -ldflags "-X main.Version=<tag>".
|
||||||
// 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"
|
var Version = "dev"
|
||||||
|
|
||||||
|
const (
|
||||||
|
serverName = "last30days"
|
||||||
|
serverVersion = "1"
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -1,3 +1,14 @@
|
|||||||
module github.com/mvanhorn/last30days-skill/mcp
|
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
|
||||||
|
)
|
||||||
|
|||||||
+34
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 "<NAME>=<VALUE>"
|
||||||
|
// STUB_ECHO_ARG - integer index; the stub prints "ARG<i>=<args[i]>"
|
||||||
|
//
|
||||||
|
// 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:-<unset>}"; fi
|
||||||
|
if [ -n "${STUB_ECHO_ARG:-}" ]; then echo "ARG${STUB_ECHO_ARG}=${!STUB_ECHO_ARG:-<unset>}"; 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Package tools owns the MCP tool surface for last30days. Today there is
|
||||||
|
// exactly one tool, research, mirroring the /last30days <topic> 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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user