返回 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 // PythonEnvOverride lets operators select the Python 3.12+ executable used
40 // by the MCP server. When unset, Run preserves the python3 PATH lookup.
41 const PythonEnvOverride = "LAST30DAYS_PYTHON"
42
43 // RunOptions configures one invocation of the embedded Python engine.
44 // PythonPath is exposed so tests can substitute a stub interpreter without
45 // manipulating the process PATH.
46 type RunOptions struct {
47 PythonPath string // resolved test/caller override; empty honors PythonEnvOverride, then DefaultPythonBinary
48 CacheDir string // engine.Ensure result; lib/ here is added to PYTHONPATH
49 Args []string // arguments after last30days.py (topic, --emit=..., etc.)
50 ExtraEnv []string // appended to os.Environ() for the child process
51 Timeout time.Duration // zero means DefaultTimeout or TimeoutEnvOverride
52 }
53
54 // RunResult captures the engine's full output. Stdout is what we surface to
55 // the agent; Stderr is included in error messages so users can diagnose
56 // engine failures without leaving Claude Desktop.
57 type RunResult struct {
58 Stdout []byte
59 Stderr []byte
60 ExitCode int
61 TimedOut bool
62 }
63
64 // Run shells out to python3 with last30days.py inside cacheDir. The child
65 // receives the parent environment (so MCPB user_config env-injection
66 // reaches the engine) plus ExtraEnv and a PYTHONPATH that points at the
67 // cache so the engine's `from lib import ...` statements resolve.
68 //
69 // A missing interpreter, a non-zero exit, and a timeout each surface as
70 // distinct errors so the tool handler can map them to user-facing
71 // messages without re-parsing stderr.
72 func Run(ctx context.Context, opts RunOptions) (*RunResult, error) {
73 if opts.CacheDir == "" {
74 return nil, errors.New("engine: CacheDir is required")
75 }
76 pythonPath, err := resolvePython(opts.PythonPath)
77 if err != nil {
78 return nil, err
79 }
80
81 scriptPath := filepath.Join(opts.CacheDir, "last30days.py")
82 if _, err := os.Stat(scriptPath); err != nil {
83 return nil, fmt.Errorf("engine: last30days.py not found in cache %s: %w", opts.CacheDir, err)
84 }
85
86 timeout := resolveTimeout(opts.Timeout)
87 subCtx, cancel := context.WithTimeout(ctx, timeout)
88 defer cancel()
89
90 args := append([]string{scriptPath}, opts.Args...)
91 cmd := exec.CommandContext(subCtx, pythonPath, args...)
92 cmd.Env = buildEnv(opts.CacheDir, opts.ExtraEnv)
93
94 var stdout, stderr bytes.Buffer
95 cmd.Stdout = &stdout
96 cmd.Stderr = &stderr
97
98 err = cmd.Run()
99 res := &RunResult{
100 Stdout: stdout.Bytes(),
101 Stderr: stderr.Bytes(),
102 ExitCode: 0,
103 TimedOut: errors.Is(subCtx.Err(), context.DeadlineExceeded),
104 }
105 if err == nil {
106 return res, nil
107 }
108
109 var exitErr *exec.ExitError
110 if errors.As(err, &exitErr) {
111 res.ExitCode = exitErr.ExitCode()
112 if res.TimedOut {
113 return res, fmt.Errorf("engine: subprocess exceeded %s timeout", timeout)
114 }
115 return res, fmt.Errorf("engine: subprocess exited with code %d", res.ExitCode)
116 }
117 return res, fmt.Errorf("engine: subprocess failed to start: %w", err)
118 }
119
120 // resolvePython returns a resolved interpreter path or a clear error. A
121 // caller-supplied path remains the highest-priority test seam. Otherwise an
122 // explicitly configured LAST30DAYS_PYTHON must resolve successfully; only
123 // an absent override falls back to the existing python3 PATH lookup.
124 func resolvePython(override string) (string, error) {
125 if override != "" {
126 return override, nil
127 }
128 if configured, ok := os.LookupEnv(PythonEnvOverride); ok {
129 if configured == "" {
130 return "", fmt.Errorf(
131 "engine: %s is set but empty; set it to a Python %s+ executable or unset it to use %s on PATH",
132 PythonEnvOverride, MinPythonVersion, DefaultPythonBinary,
133 )
134 }
135 path, err := exec.LookPath(configured)
136 if err != nil {
137 return "", fmt.Errorf(
138 "engine: %s=%q does not resolve to an executable (need Python %s+, install from %s): %w",
139 PythonEnvOverride, configured, MinPythonVersion, PythonInstallURL, err,
140 )
141 }
142 return path, nil
143 }
144 path, err := exec.LookPath(DefaultPythonBinary)
145 // Go normally rejects relative results with ErrDot. Keep this invariant
146 // even when that protection is disabled with GODEBUG=execerrdot=0.
147 if err == nil && filepath.IsAbs(path) {
148 return path, nil
149 }
150 return "", fmt.Errorf(
151 "engine: %s not found on PATH (need Python %s+, install from %s; current GOOS=%s)",
152 DefaultPythonBinary, MinPythonVersion, PythonInstallURL, runtime.GOOS,
153 )
154 }
155
156 func resolveTimeout(explicit time.Duration) time.Duration {
157 if explicit > 0 {
158 return explicit
159 }
160 if raw := os.Getenv(TimeoutEnvOverride); raw != "" {
161 if d, err := time.ParseDuration(raw); err == nil && d > 0 {
162 return d
163 }
164 // Accept bare integer seconds (e.g. "300") as documented.
165 if secs, err := strconv.Atoi(raw); err == nil && secs > 0 {
166 return time.Duration(secs) * time.Second
167 }
168 }
169 return DefaultTimeout
170 }
171
172 // buildEnv stitches PYTHONPATH onto os.Environ + ExtraEnv. Any pre-existing
173 // PYTHONPATH in the parent environment is dropped before appending the
174 // cache dir; otherwise the child sees two PYTHONPATH= entries and POSIX
175 // getenv returns the first one, so the user's value wins and the engine's
176 // `from lib import ...` fails with ModuleNotFoundError. The engine is
177 // self-contained and does not need the user's Python module search path.
178 func buildEnv(cacheDir string, extra []string) []string {
179 const pyKey = "PYTHONPATH="
180 parent := os.Environ()
181 base := make([]string, 0, len(parent)+1+len(extra))
182 for _, kv := range parent {
183 if strings.HasPrefix(kv, pyKey) {
184 continue
185 }
186 base = append(base, kv)
187 }
188 base = append(base, pyKey+cacheDir)
189 base = append(base, extra...)
190 return base
191 }
192
192 lines GO