| 1 | package installsource |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "path/filepath" |
| 7 | "sort" |
| 8 | "strings" |
| 9 | |
| 10 | "reasonix/internal/config" |
| 11 | fileencoding "reasonix/internal/fileutil/encoding" |
| 12 | ) |
| 13 | |
| 14 | // mcpEntryAction assembles the DTO for a single MCP server install. The |
| 15 | // caller decides whether apply=true actually runs cfg.UpsertPlugin + |
| 16 | // SaveTo + connectMCP. |
| 17 | func (t *installSourceTool) mcpEntryAction(req request, e config.PluginEntry, source string) action { |
| 18 | scope := t.installScope(req, "mcp", source) |
| 19 | // install_source is an explicit user action. Preserve that provenance for |
| 20 | // the live connector so a newly installed server is usable immediately, |
| 21 | // while still identifying project-scoped persistence accurately enough for |
| 22 | // the host to record the exact durable launch grant used on the next boot. |
| 23 | if scope == "project" { |
| 24 | e.Source = config.MCPSourceProjectConfig |
| 25 | } else { |
| 26 | e.Source = config.MCPSourceUserConfig |
| 27 | } |
| 28 | var normalizedCommand bool |
| 29 | e, normalizedCommand = config.NormalizePluginCommandLine(e) |
| 30 | // Tier comes from the call (req.Tier) or the entry; either way we |
| 31 | // validate. An unrecognised tier is silently downgraded to "background" by |
| 32 | // normalizeTier — we capture the original value to surface a warning. |
| 33 | desired := firstNonEmpty(req.Tier, e.Tier) |
| 34 | norm, ok := normalizeTier(desired) |
| 35 | e.Tier = norm |
| 36 | a := action{ |
| 37 | Kind: "mcp", |
| 38 | Action: "install_mcp_server", |
| 39 | Name: e.Name, |
| 40 | Source: source, |
| 41 | ConfigPath: t.configPath(scope), |
| 42 | Scope: scope, |
| 43 | Transport: pluginTransport(e), |
| 44 | URL: e.URL, |
| 45 | Command: e.Command, |
| 46 | Args: e.Args, |
| 47 | Env: e.Env, |
| 48 | Headers: e.Headers, |
| 49 | entry: e, |
| 50 | } |
| 51 | if !ok && strings.TrimSpace(desired) != "" { |
| 52 | a.RiskReasons = append(a.RiskReasons, fmt.Sprintf("tier %q is unknown; treating as background", desired)) |
| 53 | } |
| 54 | if normalizedCommand { |
| 55 | a.RiskReasons = append(a.RiskReasons, "split a pasted MCP command line into command and args") |
| 56 | } |
| 57 | a.RiskLevel, a.RiskReasons = mcpActionRisk(e, a.RiskReasons) |
| 58 | return a |
| 59 | } |
| 60 | |
| 61 | // mcpActionRisk classifies a single MCP install. An entry with auth headers, |
| 62 | // an `eager` tier, or a package-name source is medium. An entry with auth |
| 63 | // material and an out-of-tree URL is high. |
| 64 | func mcpActionRisk(e config.PluginEntry, reasons []string) (RiskLevel, []string) { |
| 65 | level := RiskMedium |
| 66 | hasAuth := false |
| 67 | for k, v := range e.Headers { |
| 68 | if strings.EqualFold(k, "Authorization") || strings.Contains(strings.ToLower(v), "bearer") || strings.Contains(strings.ToLower(v), "token") { |
| 69 | hasAuth = true |
| 70 | reasons = append(reasons, "sends auth headers to "+e.URL) |
| 71 | } |
| 72 | } |
| 73 | if e.Tier == "eager" { |
| 74 | level = RiskHigh |
| 75 | reasons = append(reasons, "tier=eager blocks startup until the handshake completes") |
| 76 | } |
| 77 | if hasAuth && level == RiskMedium { |
| 78 | level = RiskHigh |
| 79 | } |
| 80 | return level, reasons |
| 81 | } |
| 82 | |
| 83 | // remoteMCPAction builds a server entry from a URL alone. The default |
| 84 | // transport is http unless the URL's path smells like SSE. |
| 85 | func (t *installSourceTool) remoteMCPAction(req request, sourceURL string) action { |
| 86 | transport := req.Transport |
| 87 | if transport == "" || transport == "auto" { |
| 88 | transport = "http" |
| 89 | if strings.Contains(strings.ToLower(sourceURL), "sse") { |
| 90 | transport = "sse" |
| 91 | } |
| 92 | } |
| 93 | name := strings.TrimSpace(req.Name) |
| 94 | if name == "" { |
| 95 | name = mcpNameFromURL(sourceURL) |
| 96 | } |
| 97 | e := config.PluginEntry{ |
| 98 | Name: name, |
| 99 | Type: transport, |
| 100 | URL: sourceURL, |
| 101 | Headers: cleanMap(req.Headers), |
| 102 | Tier: req.Tier, |
| 103 | } |
| 104 | return t.mcpEntryAction(req, e, sourceURL) |
| 105 | } |
| 106 | |
| 107 | // localExecutableMCPAction treats a chmod +x'd local file as a stdio MCP |
| 108 | // server. By default the file itself becomes the command; callers may override |
| 109 | // the command to wrap the source with an interpreter. |
| 110 | func (t *installSourceTool) localExecutableMCPAction(req request, path string) action { |
| 111 | name := strings.TrimSpace(req.Name) |
| 112 | if name == "" { |
| 113 | name = sanitizeName(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))) |
| 114 | } |
| 115 | command := strings.TrimSpace(req.Command) |
| 116 | if command == "" { |
| 117 | command = path |
| 118 | } |
| 119 | e := config.PluginEntry{ |
| 120 | Name: name, |
| 121 | Command: command, |
| 122 | Args: append([]string(nil), req.Args...), |
| 123 | Env: cleanMap(req.Env), |
| 124 | Tier: req.Tier, |
| 125 | } |
| 126 | return t.mcpEntryAction(req, e, path) |
| 127 | } |
| 128 | |
| 129 | // packageMCPAction treats the source as an npm package name and constructs |
| 130 | // the canonical `npx -y <pkg>` invocation. The caller can override the |
| 131 | // command and args for non-npm package sources. |
| 132 | func (t *installSourceTool) packageMCPAction(req request) action { |
| 133 | name := strings.TrimSpace(req.Name) |
| 134 | if name == "" { |
| 135 | name = sanitizeName(strings.TrimPrefix(req.Source, "@")) |
| 136 | if i := strings.LastIndex(name, "/"); i >= 0 { |
| 137 | name = name[i+1:] |
| 138 | } |
| 139 | } |
| 140 | command := strings.TrimSpace(req.Command) |
| 141 | args := append([]string(nil), req.Args...) |
| 142 | if command == "" { |
| 143 | command = "npx" |
| 144 | args = []string{"-y", req.Source} |
| 145 | } |
| 146 | e := config.PluginEntry{ |
| 147 | Name: name, |
| 148 | Command: command, |
| 149 | Args: args, |
| 150 | Env: cleanMap(req.Env), |
| 151 | Tier: req.Tier, |
| 152 | } |
| 153 | return t.mcpEntryAction(req, e, req.Source) |
| 154 | } |
| 155 | |
| 156 | // readMCPJSON reads a .mcp.json file from disk and parses it. It is a |
| 157 | // convenience wrapper used by planLocal; the parser itself is exported as |
| 158 | // parseMCPJSON for tests. |
| 159 | func readMCPJSON(path string) ([]config.PluginEntry, []string, error) { |
| 160 | b, err := fileencoding.ReadFileUTF8(path) |
| 161 | if err != nil { |
| 162 | return nil, nil, err |
| 163 | } |
| 164 | entries, warnings, err := parseMCPJSON(b) |
| 165 | if err != nil { |
| 166 | return nil, warnings, err |
| 167 | } |
| 168 | return entries, warnings, nil |
| 169 | } |
| 170 | |
| 171 | // parseMCPJSON extracts mcpServers entries from a .mcp.json-style document. |
| 172 | // Warnings are returned for non-fatal anomalies (unknown tier values) and |
| 173 | // collected separately so a typo does not refuse an otherwise valid file. |
| 174 | func parseMCPJSON(b []byte) ([]config.PluginEntry, []string, error) { |
| 175 | var raw struct { |
| 176 | MCPServers map[string]struct { |
| 177 | Type string `json:"type"` |
| 178 | Command string `json:"command"` |
| 179 | Args []string `json:"args"` |
| 180 | Env map[string]string `json:"env"` |
| 181 | URL string `json:"url"` |
| 182 | Headers map[string]string `json:"headers"` |
| 183 | AutoStart *bool `json:"auto_start"` |
| 184 | StartupTimeoutSeconds int `json:"startup_timeout_seconds"` |
| 185 | CallTimeoutSeconds int `json:"call_timeout_seconds"` |
| 186 | ToolTimeoutSeconds map[string]int `json:"tool_timeout_seconds"` |
| 187 | Tier string `json:"tier"` |
| 188 | } `json:"mcpServers"` |
| 189 | } |
| 190 | if err := json.Unmarshal(b, &raw); err != nil { |
| 191 | return nil, nil, newErr(ErrInvalidManifest, "could not parse .mcp.json: %v", err) |
| 192 | } |
| 193 | if len(raw.MCPServers) == 0 { |
| 194 | return nil, nil, newErr(ErrManifestMissing, ".mcp.json has no mcpServers") |
| 195 | } |
| 196 | names := make([]string, 0, len(raw.MCPServers)) |
| 197 | for name := range raw.MCPServers { |
| 198 | names = append(names, name) |
| 199 | } |
| 200 | sort.Strings(names) |
| 201 | out := make([]config.PluginEntry, 0, len(names)) |
| 202 | var warnings []string |
| 203 | for _, name := range names { |
| 204 | s := raw.MCPServers[name] |
| 205 | // Validate the raw transport before normalizeTransport gets a |
| 206 | // chance to silently map an unknown value to "auto"/"stdio". |
| 207 | rawType := strings.ToLower(strings.TrimSpace(s.Type)) |
| 208 | if rawType != "" && rawType != "stdio" && rawType != "http" && rawType != "sse" && rawType != "streamable-http" { |
| 209 | return nil, warnings, newErr(ErrInvalidManifest, "MCP server %q has unknown transport %q", name, s.Type) |
| 210 | } |
| 211 | typ := normalizeTransport(s.Type) |
| 212 | if typ == "auto" { |
| 213 | if strings.TrimSpace(s.URL) != "" { |
| 214 | typ = "http" |
| 215 | } else { |
| 216 | typ = "stdio" |
| 217 | } |
| 218 | } |
| 219 | tier, ok := normalizeTier(s.Tier) |
| 220 | if !ok && strings.TrimSpace(s.Tier) != "" { |
| 221 | warnings = append(warnings, fmt.Sprintf("%s: tier %q is unknown; treating as background", name, s.Tier)) |
| 222 | } |
| 223 | e := config.PluginEntry{ |
| 224 | Name: name, |
| 225 | Type: typ, |
| 226 | Command: strings.TrimSpace(s.Command), |
| 227 | Args: append([]string(nil), s.Args...), |
| 228 | Env: cleanMap(s.Env), |
| 229 | URL: strings.TrimSpace(s.URL), |
| 230 | Headers: cleanMap(s.Headers), |
| 231 | AutoStart: s.AutoStart, |
| 232 | StartupTimeoutSeconds: s.StartupTimeoutSeconds, |
| 233 | CallTimeoutSeconds: s.CallTimeoutSeconds, |
| 234 | ToolTimeoutSeconds: s.ToolTimeoutSeconds, |
| 235 | Tier: tier, |
| 236 | } |
| 237 | // An empty Type is the canonical "stdio" form; keep it that way so |
| 238 | // the rest of the config layer (UpsertPlugin / ShouldAutoStart) |
| 239 | // can treat "" and "stdio" identically. |
| 240 | if e.Type == "stdio" { |
| 241 | e.Type = "" |
| 242 | } |
| 243 | normalized, changed := config.NormalizePluginCommandLine(e) |
| 244 | e = normalized |
| 245 | if changed { |
| 246 | warnings = append(warnings, fmt.Sprintf("%s: split a pasted MCP command line into command and args", name)) |
| 247 | } |
| 248 | if err := validateMCPEntry(e); err != nil { |
| 249 | return nil, warnings, err |
| 250 | } |
| 251 | out = append(out, e) |
| 252 | } |
| 253 | return out, warnings, nil |
| 254 | } |
| 255 | |
| 256 | // validateMCPEntry enforces the per-transport required fields. It is the |
| 257 | // last line of defense before a server entry is persisted. |
| 258 | func validateMCPEntry(e config.PluginEntry) error { |
| 259 | name := strings.TrimSpace(e.Name) |
| 260 | if name == "" { |
| 261 | return newErr(ErrInvalidManifest, "MCP server name is required") |
| 262 | } |
| 263 | if !config.IsValidSkillName(name) { |
| 264 | return newErr(ErrInvalidManifest, "MCP server name %q is invalid; use letters, digits, '.', '_', or '-', starting with a letter or digit, up to 64 characters", e.Name) |
| 265 | } |
| 266 | // Reject explicit unknown transports. Without this check, normalizeTransport |
| 267 | // would silently map "carrier-pigeon" -> "auto" -> "stdio" and the entry |
| 268 | // would be persisted with the wrong effective type. |
| 269 | rawType := strings.ToLower(strings.TrimSpace(e.Type)) |
| 270 | if rawType != "" && rawType != "stdio" && rawType != "http" && rawType != "sse" { |
| 271 | return newErr(ErrInvalidManifest, "MCP server %q has unknown transport %q", e.Name, e.Type) |
| 272 | } |
| 273 | switch pluginTransport(e) { |
| 274 | case "stdio": |
| 275 | if strings.TrimSpace(e.Command) == "" { |
| 276 | return newErr(ErrInvalidManifest, "MCP server %q is stdio but has no command", e.Name) |
| 277 | } |
| 278 | case "http", "sse": |
| 279 | if strings.TrimSpace(e.URL) == "" { |
| 280 | return newErr(ErrInvalidManifest, "MCP server %q is remote but has no url", e.Name) |
| 281 | } |
| 282 | default: |
| 283 | return newErr(ErrInvalidManifest, "MCP server %q has unknown transport %q", e.Name, e.Type) |
| 284 | } |
| 285 | return nil |
| 286 | } |
| 287 |