// Package installsource: install_source.go is the tool entrypoint. It // defines the public Options/Execute surface, the JSON Schema, and the // end-to-end pipeline that turns a request into a plan and (optionally) // into a series of apply calls. package installsource import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "os" "path/filepath" "sort" "strings" "reasonix/internal/config" "reasonix/internal/pluginpkg" "reasonix/internal/skill" "reasonix/internal/tool" ) // MCPConnectResult is what the ConnectMCP callback returns. Disconnect is // optional; when non-nil, the apply step will call it to undo a connect // whose persistence (SaveTo) failed — closing the "ghost install" window. type MCPConnectResult struct { ToolCount int Disconnect func() // optional; nil means rollback is not possible } // MCPConnector is the host-provided hook that turns a PluginEntry into a // live MCP connection. The returned Disconnect, if any, is used by the // install_source tool to roll back a failed persistence step. type MCPConnector func(config.PluginEntry) (MCPConnectResult, error) // ApprovalFunc is invoked between plan and apply when apply=true. Return // nil to allow the install, or a non-nil error to refuse it. The action // list reflects the exact set the apply step is about to perform; a host // (e.g. the desktop TUI) can show it to the user and decide synchronously. type ApprovalFunc func(actions []action) error // OnDisconnectFunc tells the host to remove a server from the live session and // drop the corresponding mcp____ tools from its Registry. It returns true // when a live server was actually removed, letting replace/rollback restore the // old connection only when there was one. type OnDisconnectFunc func(serverName string) bool // Options configure the install_source tool. ProjectRoot "" and HomeDir // "" fall back to os.Getwd / os.UserHomeDir at construction time. type Options struct { ProjectRoot string HomeDir string HTTPClient *http.Client ConnectMCP MCPConnector OnDisconnect OnDisconnectFunc Approval ApprovalFunc } type installSourceTool struct { root string home string reasonixHome string httpClient *http.Client connectMCP MCPConnector onDisconnect OnDisconnectFunc approval ApprovalFunc // preparePlugin overrides plugin source preparation in tests. nil uses // preparePluginSource. Plan and apply both resolve the source through the // same function, and git sources additionally report the resolved commit, // so the capability set the approval covers is by construction the one // that gets installed (apply pins the approved commit on divergence). preparePlugin func(ctx context.Context, source, mode string) (root, commit string, cleanup func(), err error) } // NewTool returns a tool.Tool that callers register with the agent's // Registry. The returned tool is safe to call from any goroutine; the // underlying config/config.SaveTo paths do their own per-file locking. func NewTool(opts Options) tool.Tool { root := opts.ProjectRoot if root == "" { if wd, err := currentDir(); err == nil { root = wd } } if abs, err := filepath.Abs(root); err == nil { root = abs } home := opts.HomeDir if home == "" { if h, err := userHomeDir(); err == nil { home = h } } reasonixHome := "" if opts.HomeDir != "" { reasonixHome = filepath.Join(home, ".reasonix") } else if dir := config.ReasonixHomeDir(); dir != "" { reasonixHome = dir } else if home != "" { reasonixHome = filepath.Join(home, ".reasonix") } client := opts.HTTPClient if client == nil { client = &http.Client{} } // install_source fetches untrusted URLs (SKILL.md, .mcp.json, GitHub // manifests); guard the dial against SSRF the same way web_fetch does, so a // prompt-injected source can't reach cloud metadata / internal services. client = ssrfGuardClient(client) return &installSourceTool{ root: root, home: home, reasonixHome: reasonixHome, httpClient: client, connectMCP: opts.ConnectMCP, onDisconnect: opts.OnDisconnect, approval: opts.Approval, } } func (*installSourceTool) Name() string { return "install_source" } func (*installSourceTool) ReadOnly() bool { return false } func (*installSourceTool) Description() string { return "Plan, install, or uninstall a Reasonix skill, MCP server, or plugin package from a URL, local file/folder, .mcp.json, executable, or package name. Two-phase: with apply=false (default) returns a deterministic plan with per-action risk level; with apply=true copies/registers skills, connects/persists MCP servers, or installs plugin packages after validation. op='uninstall' removes a previously installed skill, MCP server, or plugin package by name." } func (*installSourceTool) Schema() json.RawMessage { return json.RawMessage(`{ "type":"object", "properties":{ "op":{"type":"string","enum":["install","uninstall"],"description":"Whether to install (default) or uninstall."}, "source":{"type":"string","description":"URL, local file/folder path, .mcp.json path, or package name to install from. Ignored when op=uninstall (use name instead)."}, "kind":{"type":"string","enum":["auto","skill","mcp","plugin"],"description":"Capability kind. Defaults to auto."}, "apply":{"type":"boolean","description":"false (default) only returns an install plan; true performs the planned writes/connects. Ignored for op=uninstall."}, "scope":{"type":"string","enum":["project","global"],"description":"Where to persist config or copy skills. MCP installs default to global so every project can use them; project-root .mcp.json imports default to project; skills default to project when a workspace exists, otherwise global."}, "mode":{"type":"string","enum":["auto","copy","link","register"],"description":"Skill install mode. auto registers multi-skill roots and copies single skills into the canonical /SKILL.md layout; copy copies skill files/folders; link creates symlinks; register adds a skill root to [skills].paths."}, "name":{"type":"string","description":"Optional override for the installed MCP server or single skill name. Required for op=uninstall when removing by name."}, "transport":{"type":"string","enum":["auto","stdio","http","sse"],"description":"MCP transport override. URL sources default to http unless --sse-like; package sources default to stdio."}, "command":{"type":"string","description":"Optional stdio MCP command override for package/local executable installs."}, "args":{"type":"array","items":{"type":"string"},"description":"Optional stdio MCP args override."}, "env":{"type":"object","additionalProperties":{"type":"string"},"description":"Environment variables for stdio MCP servers."}, "headers":{"type":"object","additionalProperties":{"type":"string"},"description":"HTTP headers for remote MCP servers. Prefer ${VAR} placeholders for secrets."}, "tier":{"type":"string","enum":["background","eager"],"description":"Persisted MCP startup tier. Defaults to background."}, "replace":{"type":"boolean","description":"Allow replacing an existing MCP config entry with the same name. Skills still refuse to overwrite existing files."}, "strict":{"type":"boolean","description":"Skill install strictness. true (default) requires name+description frontmatter; false copies the file as-is (use only for files you trust)."}, "planId":{"type":"string","description":"Optional. Echoed from a previous planned response to confirm the host is approving the same plan."} }, "required":[] }`) } // Execute parses args, plans, and (if apply=true and Approval allows) // performs the writes. JSON output is always returned on success even when // the plan is empty, so the model can read structured `next` hints. func (t *installSourceTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { var req request if err := json.Unmarshal(raw, &req); err != nil { return "", fmt.Errorf("install_source: invalid args: %w", err) } req.Source = strings.TrimSpace(req.Source) if req.Op == "" { req.Op = "install" } if req.Op != "install" && req.Op != "uninstall" { return "", fmt.Errorf("install_source: op %q is not supported (want install|uninstall)", req.Op) } if req.Op == "install" && req.Source == "" { return "", errors.New("install_source requires a non-empty source") } if req.Op == "uninstall" && strings.TrimSpace(req.Name) == "" { return "", errors.New("install_source: op=uninstall requires a non-empty name") } req.Kind = normalizeKind(req.Kind) req.Scope, req.scopeExplicit = t.normalizeScope(req.Scope) req.Mode = normalizeMode(req.Mode) req.Transport = normalizeTransport(req.Transport) if norm, ok := normalizeTier(req.Tier); ok { req.Tier = norm } if req.Op == "uninstall" { return t.executeUninstall(req), nil } actions, warnings, err := t.plan(ctx, req) if err != nil { return "", err } planID := computePlanID(req, actions) if len(actions) == 0 { out := response{ OK: false, Status: "blocked", Op: req.Op, Applied: false, Source: req.Source, Kind: "", Scope: req.Scope, Mode: req.Mode, PlanID: planID, Warnings: warnings, Next: "No installable Reasonix skill, MCP server, or plugin package was detected. Ask the user for a direct SKILL.md, skill root, .mcp.json, plugin manifest, MCP endpoint, or package name.", } return marshalJSON(out), nil } if !req.Apply { for i := range actions { actions[i].Status = "planned" } scope := commonActionScope(actions) out := response{ OK: true, Status: "planned", Op: req.Op, Applied: false, Source: req.Source, Kind: summarizeKind(actions), Kinds: kindCounts(actions), Scope: scope, Mode: req.Mode, PlanID: planID, Actions: publicActions(actions), Warnings: warnings, Next: "Review the plan (especially each action's riskLevel). Call install_source again with apply=true and the same planId to install.", } return marshalJSON(out), nil } if req.PlanID != "" && req.PlanID != planID { return "", newErr(ErrApprovalDenied, "planId mismatch (got %s, expected %s); re-plan and re-approve", req.PlanID, planID) } if t.approval != nil { if err := t.approval(publicActions(actions)); err != nil { return marshalJSON(response{ OK: false, Status: "denied", Op: req.Op, Applied: false, Source: req.Source, Kind: summarizeKind(actions), Kinds: kindCounts(actions), Scope: req.Scope, Mode: req.Mode, PlanID: planID, Actions: publicActions(actions), Warnings: append(warnings, "host approval was denied: "+err.Error()), Next: "Ask the user to confirm, or run with a less risky plan (e.g. lower scope, fewer actions).", }), nil } } return t.executeApply(ctx, req, actions, warnings, planID), nil } // executeApply runs the apply phase. The first failed action short-circuits // the rest only when a single failure implies the plan is unusable; for // MCP installs in particular, partial completion is reported honestly. func (t *installSourceTool) executeApply(ctx context.Context, req request, actions []action, warnings []string, planID string) string { ok := true anySucceeded := false for i := range actions { if err := t.apply(ctx, req, &actions[i]); err != nil { ok = false actions[i].Status = "failed" actions[i].Error = err.Error() if actions[i].Next == "" { actions[i].Next = nextForError(err) } continue } actions[i].Status = "done" anySucceeded = true warnings = append(warnings, actions[i].Warnings...) } status := "done" next := "Installed and verified." if !ok { if anySucceeded { status = "partial" next = "Some actions succeeded; the failed ones are listed in actions[].status=failed. Re-plan those and retry." } else { status = "failed" next = "No action succeeded. Fix the first failed action[] entry and retry install_source with apply=true." } } return marshalJSON(response{ OK: ok, Status: status, Op: req.Op, Applied: true, Source: req.Source, Kind: summarizeKind(actions), Kinds: kindCounts(actions), Scope: commonActionScope(actions), Mode: req.Mode, PlanID: planID, Actions: publicActions(actions), Warnings: warnings, Next: next, }) } // executeUninstall handles op=uninstall. It locates the named entry in the // active config (skills via the on-disk layout, MCP via cfg.Plugins) and // asks the host to disconnect. We do not consult the approval hook for // uninstall: the user already named the entry, and removal is the inverse // of the install they authorized. func (t *installSourceTool) executeUninstall(req request) string { actions := []action{} scopes := t.uninstallSearchScopes(req) for _, scope := range scopes { actions = t.uninstallActionsForScope(req.Name, scope) if len(actions) > 0 { break } } scope := commonActionScope(actions) if len(actions) == 0 { if len(scopes) == 1 { scope = scopes[0] } else { scope = strings.Join(scopes, "/") } return marshalJSON(response{ OK: false, Status: "blocked", Op: req.Op, Applied: false, Source: req.Source, Name: req.Name, Scope: scope, Next: "No installed skill or MCP server matched that name in the chosen scope.", }) } // Uninstall is destructive but symmetric with a previously approved // install, so we apply directly. Each action is independent. ok := true anySucceeded := false for i := range actions { if err := t.apply(context.Background(), req, &actions[i]); err != nil { ok = false actions[i].Status = "failed" actions[i].Error = err.Error() actions[i].Next = "Inspect the error, then retry op=uninstall." continue } actions[i].Status = "done" anySucceeded = true } status := "done" if !ok { status = "partial" if !anySucceeded { status = "failed" } } return marshalJSON(response{ OK: ok, Status: status, Op: req.Op, Applied: true, Source: req.Source, Name: req.Name, Kind: summarizeKind(actions), Kinds: kindCounts(actions), Scope: scope, Actions: publicActions(actions), Next: "Removed.", }) } func (t *installSourceTool) uninstallSearchScopes(req request) []string { if req.scopeExplicit && req.Scope != "" { return []string{req.Scope} } scopes := []string{} if strings.TrimSpace(t.root) != "" { scopes = append(scopes, "project") } return append(scopes, "global") } func (t *installSourceTool) uninstallActionsForScope(name, scope string) []action { var actions []action cfgPath := t.configPath(scope) cfg := config.LoadForEdit(cfgPath) // Skills: try the flat file, then the directory layout, in the chosen // scope. We don't require a kind — "name" disambiguates. if path, ok := t.resolveSkillPath(name, scope); ok { actions = append(actions, action{ Kind: "skill", Action: "remove_skill", Name: name, Target: path, Scope: scope, ConfigPath: cfgPath, RiskLevel: RiskLow, }) } else if rootAction, ok := t.resolveRegisteredSkillRoot(name, scope, cfgPath, cfg); ok { actions = append(actions, rootAction) } // MCP: scan the chosen config for the named plugin. for _, p := range cfg.Plugins { if p.Name == name { actions = append(actions, action{ Kind: "mcp", Action: "remove_mcp_server", Name: p.Name, Target: p.URL, Scope: scope, Transport: pluginTransport(p), ConfigPath: cfgPath, RiskLevel: RiskMedium, RiskReasons: []string{ "disconnects a running server and drops its tools from the active session", }, }) break } } if scope == "global" || scope == "" { if st, err := pluginpkg.LoadState(t.reasonixHome); err == nil { for _, p := range st.Plugins { if p.Name != name { continue } root := pluginpkg.ResolveRoot(t.reasonixHome, p.Root) actions = append(actions, action{ Kind: "plugin", Action: "remove_plugin_package", Name: p.Name, Target: root, Scope: "global", ConfigPath: pluginpkg.StatePath(t.reasonixHome), ManifestKind: p.ManifestKind, Version: p.Version, RiskLevel: RiskMedium, RiskReasons: []string{ "removes a plugin package and disables its skills, hooks, and MCP servers", }, }) break } } } return actions } // resolveSkillPath finds the on-disk location of a previously installed // skill of the given name in the chosen scope. The bool reports whether // the path is a real install (Lstat succeeded). Both flat (.md) and // directory (/) layouts are checked. func (t *installSourceTool) resolveSkillPath(name, scope string) (string, bool) { if !config.IsValidSkillName(name) { return "", false } var root string if scope == "global" { if t.reasonixHome == "" { return "", false } root = filepath.Join(t.reasonixHome, skill.SkillsDirname) } else { root = filepath.Join(t.root, ".reasonix", skill.SkillsDirname) } flat := filepath.Join(root, name+".md") if _, err := lstat(flat); err == nil { return flat, true } dir := filepath.Join(root, name) if _, err := lstat(filepath.Join(dir, skill.SkillFile)); err == nil { return dir, true } return "", false } func (t *installSourceTool) resolveRegisteredSkillRoot(name, scope, cfgPath string, cfg *config.Config) (action, bool) { if !config.IsValidSkillName(name) { return action{}, false } for _, rawPath := range cfg.Skills.Paths { path := t.resolvePath(config.ExpandVars(rawPath)) cands, err := scanSkillRoot(path, false) if err != nil { continue } var names []string found := false for _, cand := range cands { names = append(names, cand.Name) if cand.Name == name { found = true } } if !found { continue } sort.Strings(names) return action{ Kind: "skill", Action: "remove_skill_root", Name: name, Target: rawPath, Scope: scope, ConfigPath: cfgPath, Skills: names, SkillCount: len(names), RiskLevel: RiskMedium, RiskReasons: []string{ "removes a registered skill root from [skills].paths and may hide every skill in that folder", }, }, true } return action{}, false } func (t *installSourceTool) configPath(scope string) string { if scope == "global" { if p := config.UserConfigPath(); p != "" { return p } } return filepath.Join(t.root, "reasonix.toml") } func (t *installSourceTool) normalizeScope(scope string) (string, bool) { switch strings.ToLower(strings.TrimSpace(scope)) { case "project": return "project", true case "global": return "global", true default: return "", false } } func (t *installSourceTool) installScope(req request, kind, source string) string { if req.scopeExplicit && req.Scope != "" { return req.Scope } if kind == "mcp" { if t.isProjectMCPJSONSource(source) { return "project" } return "global" } if strings.TrimSpace(t.root) != "" { return "project" } return "global" } func (t *installSourceTool) isProjectMCPJSONSource(source string) bool { if isURL(source) || !strings.EqualFold(filepath.Base(source), ".mcp.json") { return false } root := strings.TrimSpace(t.root) if root == "" { return false } sourceAbs, sourceErr := filepath.Abs(source) rootAbs, rootErr := filepath.Abs(root) if sourceErr != nil || rootErr != nil { return false } rel, err := filepath.Rel(filepath.Clean(rootAbs), filepath.Clean(sourceAbs)) if err != nil { return false } return rel == ".mcp.json" || (!strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..") } func commonActionScope(actions []action) string { if len(actions) == 0 { return "" } scope := actions[0].Scope for _, action := range actions[1:] { if action.Scope != scope { return "mixed" } } return scope } func (t *installSourceTool) resolvePath(p string) string { p = strings.TrimSpace(p) if strings.HasPrefix(p, "~/") || strings.HasPrefix(p, `~\`) { p = filepath.Join(t.home, p[2:]) } else if p == "~" { p = t.home } if !filepath.IsAbs(p) { p = filepath.Join(t.root, p) } if abs, err := filepath.Abs(p); err == nil { p = abs } return filepath.Clean(p) } // computePlanID hashes the request plus the full public action set so a later // apply call with the same planId can be verified to be approving exactly the // same plan. It intentionally excludes Apply and PlanID; everything that changes // what will be written/connected must live either in req's planning fields or in // the action DTO. func computePlanID(req request, actions []action) string { public := publicActions(actions) sort.Slice(public, func(i, j int) bool { return actionPlanKey(public[i]) < actionPlanKey(public[j]) }) payload := struct { Op string `json:"op"` Source string `json:"source"` Kind string `json:"kind"` Scope string `json:"scope"` Mode string `json:"mode"` Name string `json:"name"` Transport string `json:"transport"` Command string `json:"command"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` Headers map[string]string `json:"headers,omitempty"` Tier string `json:"tier"` Replace bool `json:"replace"` Strict bool `json:"strict"` Actions []action `json:"actions"` }{ Op: req.Op, Source: req.Source, Kind: req.Kind, Scope: commonActionScope(actions), Mode: req.Mode, Name: req.Name, Transport: req.Transport, Command: req.Command, Args: req.Args, Env: req.Env, Headers: req.Headers, Tier: req.Tier, Replace: req.Replace, Strict: req.strict(), Actions: public, } body, _ := json.Marshal(payload) h := sha256.New() h.Write(body) return "sha256:" + hex.EncodeToString(h.Sum(nil)[:16]) } // kindCounts tallies the per-kind action count for the response. Skill // skills and MCP servers in the same plan get separate counts so the // caller can summarize accurately. func kindCounts(actions []action) kindTally { var out kindTally for _, a := range actions { switch a.Kind { case "skill": out.Skill++ case "mcp": out.MCP++ case "plugin": out.Plugin++ } } return out } // nextForError maps a sentinel error to a short remediation hint. Callers // use it as the default `next` value when a plan step fails. func nextForError(err error) string { switch { case errors.Is(err, ErrAuthRequired): return "Authentication is required. Add the needed token as an environment variable or header placeholder, then retry." case errors.Is(err, ErrBinaryMissing): return "Install the missing local runtime or use an absolute command path, then retry." case errors.Is(err, ErrAlreadyExists): return "Choose another name, remove the existing entry, or retry MCP installs with replace=true." case errors.Is(err, ErrUnsafeLinkTarget): return "The link target escapes the project/home root. Pick a source path inside the workspace or home directory." case errors.Is(err, ErrApprovalDenied): return "Host denied the install. Re-run without apply=true to revise the plan, or ask the user to confirm." case errors.Is(err, ErrManifestMissing): return "No installable manifest was found at the source. Provide a direct SKILL.md, .mcp.json, executable, or package name." case errors.Is(err, ErrInvalidManifest): return "The manifest was found but did not validate. Check required fields (command/url/tier)." case errors.Is(err, ErrSourceUnreadable): return "The source could not be read. Check the URL/path and try again." default: return "Inspect the error, fix the source or environment, then retry." } } // currentDir / userHomeDir / lstat are tiny wrappers that exist so tests // can stub them; the wrappers today just call the stdlib versions. var ( currentDir = defaultCurrentDir userHomeDir = defaultUserHomeDir lstat = defaultLstat ) func defaultCurrentDir() (string, error) { return os.Getwd() } func defaultUserHomeDir() (string, error) { return os.UserHomeDir() } func defaultLstat(path string) (os.FileInfo, error) { return os.Lstat(path) }