返回 last30days-skill
research.go
根目录 / mcp / internal / tools / research.go
1 // Package tools owns the MCP tool surface for last30days.
2 package tools
3
4 import (
5 "context"
6 "errors"
7 "fmt"
8 "os"
9 "strings"
10
11 mcplib "github.com/mark3labs/mcp-go/mcp"
12 "github.com/mark3labs/mcp-go/server"
13
14 "github.com/mvanhorn/last30days-skill/mcp/internal/engine"
15 )
16
17 // Config carries the version string used to namespace the per-user cache.
18 // main passes its ldflags-stamped Version here.
19 type Config struct {
20 Version string
21 }
22
23 // Register adds every tool this server exposes to s. The caller supplies a
24 // Config so test harnesses can pin a version without touching globals.
25 func Register(s *server.MCPServer, cfg Config) {
26 registerPreflightTool(s, cfg)
27 s.AddTool(
28 mcplib.NewTool("research",
29 mcplib.WithDescription(
30 "Research what people are actually saying about any topic in the last 30 days. "+
31 "Aggregates Reddit, X, YouTube, Hacker News, Polymarket, GitHub, and the web, "+
32 "scored by upvotes, likes, transcripts, and real-money prediction-market odds. "+
33 "Returns the engine's compact output for the model to synthesize.",
34 ),
35 mcplib.WithString("topic", mcplib.Required(), mcplib.Description("The subject to research (a person, company, product, event, or general topic).")),
36 mcplib.WithString("emit", mcplib.Description("Output shape: 'compact' (default) for inline synthesis or 'html' to save a shareable brief alongside the response.")),
37 mcplib.WithBoolean("save", mcplib.Description("Persist the synthesis as a markdown report under ~/Documents/Last30Days/ (or LAST30DAYS_MEMORY_DIR if set).")),
38 mcplib.WithReadOnlyHintAnnotation(false),
39 mcplib.WithDestructiveHintAnnotation(false),
40 mcplib.WithOpenWorldHintAnnotation(true),
41 ),
42 makeResearchHandler(cfg),
43 )
44 }
45
46 func makeResearchHandler(cfg Config) server.ToolHandlerFunc {
47 return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
48 args := req.GetArguments()
49 topic, err := requireString(args, "topic")
50 if err != nil {
51 return mcplib.NewToolResultError(err.Error()), nil
52 }
53
54 emit, err := emitArgument(args)
55 if err != nil {
56 return mcplib.NewToolResultError(err.Error()), nil
57 }
58
59 save, err := boolArgument(args, "save")
60 if err != nil {
61 return mcplib.NewToolResultError(err.Error()), nil
62 }
63
64 src, err := engine.EngineFS()
65 if err != nil {
66 return mcplib.NewToolResultError(fmt.Sprintf("engine source unavailable: %v", err)), nil
67 }
68 cacheDir, err := engine.EnsureUserCache(src, cfg.Version)
69 if err != nil {
70 return mcplib.NewToolResultError(fmt.Sprintf(
71 "engine extract failed: %v\nhint: set %s to a writable directory if the default cache location is locked down",
72 err, engine.CacheEnvOverride,
73 )), nil
74 }
75
76 runArgs := researchRunArgs(topic, emit, save)
77
78 res, runErr := engine.Run(ctx, engine.RunOptions{
79 CacheDir: cacheDir,
80 Args: runArgs,
81 })
82 if runErr != nil {
83 return mcplib.NewToolResultError(formatRunError(runErr, res)), nil
84 }
85 return mcplib.NewToolResultText(string(res.Stdout)), nil
86 }
87 }
88
89 func researchRunArgs(topic, emit string, save bool) []string {
90 runArgs := []string{topic, "--emit=" + emit, "--no-browser-cookies"}
91 if save {
92 runArgs = append(runArgs, "--save-dir", mcpSaveDir())
93 }
94 return runArgs
95 }
96
97 func mcpSaveDir() string {
98 saveDir := os.Getenv("LAST30DAYS_MEMORY_DIR")
99 if saveDir == "" {
100 return "~/Documents/Last30Days"
101 }
102 return saveDir
103 }
104
105 func requireString(args map[string]any, name string) (string, error) {
106 raw, ok := args[name]
107 if !ok {
108 return "", fmt.Errorf("%s is required", name)
109 }
110 value, ok := raw.(string)
111 if !ok || strings.TrimSpace(value) == "" {
112 return "", fmt.Errorf("%s must be a non-empty string", name)
113 }
114 return value, nil
115 }
116
117 func emitArgument(args map[string]any) (string, error) {
118 raw, ok := args["emit"]
119 if !ok {
120 return "compact", nil
121 }
122 value, ok := raw.(string)
123 if !ok {
124 return "", errors.New("emit must be a string")
125 }
126 switch value {
127 case "":
128 return "compact", nil
129 case "compact", "html":
130 return value, nil
131 default:
132 return "", fmt.Errorf("emit must be 'compact' or 'html', got %q", value)
133 }
134 }
135
136 func boolArgument(args map[string]any, name string) (bool, error) {
137 raw, ok := args[name]
138 if !ok {
139 return false, nil
140 }
141 value, ok := raw.(bool)
142 if !ok {
143 return false, fmt.Errorf("%s must be a boolean", name)
144 }
145 return value, nil
146 }
147
148 // formatRunError flattens engine.Run's distinct error shapes into a single
149 // user-facing message that includes the relevant stderr context.
150 func formatRunError(runErr error, res *engine.RunResult) string {
151 var msg strings.Builder
152 msg.WriteString(runErr.Error())
153 if res != nil && len(res.Stderr) > 0 {
154 msg.WriteString("\nengine stderr:\n")
155 msg.Write(res.Stderr)
156 }
157 return msg.String()
158 }
159
159 lines GO