package installsource import ( "encoding/json" "fmt" "path/filepath" "sort" "strings" "reasonix/internal/config" fileencoding "reasonix/internal/fileutil/encoding" ) // mcpEntryAction assembles the DTO for a single MCP server install. The // caller decides whether apply=true actually runs cfg.UpsertPlugin + // SaveTo + connectMCP. func (t *installSourceTool) mcpEntryAction(req request, e config.PluginEntry, source string) action { scope := t.installScope(req, "mcp", source) var normalizedCommand bool e, normalizedCommand = config.NormalizePluginCommandLine(e) // Tier comes from the call (req.Tier) or the entry; either way we // validate. An unrecognised tier is silently downgraded to "background" by // normalizeTier — we capture the original value to surface a warning. desired := firstNonEmpty(req.Tier, e.Tier) norm, ok := normalizeTier(desired) e.Tier = norm a := action{ Kind: "mcp", Action: "install_mcp_server", Name: e.Name, Source: source, ConfigPath: t.configPath(scope), Scope: scope, Transport: pluginTransport(e), URL: e.URL, Command: e.Command, Args: e.Args, Env: e.Env, Headers: e.Headers, entry: e, } if !ok && strings.TrimSpace(desired) != "" { a.RiskReasons = append(a.RiskReasons, fmt.Sprintf("tier %q is unknown; treating as background", desired)) } if normalizedCommand { a.RiskReasons = append(a.RiskReasons, "split a pasted MCP command line into command and args") } a.RiskLevel, a.RiskReasons = mcpActionRisk(e, a.RiskReasons) return a } // mcpActionRisk classifies a single MCP install. An entry with auth headers, // an `eager` tier, or a package-name source is medium. An entry with auth // material and an out-of-tree URL is high. func mcpActionRisk(e config.PluginEntry, reasons []string) (RiskLevel, []string) { level := RiskMedium hasAuth := false for k, v := range e.Headers { if strings.EqualFold(k, "Authorization") || strings.Contains(strings.ToLower(v), "bearer") || strings.Contains(strings.ToLower(v), "token") { hasAuth = true reasons = append(reasons, "sends auth headers to "+e.URL) } } if e.Tier == "eager" { level = RiskHigh reasons = append(reasons, "tier=eager blocks startup until the handshake completes") } if hasAuth && level == RiskMedium { level = RiskHigh } return level, reasons } // remoteMCPAction builds a server entry from a URL alone. The default // transport is http unless the URL's path smells like SSE. func (t *installSourceTool) remoteMCPAction(req request, sourceURL string) action { transport := req.Transport if transport == "" || transport == "auto" { transport = "http" if strings.Contains(strings.ToLower(sourceURL), "sse") { transport = "sse" } } name := strings.TrimSpace(req.Name) if name == "" { name = mcpNameFromURL(sourceURL) } e := config.PluginEntry{ Name: name, Type: transport, URL: sourceURL, Headers: cleanMap(req.Headers), Tier: req.Tier, } return t.mcpEntryAction(req, e, sourceURL) } // localExecutableMCPAction treats a chmod +x'd local file as a stdio MCP // server. By default the file itself becomes the command; callers may override // the command to wrap the source with an interpreter. func (t *installSourceTool) localExecutableMCPAction(req request, path string) action { name := strings.TrimSpace(req.Name) if name == "" { name = sanitizeName(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))) } command := strings.TrimSpace(req.Command) if command == "" { command = path } e := config.PluginEntry{ Name: name, Command: command, Args: append([]string(nil), req.Args...), Env: cleanMap(req.Env), Tier: req.Tier, } return t.mcpEntryAction(req, e, path) } // packageMCPAction treats the source as an npm package name and constructs // the canonical `npx -y ` invocation. The caller can override the // command and args for non-npm package sources. func (t *installSourceTool) packageMCPAction(req request) action { name := strings.TrimSpace(req.Name) if name == "" { name = sanitizeName(strings.TrimPrefix(req.Source, "@")) if i := strings.LastIndex(name, "/"); i >= 0 { name = name[i+1:] } } command := strings.TrimSpace(req.Command) args := append([]string(nil), req.Args...) if command == "" { command = "npx" args = []string{"-y", req.Source} } e := config.PluginEntry{ Name: name, Command: command, Args: args, Env: cleanMap(req.Env), Tier: req.Tier, } return t.mcpEntryAction(req, e, req.Source) } // readMCPJSON reads a .mcp.json file from disk and parses it. It is a // convenience wrapper used by planLocal; the parser itself is exported as // parseMCPJSON for tests. func readMCPJSON(path string) ([]config.PluginEntry, []string, error) { b, err := fileencoding.ReadFileUTF8(path) if err != nil { return nil, nil, err } entries, warnings, err := parseMCPJSON(b) if err != nil { return nil, warnings, err } return entries, warnings, nil } // parseMCPJSON extracts mcpServers entries from a .mcp.json-style document. // Warnings are returned for non-fatal anomalies (unknown tier values) and // collected separately so a typo does not refuse an otherwise valid file. func parseMCPJSON(b []byte) ([]config.PluginEntry, []string, error) { var raw struct { MCPServers map[string]struct { Type string `json:"type"` Command string `json:"command"` Args []string `json:"args"` Env map[string]string `json:"env"` URL string `json:"url"` Headers map[string]string `json:"headers"` AutoStart *bool `json:"auto_start"` Tier string `json:"tier"` } `json:"mcpServers"` } if err := json.Unmarshal(b, &raw); err != nil { return nil, nil, newErr(ErrInvalidManifest, "could not parse .mcp.json: %v", err) } if len(raw.MCPServers) == 0 { return nil, nil, newErr(ErrManifestMissing, ".mcp.json has no mcpServers") } names := make([]string, 0, len(raw.MCPServers)) for name := range raw.MCPServers { names = append(names, name) } sort.Strings(names) out := make([]config.PluginEntry, 0, len(names)) var warnings []string for _, name := range names { s := raw.MCPServers[name] // Validate the raw transport before normalizeTransport gets a // chance to silently map an unknown value to "auto"/"stdio". rawType := strings.ToLower(strings.TrimSpace(s.Type)) if rawType != "" && rawType != "stdio" && rawType != "http" && rawType != "sse" && rawType != "streamable-http" { return nil, warnings, newErr(ErrInvalidManifest, "MCP server %q has unknown transport %q", name, s.Type) } typ := normalizeTransport(s.Type) if typ == "auto" { if strings.TrimSpace(s.URL) != "" { typ = "http" } else { typ = "stdio" } } tier, ok := normalizeTier(s.Tier) if !ok && strings.TrimSpace(s.Tier) != "" { warnings = append(warnings, fmt.Sprintf("%s: tier %q is unknown; treating as background", name, s.Tier)) } e := config.PluginEntry{ Name: name, Type: typ, Command: strings.TrimSpace(s.Command), Args: append([]string(nil), s.Args...), Env: cleanMap(s.Env), URL: strings.TrimSpace(s.URL), Headers: cleanMap(s.Headers), AutoStart: s.AutoStart, Tier: tier, } // An empty Type is the canonical "stdio" form; keep it that way so // the rest of the config layer (UpsertPlugin / ShouldAutoStart) // can treat "" and "stdio" identically. if e.Type == "stdio" { e.Type = "" } normalized, changed := config.NormalizePluginCommandLine(e) e = normalized if changed { warnings = append(warnings, fmt.Sprintf("%s: split a pasted MCP command line into command and args", name)) } if err := validateMCPEntry(e); err != nil { return nil, warnings, err } out = append(out, e) } return out, warnings, nil } // validateMCPEntry enforces the per-transport required fields. It is the // last line of defense before a server entry is persisted. func validateMCPEntry(e config.PluginEntry) error { name := strings.TrimSpace(e.Name) if name == "" { return newErr(ErrInvalidManifest, "MCP server name is required") } if !config.IsValidSkillName(name) { 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) } // Reject explicit unknown transports. Without this check, normalizeTransport // would silently map "carrier-pigeon" -> "auto" -> "stdio" and the entry // would be persisted with the wrong effective type. rawType := strings.ToLower(strings.TrimSpace(e.Type)) if rawType != "" && rawType != "stdio" && rawType != "http" && rawType != "sse" { return newErr(ErrInvalidManifest, "MCP server %q has unknown transport %q", e.Name, e.Type) } switch pluginTransport(e) { case "stdio": if strings.TrimSpace(e.Command) == "" { return newErr(ErrInvalidManifest, "MCP server %q is stdio but has no command", e.Name) } case "http", "sse": if strings.TrimSpace(e.URL) == "" { return newErr(ErrInvalidManifest, "MCP server %q is remote but has no url", e.Name) } default: return newErr(ErrInvalidManifest, "MCP server %q has unknown transport %q", e.Name, e.Type) } return nil }