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