Compare commits

...

8 Commits

Author SHA1 Message Date
Matt Van Horn 61d46b54ee fix(mcp): dedup PYTHONPATH and drop unsupported win32 platform
Addresses two Greptile findings on #428.

P1 - buildEnv duplicated PYTHONPATH when the parent environment already
set one. POSIX getenv returns the first match, so the user's stale
PYTHONPATH would shadow the engine's cache dir and break
`from lib import ...` with ModuleNotFoundError. buildEnv now filters
any incoming PYTHONPATH= entry before appending the cache dir. Adds
TestRunDropsPreExistingPythonPath (end-to-end through the stub
interpreter) and TestBuildEnvDropsAllPreExistingPythonPath (direct
unit on the helper) to cover the missed case.

P2 - manifest.compatibility.platforms listed "win32" even though the
release matrix doesn't ship a Windows binary; Claude Desktop would
let Windows users start an install with no matching artifact.
Removed until the Windows packaging follow-up lands. Manifest test
renamed to TestPlatformsMatchShippingMatrix and tightened: now
forbids platforms the release CI doesn't build, with a message
pointing at .github/workflows/release.yml.

go test ./... 38 passed across 4 packages.
2026-05-17 21:18:05 -07:00
Matt Van Horn e7b7e61237 test(ci): allow mcp/scripts/ alongside skills/last30days/scripts/
The plugin-contract test guards against references to the removed
root-level scripts/ directory but matched any line containing
"scripts/", which caught the new mcp/scripts/sync-engine.sh
invocation in the release workflow. Extend the allowlist to cover
mcp/scripts/ and make the structure explicit so future legitimate
subdir scripts/ paths can be added without re-discovering this rule.
2026-05-17 20:57:48 -07:00
Matt Van Horn a547a0a948 docs(readme): install path for Claude Desktop via .mcpb bundle
U6 of the Claude Desktop .mcpb bundle plan.

Adds Claude Desktop as a fifth install surface in the Install table and
a dedicated subsection with the drag-drop flow, per-platform download
filenames, Python 3.12+ host requirement, the per-install credential
store caveat (Desktop and Code don't share keys), and the deferred-
Windows note.
2026-05-17 20:55:32 -07:00
Matt Van Horn 8ea048b988 feat(ci): release workflow builds .mcpb bundles for darwin + linux
U5 of the Claude Desktop .mcpb bundle plan. Splits the existing single-
artifact release into three jobs.

- build-skill keeps the prior bash skills/last30days/scripts/build-skill.sh
  flow, now uploaded via actions/upload-artifact instead of attaching
  directly so the final release step can pull from one place.
- build-mcpb runs a matrix across darwin/arm64, darwin/amd64, and
  linux/amd64. Each entry installs printing-press@v4.8.0 (pinned to
  the version this PR was verified against; bump deliberately),
  runs mcp/scripts/sync-engine.sh, cross-compiles the Go binary with
  CGO_ENABLED=0 and the tag stamped into main.Version, and packages
  via `printing-press bundle`. Output filenames follow PP's
  DefaultBundleOutputPath convention.
- release downloads every artifact (.skill + 3 .mcpb files) and
  attaches them to the GitHub release with generated notes.
- Windows packaging is deferred: the manifest's entry_point cannot
  vary per platform within a single bundle, and Windows binaries need
  .exe naming for the OS to honor execve. A follow-up plan can ship a
  Windows-only bundle variant when there is demand.

Verified locally: `printing-press bundle --skip-build --binary` against
a host build produces a valid .mcpb (manifest.json + bin/<entry>).
YAML parse-clean on both workflows.
2026-05-17 20:54:48 -07:00
Matt Van Horn 1b23a3e900 feat(mcp): MCPB v0.3 manifest with 13 user_config slots
U4 of the Claude Desktop .mcpb bundle plan.

- mcp/manifest.json hand-authored to match PP's emitted shape (see
  ~/printing-press/library/bugbounty-goat/manifest.json for the
  canonical reference). 13 user_config slots, all sensitive=true and
  required=false so the engine's graceful degradation to web-only
  mode keeps the install non-blocking on credential entry.
- Covered API keys: OpenAI, xAI, Brave, Exa, Serper, Google,
  Gemini (and the Google_genai alias), Apify, Bluesky app password,
  Parallel, ScrapeCreators, OpenRouter. Cookie / session flows
  (Truth Social, Xiaohongshu, ChatGPT account ID, Codex auth)
  deferred per plan Scope Boundaries - they need a richer UX than
  plain user_config strings.
- internal/manifest/manifest_test.go enforces the structural
  invariants Claude Desktop install correctness depends on:
  required MCPB fields, lowercased-env-name -> user_config-key
  cross-reference both directions, sensitive=true + required=false +
  description present on every slot, and platform list coverage.
- Local smoke: `printing-press bundle --skip-build --binary <built>`
  produces last30days-pp-mcp-darwin-arm64.mcpb (4.7MB compressed,
  manifest.json + bin/last30days-pp-mcp).
2026-05-17 20:52:41 -07:00
Matt Van Horn 35f12cb9ea 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).
2026-05-17 20:51:00 -07:00
Matt Van Horn a1afbce84c feat(mcp): embed Python engine and extract to user cache
U2 of the Claude Desktop .mcpb bundle plan.

- internal/engine/embed.go embeds the vendored Python tree at build time
  via //go:embed all:vendored. The engine package owns the embed because
  Go's directive cannot reach outside its own package directory; sync and
  gitignore paths are updated to match (internal/engine/vendored/ in
  place of mcp/vendored/).
- internal/engine/extract.go materializes the embed into
  <cache>/last30days-pp-mcp/<version>/ with a .version sentinel that
  short-circuits re-extraction. Atomic rename from a .tmp sibling means
  a partial extraction can never be mistaken for complete. Concurrent
  first-call extractions serialize behind a per-cache-dir sync.Once.
- EnsureUserCache honors a LAST30DAYS_CACHE_DIR env override for
  locked-down filesystems; the override is named in extract errors.
- internal/engine/extract_test.go covers happy path, sentinel skip,
  version bump, 10-goroutine race, empty-version rejection, unwritable
  cache parent, and the env override (7 tests, all passing).
- A tracked vendored/.gitkeep anchors the embed path so the directive
  matches even before scripts/sync-engine.sh runs.
2026-05-17 20:46:29 -07:00
Matt Van Horn 15781bfcc0 feat(mcp): scaffold Go module under mcp/ for Claude Desktop bundle
Add a new top-level mcp/ Go module that will host the Claude Desktop MCPB
server. U1 of the Claude Desktop .mcpb bundle plan: scaffold only, no
behavior yet.

- mcp/go.mod targets Go 1.22+ with mark3labs/mcp-go as the planned
  dependency (added in U3 when the server wiring lands).
- mcp/scripts/sync-engine.sh mirrors skills/last30days/scripts/ into
  mcp/vendored/ before each build, keeping the Code skill and Desktop
  bundle on the same engine source.
- Root .gitignore excludes mcp/vendored/ and mcp/build/ so the engine
  mirror and cross-compiled binaries stay local.
