| 1 | package plugin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "strings" |
| 7 | ) |
| 8 | |
| 9 | // MCPInstallInput is the high-level install request for market entries, pasted |
| 10 | // commands/URLs/JSON, and explicit imports. Old AddMCPServer/UpdateMCPServer |
| 11 | // remain as compatibility adapters around the same readiness path. |
| 12 | type MCPInstallInput struct { |
| 13 | RegistryName string `json:"registryName,omitempty"` |
| 14 | Definition string `json:"definition,omitempty"` |
| 15 | Name string `json:"name,omitempty"` |
| 16 | Env map[string]string `json:"env,omitempty"` |
| 17 | Headers map[string]string `json:"headers,omitempty"` |
| 18 | // Spec is the already-parsed candidate used by in-process callers that |
| 19 | // resolved a market entry or argv themselves. |
| 20 | Spec Spec `json:"-"` |
| 21 | } |
| 22 | |
| 23 | // MCPInstallResult is the user-visible outcome of an install/update transaction. |
| 24 | // Only State=="ready" may be phrased as success in CLI/UI copy. |
| 25 | type MCPInstallResult struct { |
| 26 | Name string `json:"name"` |
| 27 | State string `json:"state"` // ready | action_required | issue |
| 28 | ToolCount int `json:"toolCount"` |
| 29 | Action string `json:"action"` // none | authenticate | authorize | retry |
| 30 | Message string `json:"message"` |
| 31 | } |
| 32 | |
| 33 | // InstallAndConnect runs initialize + tools/list for a candidate Spec against |
| 34 | // the shared Host and returns a readiness result. Callers own durable config, |
| 35 | // activation, launcher lock, and schema persistence around this probe so a |
| 36 | // failed readiness check does not leave a half-installed entry. |
| 37 | func ReadyInstallResult(name string, toolCount int) MCPInstallResult { |
| 38 | return MCPInstallResult{ |
| 39 | Name: strings.TrimSpace(name), |
| 40 | State: "ready", |
| 41 | ToolCount: toolCount, |
| 42 | Action: "none", |
| 43 | Message: fmt.Sprintf("%q is available with %d tools", strings.TrimSpace(name), toolCount), |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | // InstallResultForError maps a handshake error to the stable UI/CLI install |
| 48 | // contract. Expected authentication/authorization states are still returned to |
| 49 | // callers as errors so low-level users can decide whether durable config should |
| 50 | // be retained; high-level transaction owners may turn those states into a |
| 51 | // successful RPC response after persistence succeeds. |
| 52 | func InstallResultForError(name string, err error) MCPInstallResult { |
| 53 | name = strings.TrimSpace(name) |
| 54 | if err == nil { |
| 55 | return ReadyInstallResult(name, 0) |
| 56 | } |
| 57 | if requiresLaunchApproval(err) { |
| 58 | return MCPInstallResult{Name: name, State: "action_required", Action: "authorize", Message: err.Error()} |
| 59 | } |
| 60 | msg := err.Error() |
| 61 | lower := strings.ToLower(msg) |
| 62 | if strings.Contains(lower, "auth") || strings.Contains(lower, "oauth") || strings.Contains(lower, "unauthorized") || strings.Contains(lower, "forbidden") { |
| 63 | return MCPInstallResult{Name: name, State: "action_required", Action: "authenticate", Message: msg} |
| 64 | } |
| 65 | return MCPInstallResult{Name: name, State: "issue", Action: "retry", Message: msg} |
| 66 | } |
| 67 | |
| 68 | func (h *Host) InstallAndConnect(ctx context.Context, spec Spec) (MCPInstallResult, error) { |
| 69 | return h.InstallAndConnectWithLifecycle(ctx, ctx, spec) |
| 70 | } |
| 71 | |
| 72 | // InstallAndConnectWithLifecycle separates ownership of the spawned MCP process |
| 73 | // from the readiness-call deadline. Desktop sessions pass their session |
| 74 | // lifecycle as lifeCtx and a short request deadline as callCtx; one-shot CLI |
| 75 | // probes may use the same context for both. |
| 76 | func (h *Host) InstallAndConnectWithLifecycle(lifeCtx, callCtx context.Context, spec Spec) (MCPInstallResult, error) { |
| 77 | name := strings.TrimSpace(spec.Name) |
| 78 | if name == "" { |
| 79 | return MCPInstallResult{State: "issue", Action: "retry", Message: "MCP server name is required"}, fmt.Errorf("mcp install: name is required") |
| 80 | } |
| 81 | if spec.ProcessMode == "" { |
| 82 | spec.ProcessMode = MCPProcessHost |
| 83 | } |
| 84 | // User/host installs are authorized by the install action itself. |
| 85 | if !spec.RequireLaunchApproval { |
| 86 | spec.Authorized = true |
| 87 | } |
| 88 | tools, err := h.EnsureConnectedWithLifecycle(lifeCtx, callCtx, spec, 0) |
| 89 | if err != nil { |
| 90 | return InstallResultForError(name, err), err |
| 91 | } |
| 92 | // Persist schema so the next session registers tools without reconnect UX. |
| 93 | _ = SaveCachedSchema(spec.Name, CachedSchema{ |
| 94 | CacheKey: SchemaCacheKey(spec), |
| 95 | Capabilities: map[string]bool{"tools": len(tools) > 0}, |
| 96 | Tools: cacheableToolsOf(tools), |
| 97 | }) |
| 98 | return ReadyInstallResult(name, len(tools)), nil |
| 99 | } |
| 100 |