diff --git a/mcp/internal/manifest/manifest_test.go b/mcp/internal/manifest/manifest_test.go new file mode 100644 index 0000000..69a3bb7 --- /dev/null +++ b/mcp/internal/manifest/manifest_test.go @@ -0,0 +1,179 @@ +// 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.} 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.} 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 TestPlatformsCoverDesktopTargets(t *testing.T) { + m := loadManifest(t) + want := map[string]bool{"darwin": false, "linux": false, "win32": false} + for _, p := range m.Compatibility.Platforms { + if _, expected := want[p]; expected { + want[p] = true + } + } + for p, found := range want { + 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 +} diff --git a/mcp/manifest.json b/mcp/manifest.json new file mode 100644 index 0000000..63b8065 --- /dev/null +++ b/mcp/manifest.json @@ -0,0 +1,152 @@ +{ + "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", + "win32" + ] + } +}