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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user