2026-05-17 20:42:43 -07:00
20 changed files with 1745 additions and 4 deletions
+96 -2
View File
@@ -9,7 +9,10 @@ permissions:
contents: write contents: write
jobs: jobs:
build-and-release: # Build the existing .skill artifact (Claude Code / Codex / Cursor install
# surface). Unchanged from prior versions; just isolated into its own job
# so the .mcpb matrix can run in parallel.
build-skill:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -22,10 +25,101 @@ jobs:
bash skills/last30days/scripts/build-skill.sh bash skills/last30days/scripts/build-skill.sh
test -f dist/last30days.skill test -f dist/last30days.skill
- name: Upload skill artifact
uses: actions/upload-artifact@v4
with:
name: last30days-skill
path: dist/last30days.skill
# Cross-compile the Go MCP server for each Claude Desktop platform and
# package each as a .mcpb. printing-press bundle handles the manifest +
# zip layout; we only supply the pre-built binary via --skip-build.
build-mcpb:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- goos: darwin
goarch: arm64
platform: darwin/arm64
- goos: darwin
goarch: amd64
platform: darwin/amd64
- goos: linux
goarch: amd64
platform: linux/amd64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: stable
- name: Install printing-press
# Pin to a known-good PP release so the bundle command's behavior
# is deterministic across our tags. Bump deliberately when adopting
# a newer PP version. GOSUMDB=off skips the sumdb 404 some
# private-namespaced go install calls hit even when the repo is
# public; harmless here because the module path is fully qualified.
env:
GOPRIVATE: github.com/mvanhorn/*
GOSUMDB: "off"
run: go install github.com/mvanhorn/cli-printing-press/v4/cmd/printing-press@v4.8.0
- name: Sync engine into vendored/
run: bash mcp/scripts/sync-engine.sh
- name: Build MCP binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
mkdir -p mcp/build
go -C mcp build \
-ldflags "-X main.Version=${{ github.ref_name }}" \
-o build/last30days-pp-mcp \
./cmd/last30days-pp-mcp
- name: Bundle .mcpb
# printing-press bundle reads manifest.json from the cli dir and
# rewrites the binary into bin/<entry_point> inside the zip. The
# --platform tag drives the output filename suffix; the binary
# itself is whatever we just cross-compiled.
run: |
printing-press bundle mcp \
--skip-build \
--binary mcp/build/last30days-pp-mcp \
--platform ${{ matrix.platform }} \
--output mcp/build/last30days-pp-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.mcpb
- name: Upload .mcpb artifact
uses: actions/upload-artifact@v4
with:
name: mcpb-${{ matrix.goos }}-${{ matrix.goarch }}
path: mcp/build/last30days-pp-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.mcpb
# Gather every platform artifact and attach to one GitHub release.
# release-notes generation reads commits since the prior tag.
release:
needs: [build-skill, build-mcpb]
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Create GitHub release - name: Create GitHub release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: dist/last30days.skill files: |
dist/last30days.skill
dist/last30days-pp-mcp-*.mcpb
generate_release_notes: true generate_release_notes: true
draft: false draft: false
prerelease: false prerelease: false
+9
View File
@@ -26,5 +26,14 @@ htmlcov/
# build artifact from scripts/build-skill.sh # build artifact from scripts/build-skill.sh
/dist/ /dist/
# Go MCP bundle build outputs - source of truth for vendored/ stays under
# skills/last30days/scripts/; build/ holds cross-compiled binaries + .mcpb files.
# vendored/ lives inside the engine package because //go:embed cannot reach
# outside its own package directory; the .gitkeep anchor stays tracked so
# the embed pattern always finds a match even before sync-engine runs.
/mcp/internal/engine/vendored/*
!/mcp/internal/engine/vendored/.gitkeep
/mcp/build/
# Internal planning docs (ce:plan output) — keep local, don't publish # Internal planning docs (ce:plan output) — keep local, don't publish
docs/plans/ docs/plans/
+19
View File
@@ -170,6 +170,7 @@ Say "eli5 on" after any research run. The synthesis rewrites in plain language.
| **Claude Code** (recommended) | `/plugin marketplace add mvanhorn/last30days-skill` | Auto via marketplace, or `claude plugin update last30days@last30days-skill` | | **Claude Code** (recommended) | `/plugin marketplace add mvanhorn/last30days-skill` | Auto via marketplace, or `claude plugin update last30days@last30days-skill` |
| **Codex, Cursor, Copilot, Gemini CLI, GitHub Copilot, or any of 50+ [Agent Skills](https://agentskills.io) hosts** | `npx skills add mvanhorn/last30days-skill -g` | `npx skills update last30days -g` | | **Codex, Cursor, Copilot, Gemini CLI, GitHub Copilot, or any of 50+ [Agent Skills](https://agentskills.io) hosts** | `npx skills add mvanhorn/last30days-skill -g` | `npx skills update last30days -g` |
| **claude.ai** (web) | [Download `last30days.skill`](https://github.com/mvanhorn/last30days-skill/releases/latest/download/last30days.skill) and upload via Settings > Capabilities > Skills > + | Re-download and re-upload | | **claude.ai** (web) | [Download `last30days.skill`](https://github.com/mvanhorn/last30days-skill/releases/latest/download/last30days.skill) and upload via Settings > Capabilities > Skills > + | Re-download and re-upload |
| **Claude Desktop** | [Download the `.mcpb` for your platform](https://github.com/mvanhorn/last30days-skill/releases/latest) and drag into Settings > Extensions | Re-download and drag the new bundle in |
| **OpenClaw** | `clawhub install last30days-official` | `clawhub update last30days-official` | | **OpenClaw** | `clawhub install last30days-official` | `clawhub update last30days-official` |
### Claude Code (recommended) ### Claude Code (recommended)
@@ -229,6 +230,24 @@ List and remove with `npx skills list -g` and `npx skills remove last30days -g`.
Enable "Code execution and file creation" under Capabilities first — skills won't run without it. Enable "Code execution and file creation" under Capabilities first — skills won't run without it.
### Claude Desktop
Claude Desktop installs `/last30days` as an MCP server via a `.mcpb` bundle (a one-click Model Context Protocol package).
1. Go to the [latest release](https://github.com/mvanhorn/last30days-skill/releases/latest) and download the `.mcpb` for your platform:
- macOS Apple Silicon: `last30days-pp-mcp-darwin-arm64.mcpb`
- macOS Intel: `last30days-pp-mcp-darwin-amd64.mcpb`
- Linux x86_64: `last30days-pp-mcp-linux-amd64.mcpb`
2. Open Claude Desktop, go to Settings > Extensions, and drag the file in.
3. When prompted, paste API keys for the sources you want to enable. Every field is optional — the engine degrades to web-only mode if you skip them all. Keys are stored in your OS keychain.
4. Restart Claude Desktop. Ask Claude to "research Peter Steinberger" or any topic and it will call the `research` tool.
**Host requirement:** Python 3.12+ on PATH. The bundle ships the engine source but uses your local Python interpreter. Install from [python.org](https://www.python.org/downloads/) on Windows; macOS and most Linux distros ship a compatible version.
**Keys don't sync with the Code skill.** Claude Desktop and Claude Code maintain separate credential stores by design. If you already configured `~/.config/last30days/.env` for the Code skill, you'll re-enter the same keys here once.
Windows support is deferred until per-platform manifest entry points are sorted out; track in a follow-up issue.
### OpenClaw ### OpenClaw
```bash ```bash
+12
View File
@@ -0,0 +1,12 @@
# Mirror of skills/last30days/scripts/, populated by scripts/sync-engine.sh.
# Source of truth lives in the Python skill; never commit the mirror.
# Lives inside internal/engine/ because //go:embed cannot reach outside
# its own package directory.
internal/engine/vendored/*
!internal/engine/vendored/.gitkeep
# Local build output: cross-compiled binaries and packaged .mcpb files.
build/
# Anchor to the mcp/ root so the cmd/last30days-pp-mcp/ package directory
# is not also excluded (subdirs with the same name would otherwise match).
/last30days-pp-mcp
+36
View File
@@ -0,0 +1,36 @@
# last30days-pp-mcp
Go MCP server that wraps the last30days Python engine for Claude Desktop. Packaged as a `.mcpb` bundle (drag-drop install into Claude Desktop).
The MCP server exposes a single `research` tool that mirrors the `/last30days <topic>` slash command available in Claude Code. At runtime the binary extracts the vendored Python engine into a per-user cache and shells out to `python3` to produce the synthesis input Claude renders.
## Architecture
- `cmd/last30days-pp-mcp/` - server entry point
- `internal/engine/` - `embed.FS` of the Python engine + cache extractor + subprocess wrapper
- `internal/tools/` - MCP tool handlers (currently `research`)
- `internal/engine/vendored/` - mirror of `skills/last30days/scripts/`, generated by `scripts/sync-engine.sh` (gitignored). Lives inside the engine package because `//go:embed` cannot reach files outside its own package directory.
- `manifest.json` - MCPB v0.3 manifest consumed by Claude Desktop and `printing-press bundle`
## Local build
```bash
# Mirror the Python engine into vendored/.
bash scripts/sync-engine.sh
# Build for the current host.
go build -ldflags "-X main.Version=dev" -o build/last30days-pp-mcp ./cmd/last30days-pp-mcp
# Package as a .mcpb (requires the printing-press binary on PATH).
printing-press bundle . --skip-build --binary build/last30days-pp-mcp
```
The output `.mcpb` lands at `build/last30days-pp-mcp-<os>-<arch>.mcpb`. Drag it into Claude Desktop's Extensions panel to install.
## Runtime requirements
End users need Python 3.12+ on PATH. The bundle ships the engine source but relies on the host interpreter.
## Versioning
The MCPB `manifest.json` version is hand-bumped in the same PR that ships engine changes worth releasing. Release CI stamps the Go binary's `main.Version` from the tag.
+39
View File
@@ -0,0 +1,39 @@
// Package main is the entry point for the last30days MCP server bundled
// as a .mcpb for Claude Desktop. The server registers a single research
// tool (see internal/tools) and serves it over stdio. See mcp/README.md
// for build and packaging instructions.
package main
import (
"fmt"
"os"
"github.com/mark3labs/mcp-go/server"
"github.com/mvanhorn/last30days-skill/mcp/internal/tools"
)
// Version is stamped at build time via -ldflags "-X main.Version=<tag>".
// It namespaces the per-user cache directory in internal/engine so multiple
// installed versions can coexist without clobbering each other.
var Version = "dev"
const (
serverName = "last30days"
serverVersion = "1"
)
func main() {
s := server.NewMCPServer(
serverName,
serverVersion,
server.WithToolCapabilities(false),
)
tools.Register(s, tools.Config{Version: Version})
if err := server.ServeStdio(s); err != nil {
fmt.Fprintf(os.Stderr, "last30days-pp-mcp: %v\n", err)
os.Exit(1)
}
}
+14
View File
@@ -0,0 +1,14 @@
module github.com/mvanhorn/last30days-skill/mcp
go 1.25.5
require github.com/mark3labs/mcp-go v0.54.0
require (
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/text v0.14.0 // indirect
)
+34
View File
@@ -0,0 +1,34 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mark3labs/mcp-go v0.54.0 h1:PZhQvd+5xrT43cUoiaKn/hDcvLUhcLc1twSEKYPTcTA=
github.com/mark3labs/mcp-go v0.54.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+26
View File
@@ -0,0 +1,26 @@
// Package engine wraps the vendored Python last30days engine. The engine
// is embedded at build time via //go:embed and extracted into a per-user
// cache directory on first use, then invoked through python3 in a
// subprocess. Consumers should call EnsureUserCache to materialize the
// engine and Run to execute it.
package engine
import (
"embed"
"io/fs"
)
// EngineSourceDir is the embed root inside the binary. scripts/sync-engine.sh
// mirrors skills/last30days/scripts/ into this directory before each build.
// The all: prefix preserves files starting with "." or "_" so the .gitkeep
// anchor file survives - without it the embed would error before sync runs.
//
//go:embed all:vendored
var vendored embed.FS
// EngineFS returns the embedded engine as a filesystem rooted at the
// vendored/ directory contents (so callers see "last30days.py" at the
// root, not "vendored/last30days.py").
func EngineFS() (fs.FS, error) {
return fs.Sub(vendored, "vendored")
}
+168
View File
@@ -0,0 +1,168 @@
package engine
import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sync"
)
// SentinelFilename names the file Ensure writes inside the cache directory
// after a successful extraction. Its contents are compared to the requested
// version; a match short-circuits re-extraction on subsequent calls.
const SentinelFilename = ".version"
// cacheSubdir namespaces our cache under the OS user cache directory so
// multiple printing-press-style bundles can coexist.
const cacheSubdir = "last30days-pp-mcp"
// CacheEnvOverride lets users redirect the cache directory when the default
// OS cache location is read-only (locked-down corp images, ephemeral CI
// containers). Pointed at by extract errors via the documented escape hatch.
const CacheEnvOverride = "LAST30DAYS_CACHE_DIR"
// Ensure extracts src into baseDir/last30days-pp-mcp/<version> and returns
// the cache path. If the sentinel file already records the same version the
// directory is reused without rewriting. version must be non-empty so the
// cache layout always namespaces by version.
//
// Extraction writes to a sibling .tmp directory and renames it on success
// so a partial extraction can never be mistaken for a complete one. Concurrent
// callers within the same process serialize behind a per-cache-dir sync.Once
// so the rename happens exactly once.
func Ensure(src fs.FS, baseDir, version string) (string, error) {
if version == "" {
return "", errors.New("engine: version is required")
}
cacheDir := filepath.Join(baseDir, cacheSubdir, version)
once := getOnce(cacheDir)
var extractErr error
once.Do(func() {
extractErr = ensureLocked(src, cacheDir, version)
})
if extractErr != nil {
// Reset the sync.Once so a follow-up call can retry rather than
// permanently caching the error. Retry is the right default when
// the failure is transient (e.g., disk full, parent dir restored).
resetOnce(cacheDir)
return "", extractErr
}
return cacheDir, nil
}
// EnsureUserCache wraps Ensure with the OS user cache dir (or the
// LAST30DAYS_CACHE_DIR override) as base. Production callers use this; tests
// use Ensure with an explicit temp dir.
func EnsureUserCache(src fs.FS, version string) (string, error) {
if override := os.Getenv(CacheEnvOverride); override != "" {
return Ensure(src, override, version)
}
base, err := os.UserCacheDir()
if err != nil {
return "", fmt.Errorf("engine: resolve user cache dir (set %s to override): %w", CacheEnvOverride, err)
}
return Ensure(src, base, version)
}
func ensureLocked(src fs.FS, cacheDir, version string) error {
if sentinelMatches(cacheDir, version) {
return nil
}
tmpDir := cacheDir + ".tmp"
if err := os.RemoveAll(tmpDir); err != nil {
return fmt.Errorf("engine: clean tmp cache: %w", err)
}
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return fmt.Errorf("engine: create tmp cache (%s, set %s to override): %w", tmpDir, CacheEnvOverride, err)
}
if err := extractAll(src, tmpDir); err != nil {
_ = os.RemoveAll(tmpDir)
return err
}
sentinel := filepath.Join(tmpDir, SentinelFilename)
if err := os.WriteFile(sentinel, []byte(version), 0o644); err != nil {
_ = os.RemoveAll(tmpDir)
return fmt.Errorf("engine: write sentinel: %w", err)
}
if err := os.RemoveAll(cacheDir); err != nil {
_ = os.RemoveAll(tmpDir)
return fmt.Errorf("engine: clean old cache: %w", err)
}
if err := os.Rename(tmpDir, cacheDir); err != nil {
_ = os.RemoveAll(tmpDir)
return fmt.Errorf("engine: promote tmp cache: %w", err)
}
return nil
}
func sentinelMatches(cacheDir, version string) bool {
data, err := os.ReadFile(filepath.Join(cacheDir, SentinelFilename))
if err != nil {
return false
}
return string(data) == version
}
func extractAll(src fs.FS, dst string) error {
return fs.WalkDir(src, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if path == "." {
return nil
}
target := filepath.Join(dst, path)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
return copyEmbeddedFile(src, path, target)
})
}
func copyEmbeddedFile(src fs.FS, srcPath, dst string) error {
in, err := src.Open(srcPath)
if err != nil {
return fmt.Errorf("engine: open %s: %w", srcPath, err)
}
defer func() { _ = in.Close() }()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("engine: ensure parent of %s: %w", dst, err)
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("engine: create %s: %w", dst, err)
}
defer func() { _ = out.Close() }()
if _, err := io.Copy(out, in); err != nil {
return fmt.Errorf("engine: write %s: %w", dst, err)
}
return nil
}
// onceRegistry serializes first-call extraction per cache directory so the
// rename in ensureLocked happens exactly once across goroutines.
var (
onceMu sync.Mutex
onceRegistry = map[string]*sync.Once{}
)
func getOnce(cacheDir string) *sync.Once {
onceMu.Lock()
defer onceMu.Unlock()
if o, ok := onceRegistry[cacheDir]; ok {
return o
}
o := &sync.Once{}
onceRegistry[cacheDir] = o
return o
}
func resetOnce(cacheDir string) {
onceMu.Lock()
defer onceMu.Unlock()
delete(onceRegistry, cacheDir)
}
+167
View File
@@ -0,0 +1,167 @@
package engine
import (
"os"
"path/filepath"
"sync"
"testing"
"testing/fstest"
)
func newTestFS() fstest.MapFS {
return fstest.MapFS{
"last30days.py": &fstest.MapFile{Data: []byte("# last30days entry\n"), Mode: 0o644},
"lib/__init__.py": &fstest.MapFile{Data: []byte(""), Mode: 0o644},
"lib/env.py": &fstest.MapFile{Data: []byte("# env helpers\n"), Mode: 0o644},
}
}
func TestEnsureExtractsEngine(t *testing.T) {
src := newTestFS()
base := t.TempDir()
cacheDir, err := Ensure(src, base, "v1")
if err != nil {
t.Fatalf("Ensure: %v", err)
}
if cacheDir != filepath.Join(base, cacheSubdir, "v1") {
t.Fatalf("cacheDir = %q, want %q", cacheDir, filepath.Join(base, cacheSubdir, "v1"))
}
mustReadFile(t, filepath.Join(cacheDir, "last30days.py"), "# last30days entry\n")
mustReadFile(t, filepath.Join(cacheDir, "lib/env.py"), "# env helpers\n")
mustReadFile(t, filepath.Join(cacheDir, SentinelFilename), "v1")
}
func TestEnsureSkipsWhenSentinelMatches(t *testing.T) {
src := newTestFS()
base := t.TempDir()
cacheDir, err := Ensure(src, base, "v1")
if err != nil {
t.Fatalf("first Ensure: %v", err)
}
target := filepath.Join(cacheDir, "last30days.py")
info1, err := os.Stat(target)
if err != nil {
t.Fatalf("stat: %v", err)
}
// Reset the sync.Once so a second call would re-extract if not for the
// sentinel short-circuit. Without the reset, sync.Once would skip the
// extraction regardless of sentinel state.
resetOnce(cacheDir)
if _, err := Ensure(src, base, "v1"); err != nil {
t.Fatalf("second Ensure: %v", err)
}
info2, err := os.Stat(target)
if err != nil {
t.Fatalf("stat second: %v", err)
}
if !info2.ModTime().Equal(info1.ModTime()) {
t.Fatalf("expected file untouched on sentinel match; got mtime %v -> %v", info1.ModTime(), info2.ModTime())
}
}
func TestEnsureReExtractsOnVersionChange(t *testing.T) {
v1 := fstest.MapFS{
"last30days.py": &fstest.MapFile{Data: []byte("v1\n"), Mode: 0o644},
}
v2 := fstest.MapFS{
"last30days.py": &fstest.MapFile{Data: []byte("v2\n"), Mode: 0o644},
}
base := t.TempDir()
cache1, err := Ensure(v1, base, "v1")
if err != nil {
t.Fatalf("Ensure v1: %v", err)
}
cache2, err := Ensure(v2, base, "v2")
if err != nil {
t.Fatalf("Ensure v2: %v", err)
}
if cache1 == cache2 {
t.Fatalf("expected distinct cache dirs per version, got %q == %q", cache1, cache2)
}
mustReadFile(t, filepath.Join(cache1, "last30days.py"), "v1\n")
mustReadFile(t, filepath.Join(cache2, "last30days.py"), "v2\n")
}
func TestEnsureConcurrentFirstCall(t *testing.T) {
src := newTestFS()
base := t.TempDir()
const goroutines = 10
var wg sync.WaitGroup
wg.Add(goroutines)
results := make([]string, goroutines)
errs := make([]error, goroutines)
for i := 0; i < goroutines; i++ {
i := i
go func() {
defer wg.Done()
results[i], errs[i] = Ensure(src, base, "v1")
}()
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("goroutine %d: %v", i, err)
}
}
for i := 1; i < goroutines; i++ {
if results[i] != results[0] {
t.Fatalf("goroutine 0 saw %q, goroutine %d saw %q", results[0], i, results[i])
}
}
mustReadFile(t, filepath.Join(results[0], "last30days.py"), "# last30days entry\n")
}
func TestEnsureRejectsEmptyVersion(t *testing.T) {
if _, err := Ensure(newTestFS(), t.TempDir(), ""); err == nil {
t.Fatal("expected error for empty version")
}
}
func TestEnsureReturnsErrorWhenCacheUnwritable(t *testing.T) {
// Place the cache root at a path that cannot exist (a regular file).
// MkdirAll will refuse and Ensure must surface a wrapped error.
base := t.TempDir()
blocker := filepath.Join(base, "blocker")
if err := os.WriteFile(blocker, []byte("not a dir"), 0o644); err != nil {
t.Fatalf("setup: %v", err)
}
_, err := Ensure(newTestFS(), blocker, "v1")
if err == nil {
t.Fatal("expected error when cache parent is not a directory")
}
}
func TestEnsureUserCacheHonorsOverride(t *testing.T) {
override := t.TempDir()
t.Setenv(CacheEnvOverride, override)
src := newTestFS()
cacheDir, err := EnsureUserCache(src, "v1")
if err != nil {
t.Fatalf("EnsureUserCache: %v", err)
}
want := filepath.Join(override, cacheSubdir, "v1")
if cacheDir != want {
t.Fatalf("cacheDir = %q, want %q", cacheDir, want)
}
mustReadFile(t, filepath.Join(cacheDir, "last30days.py"), "# last30days entry\n")
}
func mustReadFile(t *testing.T, path, want string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if string(data) != want {
t.Fatalf("%s: got %q, want %q", path, string(data), want)
}
}
+163
View File
@@ -0,0 +1,163 @@
package engine
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
// DefaultPythonBinary is the interpreter we look up unless RunOptions
// overrides it. Windows installs may expose only "python"; we surface a
// clear error in that case rather than silently picking the wrong binary.
const DefaultPythonBinary = "python3"
// MinPythonVersion mirrors the engine's MIN_PYTHON constant in
// last30days.py. Surfaced in errors so users know what they're missing.
const MinPythonVersion = "3.12"
// PythonInstallURL is included in the missing-interpreter error so users
// have a direct route from the failure to a fix.
const PythonInstallURL = "https://www.python.org/downloads/"
// DefaultTimeout caps a single research subprocess. The engine's deep mode
// can run several minutes; five minutes is a safe upper bound that still
// fails fast when something hangs.
const DefaultTimeout = 5 * time.Minute
// TimeoutEnvOverride lets operators override DefaultTimeout per install
// (seconds, integer). Honored by Run when RunOptions.Timeout is zero.
const TimeoutEnvOverride = "LAST30DAYS_MCP_TIMEOUT"
// RunOptions configures one invocation of the embedded Python engine.
// PythonPath is exposed so tests can substitute a stub interpreter without
// manipulating the process PATH.
type RunOptions struct {
PythonPath string // resolved python3 binary; empty means look up DefaultPythonBinary on PATH
CacheDir string // engine.Ensure result; lib/ here is added to PYTHONPATH
Args []string // arguments after last30days.py (topic, --emit=..., etc.)
ExtraEnv []string // appended to os.Environ() for the child process
Timeout time.Duration // zero means DefaultTimeout or TimeoutEnvOverride
}
// RunResult captures the engine's full output. Stdout is what we surface to
// the agent; Stderr is included in error messages so users can diagnose
// engine failures without leaving Claude Desktop.
type RunResult struct {
Stdout []byte
Stderr []byte
ExitCode int
TimedOut bool
}
// Run shells out to python3 with last30days.py inside cacheDir. The child
// receives the parent environment (so MCPB user_config env-injection
// reaches the engine) plus ExtraEnv and a PYTHONPATH that points at the
// cache so the engine's `from lib import ...` statements resolve.
//
// A missing interpreter, a non-zero exit, and a timeout each surface as
// distinct errors so the tool handler can map them to user-facing
// messages without re-parsing stderr.
func Run(ctx context.Context, opts RunOptions) (*RunResult, error) {
if opts.CacheDir == "" {
return nil, errors.New("engine: CacheDir is required")
}
pythonPath, err := resolvePython(opts.PythonPath)
if err != nil {
return nil, err
}
scriptPath := filepath.Join(opts.CacheDir, "last30days.py")
if _, err := os.Stat(scriptPath); err != nil {
return nil, fmt.Errorf("engine: last30days.py not found in cache %s: %w", opts.CacheDir, err)
}
timeout := resolveTimeout(opts.Timeout)
subCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
args := append([]string{scriptPath}, opts.Args...)
cmd := exec.CommandContext(subCtx, pythonPath, args...)
cmd.Env = buildEnv(opts.CacheDir, opts.ExtraEnv)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
res := &RunResult{
Stdout: stdout.Bytes(),
Stderr: stderr.Bytes(),
ExitCode: 0,
TimedOut: errors.Is(subCtx.Err(), context.DeadlineExceeded),
}
if err == nil {
return res, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
res.ExitCode = exitErr.ExitCode()
if res.TimedOut {
return res, fmt.Errorf("engine: subprocess exceeded %s timeout", timeout)
}
return res, fmt.Errorf("engine: subprocess exited with code %d", res.ExitCode)
}
return res, fmt.Errorf("engine: subprocess failed to start: %w", err)
}
// resolvePython returns an absolute path to the interpreter or an error
// naming the install URL. If the caller supplied a path we trust it - tests
// rely on this to inject a stub. Otherwise we look up python3 on PATH.
func resolvePython(override string) (string, error) {
if override != "" {
return override, nil
}
path, err := exec.LookPath(DefaultPythonBinary)
if err == nil {
return path, nil
}
return "", fmt.Errorf(
"engine: %s not found on PATH (need Python %s+, install from %s; current GOOS=%s)",
DefaultPythonBinary, MinPythonVersion, PythonInstallURL, runtime.GOOS,
)
}
func resolveTimeout(explicit time.Duration) time.Duration {
if explicit > 0 {
return explicit
}
if raw := os.Getenv(TimeoutEnvOverride); raw != "" {
if d, err := time.ParseDuration(raw); err == nil && d > 0 {
return d
}
}
return DefaultTimeout
}
// buildEnv stitches PYTHONPATH onto os.Environ + ExtraEnv. Any pre-existing
// PYTHONPATH in the parent environment is dropped before appending the
// cache dir; otherwise the child sees two PYTHONPATH= entries and POSIX
// getenv returns the first one, so the user's value wins and the engine's
// `from lib import ...` fails with ModuleNotFoundError. The engine is
// self-contained and does not need the user's Python module search path.
func buildEnv(cacheDir string, extra []string) []string {
const pyKey = "PYTHONPATH="
parent := os.Environ()
base := make([]string, 0, len(parent)+1+len(extra))
for _, kv := range parent {
if strings.HasPrefix(kv, pyKey) {
continue
}
base = append(base, kv)
}
base = append(base, pyKey+cacheDir)
base = append(base, extra...)
return base
}
+281
View File
@@ -0,0 +1,281 @@
package engine
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// makeStubPython writes a shell script that simulates python3 and returns
// its absolute path. The script honors a small env-driven protocol so each
// test can shape its output:
//
// STUB_STDOUT - text printed to stdout
// STUB_STDERR - text printed to stderr
// STUB_EXIT_CODE - integer exit code (default 0)
// STUB_SLEEP_SECS - sleep before exiting (for timeout tests)
// STUB_ECHO_ENV - name of an env var; the stub prints "<NAME>=<VALUE>"
// STUB_ECHO_ARG - integer index; the stub prints "ARG<i>=<args[i]>"
//
// The stub ignores its first argument (the script path), matching how a
// real python3 invocation treats `python3 last30days.py ...`.
func makeStubPython(t *testing.T) string {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("stub-python tests rely on POSIX shell")
}
dir := t.TempDir()
path := filepath.Join(dir, "python3-stub.sh")
script := `#!/usr/bin/env bash
if [ -n "${STUB_SLEEP_SECS:-}" ]; then sleep "$STUB_SLEEP_SECS"; fi
if [ -n "${STUB_STDOUT:-}" ]; then printf "%s" "$STUB_STDOUT"; fi
if [ -n "${STUB_STDERR:-}" ]; then printf "%s" "$STUB_STDERR" >&2; fi
if [ -n "${STUB_ECHO_ENV:-}" ]; then echo "${STUB_ECHO_ENV}=${!STUB_ECHO_ENV:-<unset>}"; fi
if [ -n "${STUB_ECHO_ARG:-}" ]; then echo "ARG${STUB_ECHO_ARG}=${!STUB_ECHO_ARG:-<unset>}"; fi
exit "${STUB_EXIT_CODE:-0}"
`
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
return path
}
// stageCache materializes a fake CacheDir with a no-op last30days.py so
// the existence check in Run passes. The stub python3 ignores the script
// contents, so the file just has to exist.
func stageCache(t *testing.T) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "last30days.py"), []byte("# stub\n"), 0o644); err != nil {
t.Fatalf("stage cache: %v", err)
}
return dir
}
func TestRunHappyPath(t *testing.T) {
stub := makeStubPython(t)
cache := stageCache(t)
t.Setenv("STUB_STDOUT", "synthesis output\n")
res, err := Run(context.Background(), RunOptions{
PythonPath: stub,
CacheDir: cache,
Args: []string{"my topic", "--emit=compact"},
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if string(res.Stdout) != "synthesis output\n" {
t.Fatalf("stdout = %q, want %q", res.Stdout, "synthesis output\n")
}
if res.ExitCode != 0 {
t.Fatalf("ExitCode = %d, want 0", res.ExitCode)
}
if res.TimedOut {
t.Fatal("TimedOut = true, want false")
}
}
func TestRunForwardsEnv(t *testing.T) {
stub := makeStubPython(t)
cache := stageCache(t)
t.Setenv("OPENAI_API_KEY", "sk-test-value")
t.Setenv("STUB_ECHO_ENV", "OPENAI_API_KEY")
res, err := Run(context.Background(), RunOptions{
PythonPath: stub,
CacheDir: cache,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if got := strings.TrimSpace(string(res.Stdout)); got != "OPENAI_API_KEY=sk-test-value" {
t.Fatalf("stdout = %q, want OPENAI_API_KEY=sk-test-value", got)
}
}
func TestRunSetsPythonPath(t *testing.T) {
stub := makeStubPython(t)
cache := stageCache(t)
t.Setenv("STUB_ECHO_ENV", "PYTHONPATH")
res, err := Run(context.Background(), RunOptions{
PythonPath: stub,
CacheDir: cache,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
want := "PYTHONPATH=" + cache
if got := strings.TrimSpace(string(res.Stdout)); got != want {
t.Fatalf("stdout = %q, want %q", got, want)
}
}
// TestRunDropsPreExistingPythonPath guards the buildEnv dedup: when the
// parent already sets PYTHONPATH (common on dev machines and CI runners
// that touch Python), the child must NOT see two PYTHONPATH= entries.
// POSIX getenv returns the first match, so a duplicate from os.Environ
// would shadow our cache-dir entry and break `from lib import ...`.
func TestRunDropsPreExistingPythonPath(t *testing.T) {
stub := makeStubPython(t)
cache := stageCache(t)
t.Setenv("PYTHONPATH", "/users-stale-pythonpath")
t.Setenv("STUB_ECHO_ENV", "PYTHONPATH")
res, err := Run(context.Background(), RunOptions{
PythonPath: stub,
CacheDir: cache,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
got := strings.TrimSpace(string(res.Stdout))
want := "PYTHONPATH=" + cache
if got != want {
t.Fatalf("stdout = %q, want %q (stale parent value leaked through)", got, want)
}
}
func TestBuildEnvDropsAllPreExistingPythonPath(t *testing.T) {
// Direct unit test on buildEnv to catch the case where the parent has
// PYTHONPATH set: the returned slice must contain exactly one
// PYTHONPATH= entry, and it must be ours.
t.Setenv("PYTHONPATH", "/parent/one")
cache := "/cache/dir"
out := buildEnv(cache, []string{"EXTRA=1"})
var pythonPaths []string
for _, kv := range out {
if strings.HasPrefix(kv, "PYTHONPATH=") {
pythonPaths = append(pythonPaths, kv)
}
}
if len(pythonPaths) != 1 {
t.Fatalf("got %d PYTHONPATH entries, want 1: %v", len(pythonPaths), pythonPaths)
}
if pythonPaths[0] != "PYTHONPATH="+cache {
t.Fatalf("PYTHONPATH = %q, want %q", pythonPaths[0], "PYTHONPATH="+cache)
}
// Confirm ExtraEnv still rides along.
found := false
for _, kv := range out {
if kv == "EXTRA=1" {
found = true
break
}
}
if !found {
t.Fatal("EXTRA=1 missing from buildEnv output")
}
}
func TestRunSurfacesExitCode(t *testing.T) {
stub := makeStubPython(t)
cache := stageCache(t)
t.Setenv("STUB_STDERR", "engine boom\n")
t.Setenv("STUB_EXIT_CODE", "2")
res, err := Run(context.Background(), RunOptions{
PythonPath: stub,
CacheDir: cache,
})
if err == nil {
t.Fatal("expected error for non-zero exit")
}
if res == nil {
t.Fatal("res is nil; want populated result alongside error")
}
if res.ExitCode != 2 {
t.Fatalf("ExitCode = %d, want 2", res.ExitCode)
}
if !strings.Contains(string(res.Stderr), "engine boom") {
t.Fatalf("stderr did not surface engine output: %q", res.Stderr)
}
}
func TestRunTimesOut(t *testing.T) {
stub := makeStubPython(t)
cache := stageCache(t)
t.Setenv("STUB_SLEEP_SECS", "3")
res, err := Run(context.Background(), RunOptions{
PythonPath: stub,
CacheDir: cache,
Timeout: 200 * time.Millisecond,
})
if err == nil {
t.Fatal("expected timeout error")
}
if !res.TimedOut {
t.Fatal("TimedOut = false, want true")
}
if !strings.Contains(err.Error(), "timeout") {
t.Fatalf("error %q lacks 'timeout' marker", err)
}
}
func TestRunMissingPython(t *testing.T) {
cache := stageCache(t)
// Empty PATH guarantees the lookup fails. PythonPath stays unset so Run
// falls through to exec.LookPath.
t.Setenv("PATH", "")
_, err := Run(context.Background(), RunOptions{CacheDir: cache})
if err == nil {
t.Fatal("expected lookup failure with empty PATH")
}
if !strings.Contains(err.Error(), DefaultPythonBinary) {
t.Fatalf("error %q does not mention %s", err, DefaultPythonBinary)
}
if !strings.Contains(err.Error(), PythonInstallURL) {
t.Fatalf("error %q does not include install URL", err)
}
}
func TestRunMissingScript(t *testing.T) {
stub := makeStubPython(t)
// CacheDir exists but contains no last30days.py.
cache := t.TempDir()
_, err := Run(context.Background(), RunOptions{
PythonPath: stub,
CacheDir: cache,
})
if err == nil {
t.Fatal("expected error when last30days.py missing")
}
if !strings.Contains(err.Error(), "last30days.py") {
t.Fatalf("error %q does not name missing script", err)
}
}
func TestRunRejectsEmptyCacheDir(t *testing.T) {
stub := makeStubPython(t)
_, err := Run(context.Background(), RunOptions{PythonPath: stub})
if err == nil {
t.Fatal("expected error for empty CacheDir")
}
if !errors.Is(err, err) || !strings.Contains(err.Error(), "CacheDir") {
t.Fatalf("error %q does not name CacheDir", err)
}
}
func TestResolveTimeoutHonorsEnv(t *testing.T) {
t.Setenv(TimeoutEnvOverride, "750ms")
if got := resolveTimeout(0); got != 750*time.Millisecond {
t.Fatalf("resolveTimeout = %v, want 750ms", got)
}
t.Setenv(TimeoutEnvOverride, "garbage")
if got := resolveTimeout(0); got != DefaultTimeout {
t.Fatalf("garbage value: got %v, want default %v", got, DefaultTimeout)
}
if got := resolveTimeout(time.Minute); got != time.Minute {
t.Fatalf("explicit value not honored: got %v", got)
}
}
+2
View File
@@ -0,0 +1,2 @@
Populated at build time by scripts/sync-engine.sh.
Source of truth: skills/last30days/scripts/.
+189
View File
@@ -0,0 +1,189 @@
// Package manifest holds tests for mcp/manifest.json. It contains no
// production code - the manifest itself is the artifact, and these tests
// guard structural invariants the bundling pipeline depends on.
package manifest
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// envBinding is a minimal subset of the MCPB v0.3 manifest just covering
// the fields these tests assert on. We deliberately do not depend on the
// printing-press internal/pipeline types (that's an internal/ package and
// not importable across modules) - the structural invariants below are
// what actually matter for Claude Desktop install correctness.
type manifestShape struct {
ManifestVersion string `json:"manifest_version"`
Name string `json:"name"`
Version string `json:"version"`
Server struct {
Type string `json:"type"`
EntryPoint string `json:"entry_point"`
MCPConfig struct {
Command string `json:"command"`
Env map[string]string `json:"env"`
} `json:"mcp_config"`
} `json:"server"`
UserConfig map[string]struct {
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
Sensitive bool `json:"sensitive"`
Required bool `json:"required"`
} `json:"user_config"`
Compatibility struct {
ClaudeDesktop string `json:"claude_desktop"`
Platforms []string `json:"platforms"`
} `json:"compatibility"`
}
// loadManifest reads mcp/manifest.json relative to this test file so the
// test passes regardless of where `go test` is invoked from.
func loadManifest(t *testing.T) manifestShape {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
// manifest_test.go is at mcp/internal/manifest/; manifest.json at mcp/.
manifestPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "manifest.json")
data, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatalf("read manifest: %v", err)
}
var m manifestShape
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("parse manifest: %v", err)
}
return m
}
func TestManifestRequiredFields(t *testing.T) {
m := loadManifest(t)
if m.ManifestVersion != "0.3" {
t.Errorf("manifest_version = %q, want 0.3", m.ManifestVersion)
}
if m.Name != "last30days-pp-mcp" {
t.Errorf("name = %q, want last30days-pp-mcp", m.Name)
}
if m.Version == "" {
t.Error("version is empty")
}
if m.Server.Type != "binary" {
t.Errorf("server.type = %q, want binary", m.Server.Type)
}
if m.Server.EntryPoint != "bin/last30days-pp-mcp" {
t.Errorf("server.entry_point = %q, want bin/last30days-pp-mcp", m.Server.EntryPoint)
}
if m.Compatibility.ClaudeDesktop == "" {
t.Error("compatibility.claude_desktop is empty")
}
}
// TestEnvAndUserConfigCrossReference is the key invariant: every
// ${user_config.<key>} substitution in server.mcp_config.env must point
// at a real user_config entry, and every declared user_config must be
// wired to an env var. A typo on either side silently disables a credential
// at install time without the binary or Claude Desktop noticing.
func TestEnvAndUserConfigCrossReference(t *testing.T) {
m := loadManifest(t)
if len(m.Server.MCPConfig.Env) == 0 {
t.Fatal("server.mcp_config.env is empty; expected user_config substitutions")
}
if len(m.UserConfig) == 0 {
t.Fatal("user_config is empty; expected per-key declarations")
}
for envName, value := range m.Server.MCPConfig.Env {
key, ok := parseUserConfigRef(value)
if !ok {
t.Errorf("env[%s] = %q is not a ${user_config.<key>} reference", envName, value)
continue
}
if _, declared := m.UserConfig[key]; !declared {
t.Errorf("env[%s] references user_config[%q], which is not declared", envName, key)
}
// The user_config key must be the lowercased env var so Claude
// Desktop's substitution rule matches PP's emitted shape.
if got := strings.ToLower(envName); key != got {
t.Errorf("env[%s] -> user_config[%q]; convention requires user_config[%q]", envName, key, got)
}
}
envValues := make(map[string]bool, len(m.Server.MCPConfig.Env))
for _, value := range m.Server.MCPConfig.Env {
if key, ok := parseUserConfigRef(value); ok {
envValues[key] = true
}
}
for key := range m.UserConfig {
if !envValues[key] {
t.Errorf("user_config[%q] is declared but never substituted into env", key)
}
}
}
func TestUserConfigShape(t *testing.T) {
m := loadManifest(t)
for key, slot := range m.UserConfig {
if slot.Type != "string" {
t.Errorf("user_config[%q].type = %q, want string", key, slot.Type)
}
if slot.Title == "" {
t.Errorf("user_config[%q].title is empty", key)
}
if slot.Description == "" {
t.Errorf("user_config[%q].description is empty", key)
}
if !slot.Sensitive {
// API keys must be flagged sensitive so Claude Desktop masks
// the input and prefers OS-keychain storage.
t.Errorf("user_config[%q].sensitive = false; want true for API credentials", key)
}
if slot.Required {
// The engine degrades to web-only mode without keys, so no
// key is install-blocking.
t.Errorf("user_config[%q].required = true; engine degrades without keys, so all keys are optional", key)
}
}
}
func TestPlatformsMatchShippingMatrix(t *testing.T) {
// compatibility.platforms must list exactly what the release CI
// actually packages. Listing a platform we don't ship would let
// Claude Desktop start an install that has no matching binary inside
// the bundle, producing a silent failure. The CI matrix in
// .github/workflows/release.yml currently covers darwin (arm64 +
// amd64) and linux/amd64; Windows is deferred.
m := loadManifest(t)
required := map[string]bool{"darwin": false, "linux": false}
forbidden := map[string]bool{"win32": true}
for _, p := range m.Compatibility.Platforms {
if _, ok := required[p]; ok {
required[p] = true
}
if forbidden[p] {
t.Errorf("compatibility.platforms contains %q but the release matrix does not ship that platform; add it to the matrix or remove from the manifest", p)
}
}
for p, found := range required {
if !found {
t.Errorf("compatibility.platforms missing %q", p)
}
}
}
func parseUserConfigRef(value string) (string, bool) {
const prefix = "${user_config."
const suffix = "}"
if !strings.HasPrefix(value, prefix) || !strings.HasSuffix(value, suffix) {
return "", false
}
return value[len(prefix) : len(value)-len(suffix)], true
}
+146
View File
@@ -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()
}
+145
View File
@@ -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")
}
}
+151
View File
@@ -0,0 +1,151 @@
{
"manifest_version": "0.3",
"name": "last30days-pp-mcp",
"display_name": "Last30Days",
"version": "3.0.0",
"description": "Research any topic across Reddit, X, YouTube, Hacker News, Polymarket, GitHub, and the web - last 30 days, scored by upvotes, likes, and real-money prediction-market odds.",
"author": {
"name": "Matt Van Horn",
"url": "https://github.com/mvanhorn/last30days-skill"
},
"repository": {
"type": "git",
"url": "https://github.com/mvanhorn/last30days-skill"
},
"license": "MIT",
"keywords": [
"research",
"reddit",
"twitter",
"x",
"youtube",
"hacker-news",
"polymarket",
"github",
"search",
"synthesis"
],
"server": {
"type": "binary",
"entry_point": "bin/last30days-pp-mcp",
"mcp_config": {
"command": "${__dirname}/bin/last30days-pp-mcp",
"args": [],
"env": {
"OPENAI_API_KEY": "${user_config.openai_api_key}",
"XAI_API_KEY": "${user_config.xai_api_key}",
"BRAVE_API_KEY": "${user_config.brave_api_key}",
"EXA_API_KEY": "${user_config.exa_api_key}",
"SERPER_API_KEY": "${user_config.serper_api_key}",
"GOOGLE_API_KEY": "${user_config.google_api_key}",
"GEMINI_API_KEY": "${user_config.gemini_api_key}",
"GOOGLE_GENAI_API_KEY": "${user_config.google_genai_api_key}",
"APIFY_API_TOKEN": "${user_config.apify_api_token}",
"BSKY_APP_PASSWORD": "${user_config.bsky_app_password}",
"PARALLEL_API_KEY": "${user_config.parallel_api_key}",
"SCRAPECREATORS_API_KEY": "${user_config.scrapecreators_api_key}",
"OPENROUTER_API_KEY": "${user_config.openrouter_api_key}"
}
}
},
"user_config": {
"openai_api_key": {
"type": "string",
"title": "OPENAI_API_KEY",
"description": "OpenAI API key. Powers Reddit research via OpenAI's web_search tool. Get one at https://platform.openai.com/api-keys.",
"sensitive": true,
"required": false
},
"xai_api_key": {
"type": "string",
"title": "XAI_API_KEY",
"description": "xAI API key. Powers X / Twitter research via xAI's x_search tool. Get one at https://console.x.ai/.",
"sensitive": true,
"required": false
},
"brave_api_key": {
"type": "string",
"title": "BRAVE_API_KEY",
"description": "Brave Search API key. Used for grounded web search results. Get one at https://brave.com/search/api/.",
"sensitive": true,
"required": false
},
"exa_api_key": {
"type": "string",
"title": "EXA_API_KEY",
"description": "Exa search API key. Alternative web search backend with semantic ranking. Get one at https://exa.ai/.",
"sensitive": true,
"required": false
},
"serper_api_key": {
"type": "string",
"title": "SERPER_API_KEY",
"description": "Serper API key. Google search via API. Get one at https://serper.dev/.",
"sensitive": true,
"required": false
},
"google_api_key": {
"type": "string",
"title": "GOOGLE_API_KEY",
"description": "Google API key for YouTube transcript fetching and other Google services. Get one at https://console.cloud.google.com/apis/credentials.",
"sensitive": true,
"required": false
},
"gemini_api_key": {
"type": "string",
"title": "GEMINI_API_KEY",
"description": "Gemini API key. Used for synthesis fallback when other LLM providers are unavailable. Get one at https://aistudio.google.com/apikey.",
"sensitive": true,
"required": false
},
"google_genai_api_key": {
"type": "string",
"title": "GOOGLE_GENAI_API_KEY",
"description": "Alternative Google generative-AI API key. Same source as GEMINI_API_KEY; set whichever name your tooling expects.",
"sensitive": true,
"required": false
},
"apify_api_token": {
"type": "string",
"title": "APIFY_API_TOKEN",
"description": "Apify API token. Powers TikTok and Instagram Reels search via Apify actors. Get one at https://console.apify.com/account/integrations.",
"sensitive": true,
"required": false
},
"bsky_app_password": {
"type": "string",
"title": "BSKY_APP_PASSWORD",
"description": "Bluesky app password (not your main password). Powers AT Protocol post search. Create at https://bsky.app/settings/app-passwords.",
"sensitive": true,
"required": false
},
"parallel_api_key": {
"type": "string",
"title": "PARALLEL_API_KEY",
"description": "Parallel AI key. Powers parallel research runs across sources. Get one at https://parallel.ai/.",
"sensitive": true,
"required": false
},
"scrapecreators_api_key": {
"type": "string",
"title": "SCRAPECREATORS_API_KEY",
"description": "ScrapeCreators API key. Powers creator-focused social search across TikTok, Instagram, and YouTube. Get one at https://scrapecreators.com/.",
"sensitive": true,
"required": false
},
"openrouter_api_key": {
"type": "string",
"title": "OPENROUTER_API_KEY",
"description": "OpenRouter API key. Alternative LLM provider gateway for synthesis. Get one at https://openrouter.ai/keys.",
"sensitive": true,
"required": false
}
},
"compatibility": {
"claude_desktop": ">=1.0.0",
"platforms": [
"darwin",
"linux"
]
}
}
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Mirrors skills/last30days/scripts/{last30days.py,lib/} into mcp/vendored/
# so the Go binary's embed.FS captures the engine at build time.
#
# Source of truth: skills/last30days/scripts/. Never edit mcp/vendored/ directly.
# Run before `go build` locally and in CI before `printing-press bundle`.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MCP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
REPO_ROOT="$(cd "${MCP_DIR}/.." && pwd)"
ENGINE_SRC="${REPO_ROOT}/skills/last30days/scripts"
# Embed path must live inside the consuming package (Go //go:embed cannot
# reach outside its own directory tree), so vendored/ sits under engine/.
VENDORED="${MCP_DIR}/internal/engine/vendored"
if [ ! -f "${ENGINE_SRC}/last30days.py" ]; then
echo "sync-engine: ${ENGINE_SRC}/last30days.py not found" >&2
exit 1
fi
mkdir -p "${VENDORED}"
# Clear stale content while keeping the .gitkeep that anchors the embed path.
find "${VENDORED}" -mindepth 1 -not -name ".gitkeep" -delete
# Copy the entry script and the lib/ tree (modules + lib/vendor/).
cp "${ENGINE_SRC}/last30days.py" "${VENDORED}/last30days.py"
cp -R "${ENGINE_SRC}/lib" "${VENDORED}/lib"
# Strip caches so the embed.FS stays deterministic.
find "${VENDORED}" -type d -name "__pycache__" -prune -exec rm -rf {} +
find "${VENDORED}" -type f -name "*.pyc" -delete
echo "sync-engine: vendored engine at ${VENDORED}"
+13 -2
View File
@@ -49,11 +49,22 @@ class TestPluginContract(unittest.TestCase):
self.assertIn("description", marketplace["metadata"]) self.assertIn("description", marketplace["metadata"])
def test_workflows_do_not_reference_removed_root_scripts_dir(self) -> None: def test_workflows_do_not_reference_removed_root_scripts_dir(self) -> None:
# The root-level scripts/ directory was removed; workflows must not
# reference it. Subdirectory scripts/ paths (skills/last30days/scripts/
# for the Code-skill build, mcp/scripts/ for the .mcpb build) are
# the legitimate replacements.
allowed_prefixes = (
"skills/last30days/scripts/",
"mcp/scripts/",
)
offenders = [] offenders = []
for path in sorted((ROOT / ".github" / "workflows").glob("*.yml")): for path in sorted((ROOT / ".github" / "workflows").glob("*.yml")):
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
if "scripts/" in line and "skills/last30days/scripts/" not in line: if "scripts/" not in line:
offenders.append(f"{path.relative_to(ROOT)}:{line_number}: {line.strip()}") continue
if any(prefix in line for prefix in allowed_prefixes):
continue
offenders.append(f"{path.relative_to(ROOT)}:{line_number}: {line.strip()}")
self.assertEqual([], offenders) self.assertEqual([], offenders)