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,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