272 lines
9.1 KiB
Go
272 lines
9.1 KiB
Go
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 <pkg>` 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
|
|
}
|