diff --git a/docs/MCP.md b/docs/MCP.md index 0c380d2..db6d9e8 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -2,7 +2,7 @@ `pushman mcp` serves the Model Context Protocol over standard input and output from the installed Pushman CLI binary. It is intended for local AI clients and uses the same Pushman API origin, operating-system credential, validation rules, and monthly allowance as normal CLI commands. -No additional daemon, remote MCP endpoint, MCP account, or plaintext token file is required. +No additional daemon or plaintext token file is required for local stdio. A hosted OAuth plugin is also available when its connection has been deployed; see the [hosted MCP guide](https://github.com/pushmanhq/pushman/blob/main/docs/MCP.md). ## Prerequisites @@ -91,9 +91,12 @@ The server is a long-running stdio subprocess. Do not start it manually and expe | `pushman_get_status` | Reads local authorization state and sender nickname | Account CLI credential | | `pushman_doctor` | Runs non-mutating credential and service connectivity checks | Account CLI credential | -The send tool is explicitly described to clients as non-read-only, non-idempotent, non-destructive/additive, and open-world. All other tools are read-only and open-world because they contact the hosted Pushman service. Tool annotations are safety hints, not an authorization boundary; the hosted API still enforces the credential's capabilities and account quota. +The send tool is explicitly described to clients as non-read-only, non-idempotent, non-destructive/additive, and open-world. The six original read tools are read-only and open-world because they contact the hosted Pushman service. The additional account and local workflows have their own annotations. Tool annotations are safety hints, not an authorization boundary; the hosted API still enforces the credential's capabilities and account quota. -Pairing, logout, sender rename, credential creation or revocation, account changes, and billing are deliberately not exposed through MCP. Remote Streamable HTTP, MCP resources, and MCP prompts are not part of this version. +Local login/pair, sender rename/logout, diagnostics and eligible binary update +are described in [CLI workflow parity](#cli-workflow-parity-v050) below. Account +administration, billing, MCP resources and MCP prompts remain outside this local +tool surface. Hosted Streamable HTTP uses the separate OAuth endpoint and sender. Usage results may include server-provided `plan` and `billingState` metadata. Older servers omit them; clients must not infer a plan from the numerical limit. `billingState: unavailable` @@ -141,3 +144,30 @@ make build-dev Tests negotiate with the official MCP Go SDK client, verify all schemas and safety annotations, exercise successful and failed tool calls, and run the Cobra command in a separate process to prove protocol-only stdout and clean EOF shutdown. On Windows, `go test -v ./internal/wincheck` builds the real main package into a path with spaces and Korean characters, then exercises the official SDK client against that executable and a loopback API. It verifies negotiation, tool schemas, success/error, send-only authorization, protocol-only stdout, EOF, restart, and cancellation of an in-flight API call. This does not substitute for acceptance in a specific desktop host. + +## CLI workflow parity (v0.5.0) + +Local MCP exposes 17 tools. The original seven tools retain their notification +fields, output contract and explicit-send policy. The additional workflows are: + +| CLI command | Local MCP tool | Behavior | +| --- | --- | --- | +| `login`, `pair` | `pushman_login`, `pushman_pair` | Start explicitly requested human-approved authorization; return a public URL/code and progress handle. | +| Authorization progress | `pushman_get_authorization` | Read the exact process-local handle. | +| Cancel pending authorization | `pushman_cancel_authorization` | Stop polling; does not revoke a completed credential. | +| `rename` | `pushman_rename_sender` | Rename only the current sender. | +| `logout` | `pushman_logout` | Revoke the current credential and remove it locally. | +| `support-bundle` | `pushman_get_support_bundle` | Return privacy-safe JSON to the client; no local file or support upload. | +| `version` | `pushman_get_version` | Report this process's loaded build. | +| `help` | `pushman_get_capabilities` | Map commands to MCP workflows and explain transport limits. | +| `self-update` | `pushman_self_update` | Reuse the eligible Homebrew ownership check; requires an explicit binary-update request. | + +Do not invent an authorization handle, restart a pending attempt, auto-retry a +denial/expiry, or submit provider credentials for the user. Client disconnect +stops pending polling. Verify status after completion; no test send is implied. + +Account-management tools do not change the send-only automation-token boundary. +After self-update, the existing MCP process still runs its loaded version. +Restart the client's Pushman subprocess and verify version/tool discovery before +claiming updated tools are active. Hosted OAuth has its own sender and token +family; a hosted service cannot modify this computer's keyring or executable. diff --git a/go.mod b/go.mod index 1244664..70731c7 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/pushmanhq/pushman-cli -go 1.27.0 +go 1.27.2 require ( github.com/google/jsonschema-go v0.4.3 diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 9c7d806..57523ca 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -62,6 +62,7 @@ func newMCPServer(deps Dependencies) *mcp.Server { }) registerMCPTools(server, deps.Service) + registerMCPAccountTools(server, withDefaults(deps)) return server } @@ -180,7 +181,7 @@ func registerMCPTools(server *mcp.Server, service Service) { if err != nil { return nil, mcpStatusOutput{}, err } - return nil, mcpStatusOutput{Paired: status.Paired, Nickname: status.Nickname}, nil + return nil, mcpStatusOutput{Paired: status.Paired, Nickname: status.Nickname, Method: status.Method}, nil }) mcp.AddTool(server, &mcp.Tool{ @@ -286,6 +287,7 @@ type mcpUsageOutput struct { type mcpStatusOutput struct { Paired bool `json:"paired"` Nickname string `json:"nickname,omitempty"` + Method string `json:"method,omitempty"` } type mcpDoctorCheck struct { diff --git a/internal/cli/mcp_account.go b/internal/cli/mcp_account.go new file mode 100644 index 0000000..edab01d --- /dev/null +++ b/internal/cli/mcp_account.go @@ -0,0 +1,237 @@ +package cli + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "runtime" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "golang.org/x/text/unicode/norm" +) + +type mcpAccountResult struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` +} +type mcpRenameInput struct { + Nickname string `json:"nickname" jsonschema:"New sender nickname, 1 through 64 Unicode scalar values."` +} +type mcpAuthorizationInput struct { + SuggestedName string `json:"suggestedName,omitempty" jsonschema:"Optional sender nickname. Defaults to this computer's hostname."` +} +type mcpAuthorizationHandle struct { + AuthorizationID string `json:"authorizationId" jsonschema:"Opaque progress handle returned by this MCP process, never a device code or bearer token."` +} +type mcpAuthorizationOutput struct { + AuthorizationID string `json:"authorizationId"` + Status string `json:"status"` + UserCode string `json:"userCode,omitempty"` + VerificationURL string `json:"verificationUrl,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` + Nickname string `json:"nickname,omitempty"` + Message string `json:"message,omitempty"` +} + +type mcpPendingAuthorization struct { + output mcpAuthorizationOutput + owner *mcp.ServerSession + cancel context.CancelFunc + ready chan struct{} + done chan struct{} + err error +} +type mcpAuthorizations struct { + mu sync.Mutex + pending *mcpPendingAuthorization +} + +func registerMCPAccountTools(server *mcp.Server, deps Dependencies) { + read := &mcp.ToolAnnotations{ReadOnlyHint: true, DestructiveHint: boolPointer(false), IdempotentHint: true, OpenWorldHint: boolPointer(false)} + change := &mcp.ToolAnnotations{DestructiveHint: boolPointer(false), OpenWorldHint: boolPointer(true)} + revoke := &mcp.ToolAnnotations{DestructiveHint: boolPointer(true), OpenWorldHint: boolPointer(true)} + mcp.AddTool(server, &mcp.Tool{Name: "pushman_rename_sender", Title: "Rename this Pushman sender", Description: "Rename only this authorized sender. Requires an explicit user request; does not send a notification.", Annotations: change}, func(ctx context.Context, _ *mcp.CallToolRequest, in mcpRenameInput) (*mcp.CallToolResult, mcpAccountResult, error) { + name := norm.NFC.String(strings.TrimSpace(in.Nickname)) + if !utf8.ValidString(name) || countScalars(name) < 1 || countScalars(name) > 64 { + return nil, mcpAccountResult{}, usagef("nickname must contain 1 through 64 Unicode scalar values") + } + err := deps.Service.Rename(ctx, name) + return nil, mcpAccountResult{OK: err == nil}, err + }) + mcp.AddTool(server, &mcp.Tool{Name: "pushman_logout", Title: "Disconnect this Pushman sender", Description: "Revoke the current local sender credential and remove it from this computer's credential store. Requires an explicit user request.", Annotations: revoke}, func(ctx context.Context, _ *mcp.CallToolRequest, _ mcpEmptyInput) (*mcp.CallToolResult, mcpAccountResult, error) { + err := deps.Service.Logout(ctx) + return nil, mcpAccountResult{OK: err == nil}, err + }) + mcp.AddTool(server, &mcp.Tool{Name: "pushman_get_version", Title: "Get Pushman version", Description: "Read the installed CLI build version without changing it.", Annotations: read}, func(context.Context, *mcp.CallToolRequest, mcpEmptyInput) (*mcp.CallToolResult, VersionInfo, error) { + return nil, deps.Version, nil + }) + mcp.AddTool(server, &mcp.Tool{Name: "pushman_get_support_bundle", Title: "Get privacy-safe Pushman diagnostics", Description: "Return version, platform, generation time and named boolean doctor checks. Excludes tokens, messages, account IDs, endpoints and diagnostic text. Does not write or upload a file.", Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPointer(true)}}, func(ctx context.Context, _ *mcp.CallToolRequest, _ mcpEmptyInput) (*mcp.CallToolResult, supportBundle, error) { + checks, err := deps.Service.Doctor(ctx) + if err != nil { + return nil, supportBundle{}, err + } + safe := make([]supportBundleCheck, 0, len(checks)) + for _, check := range checks { + safe = append(safe, supportBundleCheck{Name: check.Name, OK: check.OK}) + } + return nil, supportBundle{Schema: 1, GeneratedAt: deps.Now().UTC().Format(time.RFC3339), Version: deps.Version, Runtime: supportBundleRuntime{GOOS: runtime.GOOS, GOARCH: runtime.GOARCH}, Checks: safe, Privacy: "Returned only to this requesting client; no file or support upload. No tokens, message content, account IDs, endpoints or diagnostic messages."}, nil + }) + mcp.AddTool(server, &mcp.Tool{Name: "pushman_self_update", Title: "Update an eligible Pushman Homebrew installation", Description: "Run the existing ownership-checked Homebrew self-update on this computer. Requires an explicit request to update the CLI. May replace the executable. Never retries or bypasses an ownership refusal.", Annotations: change}, func(ctx context.Context, _ *mcp.CallToolRequest, _ mcpEmptyInput) (*mcp.CallToolResult, mcpAccountResult, error) { + output, err := deps.SelfUpdate(ctx) + return nil, mcpAccountResult{OK: err == nil, Message: output}, err + }) + mcp.AddTool(server, &mcp.Tool{Name: "pushman_get_capabilities", Title: "Get Pushman command help and capabilities", Description: "Describe every CLI workflow, its MCP equivalent and this transport's authorization boundary.", Annotations: read}, func(context.Context, *mcp.CallToolRequest, mcpEmptyInput) (*mcp.CallToolResult, mcpCapabilitiesOutput, error) { + return nil, localMCPCapabilities(), nil + }) + authorizations := &mcpAuthorizations{} + for _, method := range []string{"login", "pair"} { + mcp.AddTool(server, &mcp.Tool{Name: "pushman_" + method, Title: "Start Pushman " + method, Description: "Start explicitly requested local account authorization. Returns the public verification URL and code; the user signs in or approves on iPhone. Credentials and polling secrets stay private. Use pushman_get_authorization to inspect progress or pushman_cancel_authorization to cancel.", Annotations: change}, func(ctx context.Context, req *mcp.CallToolRequest, in mcpAuthorizationInput) (*mcp.CallToolResult, mcpAuthorizationOutput, error) { + out, err := authorizations.begin(ctx, req.Session, deps, method, in.SuggestedName) + return nil, out, err + }) + } + mcp.AddTool(server, &mcp.Tool{Name: "pushman_get_authorization", Title: "Read Pushman authorization progress", Description: "Read the status of one authorization started by this MCP session. Does not create or retry an authorization request.", Annotations: read}, func(_ context.Context, req *mcp.CallToolRequest, in mcpAuthorizationHandle) (*mcp.CallToolResult, mcpAuthorizationOutput, error) { + out, err := authorizations.get(req.Session, in.AuthorizationID, false) + return nil, out, err + }) + mcp.AddTool(server, &mcp.Tool{Name: "pushman_cancel_authorization", Title: "Cancel pending Pushman authorization", Description: "Cancel the named pending authorization from this MCP session. Does not revoke an already issued credential.", Annotations: change}, func(_ context.Context, req *mcp.CallToolRequest, in mcpAuthorizationHandle) (*mcp.CallToolResult, mcpAuthorizationOutput, error) { + out, err := authorizations.get(req.Session, in.AuthorizationID, true) + return nil, out, err + }) +} + +func (a *mcpAuthorizations) begin(ctx context.Context, owner *mcp.ServerSession, deps Dependencies, method, name string) (mcpAuthorizationOutput, error) { + if name == "" { + var err error + name, err = deps.Hostname() + if err != nil { + return mcpAuthorizationOutput{}, fmt.Errorf("read hostname for authorization: %w", err) + } + } + if !utf8.ValidString(name) || countScalars(strings.TrimSpace(name)) > 64 { + return mcpAuthorizationOutput{}, usagef("suggestedName must contain 1 through 64 Unicode scalar values") + } + name, err := normalizeSuggestedName(name) + if err != nil { + return mcpAuthorizationOutput{}, err + } + var id [16]byte + if _, err := rand.Read(id[:]); err != nil { + return mcpAuthorizationOutput{}, err + } + a.mu.Lock() + if a.pending != nil { + select { + case <-a.pending.done: + default: + a.mu.Unlock() + return mcpAuthorizationOutput{}, fmt.Errorf("an authorization is already pending; inspect or cancel it") + } + } + runCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + p := &mcpPendingAuthorization{output: mcpAuthorizationOutput{AuthorizationID: hex.EncodeToString(id[:]), Status: "pending"}, owner: owner, cancel: cancel, ready: make(chan struct{}, 1), done: make(chan struct{})} + a.pending = p + a.mu.Unlock() + challenge := func(code, url string, expiry time.Time) error { + a.mu.Lock() + p.output.UserCode = code + p.output.VerificationURL = url + p.output.ExpiresAt = expiry.UTC().Format(time.RFC3339) + a.mu.Unlock() + select { + case p.ready <- struct{}{}: + default: + } + return nil + } + go func() { + defer cancel() + var result PairResult + var err error + if method == "login" { + result, err = deps.Service.Login(runCtx, LoginRequest{Platform: runtime.GOOS, SuggestedName: name, OnChallenge: func(c LoginChallenge) error { return challenge(c.UserCode, c.CompleteURL, deps.Now().Add(c.ExpiresIn)) }}) + } else { + result, err = deps.Service.Pair(runCtx, PairRequest{Platform: runtime.GOOS, SuggestedName: name, OnChallenge: func(c PairChallenge) error { + url, err := pairingVerificationURL(c.VerificationURL, c.UserCode) + if err != nil { + return err + } + return challenge(c.UserCode, url, c.ExpiresAt) + }}) + } + a.mu.Lock() + p.err = err + if err == nil { + p.output.Status = "authorized" + p.output.Nickname = result.Nickname + } else { + p.output.Status = "failed" + p.output.Message = "Authorization did not complete; inspect local status before starting another attempt." + } + // Clear public challenge details once the attempt ends. + p.output.UserCode = "" + p.output.VerificationURL = "" + close(p.done) + a.mu.Unlock() + }() + // A subprocess/client disconnect must stop polling rather than leave an + // unattended authorization capable of changing the keyring later. + if owner != nil { + sessionClosed := make(chan struct{}) + go func() { _ = owner.Wait(); close(sessionClosed) }() + go func() { + select { + case <-sessionClosed: + cancel() + case <-p.done: + } + }() + } + select { + case <-p.ready: + case <-p.done: + case <-ctx.Done(): + cancel() + return mcpAuthorizationOutput{}, ctx.Err() + } + a.mu.Lock() + defer a.mu.Unlock() + if p.err != nil { + return mcpAuthorizationOutput{}, p.err + } + return p.output, nil +} + +func (a *mcpAuthorizations) get(owner *mcp.ServerSession, id string, cancel bool) (mcpAuthorizationOutput, error) { + a.mu.Lock() + defer a.mu.Unlock() + p := a.pending + if p == nil || p.owner != owner || p.output.AuthorizationID != id { + return mcpAuthorizationOutput{}, usagef("authorizationId is not available in this MCP session") + } + if cancel { + select { + case <-p.done: + default: + p.cancel() + p.output.Status = "canceling" + } + } + return p.output, nil +} + +type mcpCapabilitiesOutput struct { + Transport string `json:"transport"` + Commands map[string]string `json:"commands"` + Authorization string `json:"authorization"` +} + +func localMCPCapabilities() mcpCapabilitiesOutput { + return mcpCapabilitiesOutput{Transport: "stdio", Authorization: "Uses this computer's account credential; PUSHMAN_TOKEN remains send-only. Mutations require explicit user intent.", Commands: map[string]string{ + "push": "pushman_send_notification", "devices": "pushman_list_devices", "history": "pushman_list_history", "history show": "pushman_get_message", "usage": "pushman_get_usage", "status": "pushman_get_status", "doctor": "pushman_doctor", "rename": "pushman_rename_sender", "logout": "pushman_logout", "login": "pushman_login then pushman_get_authorization", "pair": "pushman_pair then pushman_get_authorization", "support-bundle": "pushman_get_support_bundle returns a bundle without writing a file", "version": "pushman_get_version", "help": "pushman_get_capabilities and each tool's schema", "self-update": "pushman_self_update (eligible Homebrew installation only)", "mcp": "This active MCP transport"}} +} diff --git a/internal/cli/mcp_account_test.go b/internal/cli/mcp_account_test.go new file mode 100644 index 0000000..a736e6d --- /dev/null +++ b/internal/cli/mcp_account_test.go @@ -0,0 +1,147 @@ +package cli + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type accountMCPService struct { + mcpStubService + authorized chan struct{} + stopped chan struct{} + renamed string + loggedOut bool +} + +func (s *accountMCPService) Login(ctx context.Context, r LoginRequest) (PairResult, error) { + defer close(s.stopped) + if err := r.OnChallenge(LoginChallenge{UserCode: "ABCD-EFGH", VerificationURL: "https://example.com/activate/", CompleteURL: "https://example.com/activate/?user_code=ABCD-EFGH", ExpiresIn: 10 * time.Minute}); err != nil { + return PairResult{}, err + } + select { + case <-ctx.Done(): + return PairResult{}, ctx.Err() + case <-s.authorized: + return PairResult{Nickname: r.SuggestedName}, nil + } +} +func (s *accountMCPService) Pair(ctx context.Context, r PairRequest) (PairResult, error) { + defer close(s.stopped) + if err := r.OnChallenge(PairChallenge{UserCode: "ABCD-EFGH", VerificationURL: "https://example.com/pair/", ExpiresAt: time.Now().Add(10 * time.Minute)}); err != nil { + return PairResult{}, err + } + select { + case <-ctx.Done(): + return PairResult{}, ctx.Err() + case <-s.authorized: + return PairResult{Nickname: r.SuggestedName}, nil + } +} +func (s *accountMCPService) Rename(_ context.Context, name string) error { + s.renamed = name + return nil +} +func (s *accountMCPService) Logout(context.Context) error { s.loggedOut = true; return nil } +func decodeAuthorization(t *testing.T, result *mcp.CallToolResult) mcpAuthorizationOutput { + t.Helper() + if result.IsError { + t.Fatal(result.Content) + } + raw, err := json.Marshal(result.StructuredContent) + if err != nil { + t.Fatal(err) + } + var out mcpAuthorizationOutput + if json.Unmarshal(raw, &out) != nil { + t.Fatal("invalid authorization result") + } + return out +} +func TestMCPLocalAuthorizationProgressAndCancellation(t *testing.T) { + for _, method := range []string{"login", "pair"} { + t.Run(method, func(t *testing.T) { + service := &accountMCPService{authorized: make(chan struct{}), stopped: make(chan struct{})} + session, closeSession := connectMCPTestClient(t, service) + defer closeSession() + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "pushman_" + method, Arguments: map[string]any{"suggestedName": "Synthetic sender"}}) + if err != nil { + t.Fatal(err) + } + out := decodeAuthorization(t, result) + if out.Status != "pending" || out.UserCode != "ABCD-EFGH" || out.AuthorizationID == "" || !strings.HasPrefix(out.VerificationURL, "https://example.com/") { + t.Fatal("public challenge missing") + } + callToolOK(t, session, "pushman_get_authorization", map[string]any{"authorizationId": out.AuthorizationID}, "status", "pending") + invalid, _ := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "pushman_get_authorization", Arguments: map[string]any{"authorizationId": "other"}}) + if !invalid.IsError { + t.Fatal("unknown handle accepted") + } + callToolOK(t, session, "pushman_cancel_authorization", map[string]any{"authorizationId": out.AuthorizationID}, "status", nil) + select { + case <-service.stopped: + case <-time.After(time.Second): + t.Fatal("authorization did not stop") + } + callToolOK(t, session, "pushman_get_authorization", map[string]any{"authorizationId": out.AuthorizationID}, "status", "failed") + }) + } +} +func TestMCPLocalAuthorizationCompletesAndDisconnectStopsPending(t *testing.T) { + service := &accountMCPService{authorized: make(chan struct{}), stopped: make(chan struct{})} + session, closeSession := connectMCPTestClient(t, service) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "pushman_login", Arguments: map[string]any{"suggestedName": "Synthetic sender"}}) + if err != nil { + t.Fatal(err) + } + out := decodeAuthorization(t, result) + closeSession() + select { + case <-service.stopped: + case <-time.After(time.Second): + t.Fatal("client disconnect left polling active") + } + service = &accountMCPService{authorized: make(chan struct{}), stopped: make(chan struct{})} + session, closeSession = connectMCPTestClient(t, service) + defer closeSession() + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{Name: "pushman_pair", Arguments: map[string]any{"suggestedName": "Synthetic sender"}}) + if err != nil { + t.Fatal(err) + } + out = decodeAuthorization(t, result) + close(service.authorized) + <-service.stopped + callToolOK(t, session, "pushman_get_authorization", map[string]any{"authorizationId": out.AuthorizationID}, "nickname", "Synthetic sender") + callToolOK(t, session, "pushman_get_authorization", map[string]any{"authorizationId": out.AuthorizationID}, "status", "authorized") +} +func TestMCPAccountMutationValidationAndBundlePrivacy(t *testing.T) { + service := &accountMCPService{mcpStubService: mcpStubService{checks: []DoctorCheck{{Name: "credential", OK: true, Message: "synthetic-private-context"}}}} + updates := 0 + session, closeSession := connectMCPDependencies(t, Dependencies{Service: service, Version: VersionInfo{Version: "test"}, SelfUpdate: func(context.Context) (string, error) { updates++; return "already up to date", nil }}) + defer closeSession() + bad, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "pushman_rename_sender", Arguments: map[string]any{"nickname": " "}}) + if err != nil || !bad.IsError || service.renamed != "" { + t.Fatal("invalid rename reached service") + } + callToolOK(t, session, "pushman_rename_sender", map[string]any{"nickname": " Renamed "}, "ok", true) + if service.renamed != "Renamed" { + t.Fatal("rename normalization changed") + } + bundle, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "pushman_get_support_bundle", Arguments: map[string]any{}}) + if err != nil || bundle.IsError || toolTextContains(bundle, "synthetic-private-context") { + t.Fatal("support diagnostics leaked") + } + callToolOK(t, session, "pushman_get_version", map[string]any{}, "Version", "test") + callToolOK(t, session, "pushman_self_update", map[string]any{}, "ok", true) + if updates != 1 { + t.Fatal("update repeated") + } + callToolOK(t, session, "pushman_logout", map[string]any{}, "ok", true) + if !service.loggedOut { + t.Fatal("logout not called") + } +} diff --git a/internal/cli/mcp_test.go b/internal/cli/mcp_test.go index 02cc318..05d8c46 100644 --- a/internal/cli/mcp_test.go +++ b/internal/cli/mcp_test.go @@ -41,7 +41,7 @@ func TestMCPToolSurfaceAndCalls(t *testing.T) { if err != nil { t.Fatal(err) } - if got, want := len(listed.Tools), 7; got != want { + if got, want := len(listed.Tools), 17; got != want { t.Fatalf("tool count = %d, want %d", got, want) } tools := make(map[string]*mcp.Tool, len(listed.Tools)) @@ -77,7 +77,7 @@ func TestMCPToolSurfaceAndCalls(t *testing.T) { } } for name, tool := range tools { - if name != "pushman_send_notification" && !tool.Annotations.ReadOnlyHint { + if (name == "pushman_list_devices" || name == "pushman_list_history" || name == "pushman_get_message" || name == "pushman_get_usage" || name == "pushman_get_status" || name == "pushman_doctor") && !tool.Annotations.ReadOnlyHint { t.Fatalf("read tool %q is not marked read-only", name) } } @@ -224,9 +224,12 @@ func TestMCPHelperProcess(t *testing.T) { } func connectMCPTestClient(t *testing.T, service Service) (*mcp.ClientSession, func()) { + return connectMCPDependencies(t, Dependencies{Service: service, Version: VersionInfo{Version: "test"}}) +} +func connectMCPDependencies(t *testing.T, deps Dependencies) (*mcp.ClientSession, func()) { t.Helper() serverTransport, clientTransport := mcp.NewInMemoryTransports() - server := newMCPServer(Dependencies{Service: service, Version: VersionInfo{Version: "test"}}) + server := newMCPServer(deps) serverSession, err := server.Connect(context.Background(), serverTransport, nil) if err != nil { t.Fatal(err) diff --git a/internal/wincheck/mcp_windows_test.go b/internal/wincheck/mcp_windows_test.go index 2655863..290af7c 100644 --- a/internal/wincheck/mcp_windows_test.go +++ b/internal/wincheck/mcp_windows_test.go @@ -112,7 +112,7 @@ func TestWindowsMCPExecutableAndRestart(t *testing.T) { func() { defer process.close(t) tools, err := process.session.ListTools(t.Context(), nil) - if err != nil || len(tools.Tools) != 7 { + if err != nil || len(tools.Tools) != 17 { t.Fatalf("actual CLI tools/list failed: %v", err) } for _, tool := range tools.Tools {