返回 last30days-skill
run.go
根目录 / mcp / internal / engine / run.go
1 package engine
2
3 import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "runtime"
12 "strconv"
13 "strings"
14 "time"
15 )
16
17 // DefaultPythonBinary is the interpreter we look up unless RunOptions
18 // overrides it. Windows installs may expose only "python"; we surface a
19 // clear error in that case rather than silently picking the wrong binary.
20 const DefaultPythonBinary = "python3"
21
22 // MinPythonVersion mirrors the engine's MIN_PYTHON constant in
23 // last30days.py. Surfaced in errors so users know what they're missing.
24 const MinPythonVersion = "3.12"
25
26 // PythonInstallURL is included in the missing-interpreter error so users
27 // have a direct route from the failure to a fix.
28 const PythonInstallURL = "https://www.python.org/downloads/"
29
30 // DefaultTimeout caps a single research subprocess. The engine's deep mode
31 // can run several minutes; five minutes is a safe upper bound that still
32 // fails fast when something hangs.
33 const DefaultTimeout = 5 * time.Minute
34
35 // TimeoutEnvOverride lets operators override DefaultTimeout per install
36 // (seconds, integer). Honored by Run when RunOptions.Timeout is zero.
37 const TimeoutEnvOverride = "LAST30DAYS_MCP_TIMEOUT"
38
39 // RunOptions configures one invocation of the embedded Python engine.
40 // PythonPath is exposed so tests can substitute a stub interpreter without
41 // manipulating the process PATH.
42 type RunOptions struct {
43 PythonPath string // resolved python3 binary; empty means look up DefaultPythonBinary on PATH
44 CacheDir string // engine.Ensure result; lib/ here is added to PYTHONPATH
45 Args []string // arguments after last30days.py (topic, --emit=..., etc.)
46 ExtraEnv []string // appended to os.Environ() for the child process
47 Timeout time.Duration // zero means DefaultTimeout or TimeoutEnvOverride
48 }
49
50 // RunResult captures the engine's full output. Stdout is what we surface to
51 // the agent; Stderr is included in error messages so users can diagnose
52 // engine failures without leaving Claude Desktop.
53 type RunResult struct {
54 Stdout []byte
55 Stderr []byte
56 ExitCode int
57 TimedOut bool
58 }
59
60 // Run shells out to python3 with last30days.py inside cacheDir. The child
61 // receives the parent environment (so MCPB user_config env-injection
62 // reaches the engine) plus ExtraEnv and a PYTHONPATH that points at the
63 // cache so the engine's `from lib import ...` statements resolve.
64 //
65 // A missing interpreter, a non-zero exit, and a timeout each surface as
66 // distinct errors so the tool handler can map them to user-facing
67 // messages without re-parsing stderr.
68 func Run(ctx context.Context, opts RunOptions) (*RunResult, error) {
69 if opts.CacheDir == "" {
70 return nil, errors.New("engine: CacheDir is required")
71 }
72 pythonPath, err := resolvePython(opts.PythonPath)
73 if err != nil {
74 return nil, err
75 }
76
77 scriptPath := filepath.Join(opts.CacheDir, "last30days.py")
78 if _, err := os.Stat(scriptPath); err != nil {
79 return nil, fmt.Errorf("engine: last30days.py not found in cache %s: %w", opts.CacheDir, err)
80 }
81
82 timeout := resolveTimeout(opts.Timeout)
83 subCtx, cancel := context.WithTimeout(ctx, timeout)
84 defer cancel()
85
86 args := append([]string{scriptPath}, opts.Args...)
87 cmd := exec.CommandContext(subCtx, pythonPath, args...)
88 cmd.Env = buildEnv(opts.CacheDir, opts.ExtraEnv)
89
90 var stdout, stderr bytes.Buffer
91 cmd.Stdout = &stdout
92 cmd.Stderr = &stderr
93
94 err = cmd.Run()
95 res := &RunResult{
96 Stdout: stdout.Bytes(),
97 Stderr: stderr.Bytes(),
98 ExitCode: 0,
99 TimedOut: errors.Is(subCtx.Err(), context.DeadlineExceeded),
100 }
101 if err == nil {
102 return res, nil
103 }
104
105 var exitErr *exec.ExitError
106 if errors.As(err, &exitErr) {
107 res.ExitCode = exitErr.ExitCode()
108 if res.TimedOut {
109 return res, fmt.Errorf("engine: subprocess exceeded %s timeout", timeout)
110 }
111 return res, fmt.Errorf("engine: subprocess exited with code %d", res.ExitCode)
112 }
113 return res, fmt.Errorf("engine: subprocess failed to start: %w", err)
114 }
115
116 // resolvePython returns an absolute path to the interpreter or an error
117 // naming the install URL. If the caller supplied a path we trust it - tests
118 // rely on this to inject a stub. Otherwise we look up python3 on PATH.
119 func resolvePython(override string) (string, error) {
120 if override != "" {
121 return override, nil
122 }
123 path, err := exec.LookPath(DefaultPythonBinary)
124 if err == nil {
125 return path, nil
126 }
127 return "", fmt.Errorf(
128 "engine: %s not found on PATH (need Python %s+, install from %s; current GOOS=%s)",
129 DefaultPythonBinary, MinPythonVersion, PythonInstallURL, runtime.GOOS,
130 )
131 }
132
133 func resolveTimeout(explicit time.Duration) time.Duration {
134 if explicit > 0 {
135 return explicit
136 }
137 if raw := os.Getenv(TimeoutEnvOverride); raw != "" {
138 if d, err := time.ParseDuration(raw); err == nil && d > 0 {
139 return d
140 }
141 // Accept bare integer seconds (e.g. "300") as documented.
142 if secs, err := strconv.Atoi(raw); err == nil && secs > 0 {
143 return time.Duration(secs) * time.Second
144 }
145 }
146 return DefaultTimeout
147 }
148
149 // buildEnv stitches PYTHONPATH onto os.Environ + ExtraEnv. Any pre-existing
150 // PYTHONPATH in the parent environment is dropped before appending the
151 // cache dir; otherwise the child sees two PYTHONPATH= entries and POSIX
152 // getenv returns the first one, so the user's value wins and the engine's
153 // `from lib import ...` fails with ModuleNotFoundError. The engine is
154 // self-contained and does not need the user's Python module search path.
155 func buildEnv(cacheDir string, extra []string) []string {
156 const pyKey = "PYTHONPATH="
157 parent := os.Environ()
158 base := make([]string, 0, len(parent)+1+len(extra))
159 for _, kv := range parent {
160 if strings.HasPrefix(kv, pyKey) {
161 continue
162 }
163 base = append(base, kv)
164 }
165 base = append(base, pyKey+cacheDir)
166 base = append(base, extra...)
167 return base
168 }
169
169 lines GO