返回 last30days-skill
manifest_test.go
根目录 / mcp / internal / manifest / manifest_test.go
1 // Package manifest holds tests for mcp/manifest.json. It contains no
2 // production code - the manifest itself is the artifact, and these tests
3 // guard structural invariants the bundling pipeline depends on.
4 package manifest
5
6 import (
7 "encoding/json"
8 "os"
9 "path/filepath"
10 "runtime"
11 "strings"
12 "testing"
13 )
14
15 // envBinding is a minimal subset of the MCPB v0.3 manifest just covering
16 // the fields these tests assert on. We deliberately do not depend on the
17 // printing-press internal/pipeline types (that's an internal/ package and
18 // not importable across modules) - the structural invariants below are
19 // what actually matter for Claude Desktop install correctness.
20 type manifestShape struct {
21 ManifestVersion string `json:"manifest_version"`
22 Name string `json:"name"`
23 Version string `json:"version"`
24 Server struct {
25 Type string `json:"type"`
26 EntryPoint string `json:"entry_point"`
27 MCPConfig struct {
28 Command string `json:"command"`
29 Env map[string]string `json:"env"`
30 } `json:"mcp_config"`
31 } `json:"server"`
32 UserConfig map[string]struct {
33 Type string `json:"type"`
34 Title string `json:"title"`
35 Description string `json:"description"`
36 Sensitive bool `json:"sensitive"`
37 Required bool `json:"required"`
38 } `json:"user_config"`
39 Compatibility struct {
40 ClaudeDesktop string `json:"claude_desktop"`
41 Platforms []string `json:"platforms"`
42 } `json:"compatibility"`
43 }
44
45 // loadManifest reads mcp/manifest.json relative to this test file so the
46 // test passes regardless of where `go test` is invoked from.
47 func loadManifest(t *testing.T) manifestShape {
48 t.Helper()
49 _, thisFile, _, ok := runtime.Caller(0)
50 if !ok {
51 t.Fatal("runtime.Caller failed")
52 }
53 // manifest_test.go is at mcp/internal/manifest/; manifest.json at mcp/.
54 manifestPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "manifest.json")
55 data, err := os.ReadFile(manifestPath)
56 if err != nil {
57 t.Fatalf("read manifest: %v", err)
58 }
59 var m manifestShape
60 if err := json.Unmarshal(data, &m); err != nil {
61 t.Fatalf("parse manifest: %v", err)
62 }
63 return m
64 }
65
66 func TestManifestRequiredFields(t *testing.T) {
67 m := loadManifest(t)
68 if m.ManifestVersion != "0.3" {
69 t.Errorf("manifest_version = %q, want 0.3", m.ManifestVersion)
70 }
71 if m.Name != "last30days-pp-mcp" {
72 t.Errorf("name = %q, want last30days-pp-mcp", m.Name)
73 }
74 if m.Version == "" {
75 t.Error("version is empty")
76 }
77 if m.Server.Type != "binary" {
78 t.Errorf("server.type = %q, want binary", m.Server.Type)
79 }
80 if m.Server.EntryPoint != "bin/last30days-pp-mcp" {
81 t.Errorf("server.entry_point = %q, want bin/last30days-pp-mcp", m.Server.EntryPoint)
82 }
83 if m.Compatibility.ClaudeDesktop == "" {
84 t.Error("compatibility.claude_desktop is empty")
85 }
86 }
87
88 // TestEnvAndUserConfigCrossReference is the key invariant: every
89 // ${user_config.<key>} substitution in server.mcp_config.env must point
90 // at a real user_config entry, and every declared user_config must be
91 // wired to an env var. A typo on either side silently disables a credential
92 // at install time without the binary or Claude Desktop noticing.
93 func TestEnvAndUserConfigCrossReference(t *testing.T) {
94 m := loadManifest(t)
95
96 if len(m.Server.MCPConfig.Env) == 0 {
97 t.Fatal("server.mcp_config.env is empty; expected user_config substitutions")
98 }
99 if len(m.UserConfig) == 0 {
100 t.Fatal("user_config is empty; expected per-key declarations")
101 }
102
103 for envName, value := range m.Server.MCPConfig.Env {
104 key, ok := parseUserConfigRef(value)
105 if !ok {
106 t.Errorf("env[%s] = %q is not a ${user_config.<key>} reference", envName, value)
107 continue
108 }
109 if _, declared := m.UserConfig[key]; !declared {
110 t.Errorf("env[%s] references user_config[%q], which is not declared", envName, key)
111 }
112 // The user_config key must be the lowercased env var so Claude
113 // Desktop's substitution rule matches PP's emitted shape.
114 if got := strings.ToLower(envName); key != got {
115 t.Errorf("env[%s] -> user_config[%q]; convention requires user_config[%q]", envName, key, got)
116 }
117 }
118
119 envValues := make(map[string]bool, len(m.Server.MCPConfig.Env))
120 for _, value := range m.Server.MCPConfig.Env {
121 if key, ok := parseUserConfigRef(value); ok {
122 envValues[key] = true
123 }
124 }
125 for key := range m.UserConfig {
126 if !envValues[key] {
127 t.Errorf("user_config[%q] is declared but never substituted into env", key)
128 }
129 }
130 }
131
132 func TestUserConfigShape(t *testing.T) {
133 m := loadManifest(t)
134 for key, slot := range m.UserConfig {
135 if slot.Type != "string" {
136 t.Errorf("user_config[%q].type = %q, want string", key, slot.Type)
137 }
138 if slot.Title == "" {
139 t.Errorf("user_config[%q].title is empty", key)
140 }
141 if slot.Description == "" {
142 t.Errorf("user_config[%q].description is empty", key)
143 }
144 if !slot.Sensitive {
145 // API keys must be flagged sensitive so Claude Desktop masks
146 // the input and prefers OS-keychain storage.
147 t.Errorf("user_config[%q].sensitive = false; want true for API credentials", key)
148 }
149 if slot.Required {
150 // The engine degrades to web-only mode without keys, so no
151 // key is install-blocking.
152 t.Errorf("user_config[%q].required = true; engine degrades without keys, so all keys are optional", key)
153 }
154 }
155 }
156
157 func TestPlatformsMatchShippingMatrix(t *testing.T) {
158 // compatibility.platforms must list exactly what the release CI
159 // actually packages. Listing a platform we don't ship would let
160 // Claude Desktop start an install that has no matching binary inside
161 // the bundle, producing a silent failure. The CI matrix in
162 // .github/workflows/release.yml currently covers darwin (arm64 +
163 // amd64) and linux/amd64; Windows is deferred.
164 m := loadManifest(t)
165 required := map[string]bool{"darwin": false, "linux": false}
166 forbidden := map[string]bool{"win32": true}
167 for _, p := range m.Compatibility.Platforms {
168 if _, ok := required[p]; ok {
169 required[p] = true
170 }
171 if forbidden[p] {
172 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)
173 }
174 }
175 for p, found := range required {
176 if !found {
177 t.Errorf("compatibility.platforms missing %q", p)
178 }
179 }
180 }
181
182 func parseUserConfigRef(value string) (string, bool) {
183 const prefix = "${user_config."
184 const suffix = "}"
185 if !strings.HasPrefix(value, prefix) || !strings.HasSuffix(value, suffix) {
186 return "", false
187 }
188 return value[len(prefix) : len(value)-len(suffix)], true
189 }
190
190 lines GO