Skip to content

Commit 9442824

Browse files
authored
feat(cli): add server-edition credential command group (spec 074 T9) (#695)
Add `mcpproxy credential list|status|connect|rm` to the server edition for managing per-user brokered upstream credentials over the T8 REST surfaces (GET/DELETE /api/v1/user/credentials, .../{server}/connect). - Responses are decoded into a non-secret typed view, so token material is never printed (FR-026) even if a response carries it. - Honors -o table|json|yaml and MCPPROXY_OUTPUT via the existing formatters. - Targets a server URL (--url / MCPPROXY_SERVER_URL) with a user JWT (--token / MCPPROXY_TOKEN): these surfaces sit behind session-or-Bearer auth, not the API-key/socket group, so a Bearer transport was added to the shared CLI client. - `connect` prints the browser URL (escaping slash-named servers); the flow binds to the user's browser session server-side. - Build-tagged (//go:build server) and registered via a server-edition command seam; the personal edition is unaffected (verified: `credential` is absent there). Docs: docs/cli/credential-commands.md. Related: spec 074 T9 / MCP-1042
1 parent a7bdf2c commit 9442824

9 files changed

Lines changed: 848 additions & 4 deletions

File tree

cmd/mcpproxy/credential_cmd.go

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
//go:build server
2+
3+
package main
4+
5+
import (
6+
"context"
7+
"fmt"
8+
"net/url"
9+
"os"
10+
"strings"
11+
"time"
12+
13+
"github.com/spf13/cobra"
14+
"go.uber.org/zap"
15+
16+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient"
17+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
18+
)
19+
20+
// Server-edition flags for the credential command group. The credential broker
21+
// surfaces (spec 074) live behind session-or-Bearer auth, so the CLI targets a
22+
// server URL and presents a user JWT — unlike the API-key/socket commands.
23+
var (
24+
credServerURL string
25+
credToken string
26+
)
27+
28+
// newCredentialCommand builds the `mcpproxy credential` command tree (server
29+
// edition only). It manages per-user brokered upstream credentials via the
30+
// T8 REST surfaces and never prints secret values (FR-026).
31+
func newCredentialCommand() *cobra.Command {
32+
credentialCmd := &cobra.Command{
33+
Use: "credential",
34+
Short: "Manage per-user brokered upstream credentials (server edition)",
35+
Long: `Inspect and manage your brokered credentials for shared upstream servers.
36+
37+
These commands talk to a running server-edition mcpproxy and require a user
38+
token (a JWT obtained from the Web UI or POST /api/v1/auth/token). Provide it
39+
with --token or the MCPPROXY_TOKEN environment variable, and point at the
40+
server with --url or MCPPROXY_SERVER_URL.
41+
42+
Secret values are never displayed (FR-026).
43+
44+
Examples:
45+
mcpproxy credential list
46+
mcpproxy credential status github
47+
mcpproxy credential connect github
48+
mcpproxy credential rm github`,
49+
}
50+
51+
credentialCmd.PersistentFlags().StringVar(&credServerURL, "url", "", "Base URL of the server-edition mcpproxy (default: $MCPPROXY_SERVER_URL or local listen address)")
52+
credentialCmd.PersistentFlags().StringVar(&credToken, "token", "", "User JWT bearer token (default: $MCPPROXY_TOKEN)")
53+
54+
credentialCmd.AddCommand(newCredentialListCmd())
55+
credentialCmd.AddCommand(newCredentialStatusCmd())
56+
credentialCmd.AddCommand(newCredentialConnectCmd())
57+
credentialCmd.AddCommand(newCredentialRemoveCmd())
58+
59+
return credentialCmd
60+
}
61+
62+
func newCredentialListCmd() *cobra.Command {
63+
return &cobra.Command{
64+
Use: "list",
65+
Short: "List brokered upstreams with connection status (no secrets)",
66+
Long: `List every brokered upstream and your connection status for it.
67+
68+
Examples:
69+
mcpproxy credential list
70+
mcpproxy credential list -o json`,
71+
Args: cobra.NoArgs,
72+
RunE: runCredentialList,
73+
}
74+
}
75+
76+
func newCredentialStatusCmd() *cobra.Command {
77+
return &cobra.Command{
78+
Use: "status <server>",
79+
Short: "Show one upstream's connection detail (no secrets)",
80+
Long: `Show the connection detail for a single brokered upstream.
81+
82+
Examples:
83+
mcpproxy credential status github
84+
mcpproxy credential status github -o yaml`,
85+
Args: cobra.ExactArgs(1),
86+
RunE: runCredentialStatus,
87+
}
88+
}
89+
90+
func newCredentialConnectCmd() *cobra.Command {
91+
return &cobra.Command{
92+
Use: "connect <server>",
93+
Short: "Print the browser URL to connect an upstream credential",
94+
Long: `Print the connect URL for a brokered upstream. Open it in a browser where
95+
you are signed in to mcpproxy to complete the OAuth connect flow; the proxy
96+
binds the flow to your user and stores the resulting credential server-side.
97+
98+
Examples:
99+
mcpproxy credential connect github`,
100+
Args: cobra.ExactArgs(1),
101+
RunE: runCredentialConnect,
102+
}
103+
}
104+
105+
func newCredentialRemoveCmd() *cobra.Command {
106+
return &cobra.Command{
107+
Use: "rm <server>",
108+
Aliases: []string{"remove", "disconnect"},
109+
Short: "Disconnect (revoke) your credential for an upstream",
110+
Long: `Disconnect and revoke your stored credential for a brokered upstream.
111+
112+
Examples:
113+
mcpproxy credential rm github`,
114+
Args: cobra.ExactArgs(1),
115+
RunE: runCredentialRemove,
116+
}
117+
}
118+
119+
// --- client wiring ---
120+
121+
// resolveCredentialBaseURL determines the server base URL from the --url flag,
122+
// the MCPPROXY_SERVER_URL env var, or the local listen address in config.
123+
func resolveCredentialBaseURL() string {
124+
if credServerURL != "" {
125+
return strings.TrimRight(credServerURL, "/")
126+
}
127+
if env := os.Getenv("MCPPROXY_SERVER_URL"); env != "" {
128+
return strings.TrimRight(env, "/")
129+
}
130+
if cfg, err := config.Load(); err == nil && cfg.Listen != "" {
131+
listen := cfg.Listen
132+
if strings.HasPrefix(listen, ":") {
133+
listen = "127.0.0.1" + listen
134+
}
135+
return "http://" + listen
136+
}
137+
return "http://127.0.0.1:8080"
138+
}
139+
140+
// resolveCredentialToken returns the user JWT from --token or MCPPROXY_TOKEN.
141+
func resolveCredentialToken() string {
142+
if credToken != "" {
143+
return credToken
144+
}
145+
return os.Getenv("MCPPROXY_TOKEN")
146+
}
147+
148+
func newCredentialClient() (*cliclient.Client, string) {
149+
baseURL := resolveCredentialBaseURL()
150+
logger, _ := zap.NewProduction()
151+
return cliclient.NewClientWithBearer(baseURL, resolveCredentialToken(), logger.Sugar()), baseURL
152+
}
153+
154+
// --- run functions ---
155+
156+
func runCredentialList(_ *cobra.Command, _ []string) error {
157+
client, _ := newCredentialClient()
158+
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
159+
defer cancel()
160+
161+
creds, err := client.ListCredentials(ctx)
162+
if err != nil {
163+
return err
164+
}
165+
return emitCredentials(creds)
166+
}
167+
168+
func runCredentialStatus(_ *cobra.Command, args []string) error {
169+
client, _ := newCredentialClient()
170+
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
171+
defer cancel()
172+
173+
creds, err := client.ListCredentials(ctx)
174+
if err != nil {
175+
return err
176+
}
177+
cred, ok := findCredential(creds, args[0])
178+
if !ok {
179+
return fmt.Errorf("brokered server %q not found", args[0])
180+
}
181+
return emitCredentialDetail(cred)
182+
}
183+
184+
func runCredentialConnect(_ *cobra.Command, args []string) error {
185+
baseURL := resolveCredentialBaseURL()
186+
connectURL := credentialConnectURL(baseURL, args[0])
187+
188+
format := ResolveOutputFormat()
189+
if format == "json" || format == "yaml" {
190+
return emitFormatted(map[string]string{"server": args[0], "connect_url": connectURL})
191+
}
192+
193+
fmt.Printf("Open this URL in a browser where you are signed in to mcpproxy:\n\n %s\n\n", connectURL)
194+
fmt.Println("After authorizing, the credential is stored server-side and tied to your user.")
195+
return nil
196+
}
197+
198+
func runCredentialRemove(_ *cobra.Command, args []string) error {
199+
client, _ := newCredentialClient()
200+
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
201+
defer cancel()
202+
203+
msg, err := client.DeleteCredential(ctx, args[0])
204+
if err != nil {
205+
return err
206+
}
207+
if msg == "" {
208+
msg = fmt.Sprintf("Disconnected credential for %q", args[0])
209+
}
210+
fmt.Println(msg)
211+
return nil
212+
}
213+
214+
// --- pure helpers (kept side-effect-free for testing) ---
215+
216+
// findCredential looks up a credential by case-insensitive server name.
217+
func findCredential(creds []cliclient.CredentialStatus, name string) (cliclient.CredentialStatus, bool) {
218+
for _, c := range creds {
219+
if strings.EqualFold(c.Server, name) {
220+
return c, true
221+
}
222+
}
223+
return cliclient.CredentialStatus{}, false
224+
}
225+
226+
// credentialConnectURL builds the browser connect URL for a brokered upstream.
227+
// The server name is path-escaped so namespace/name registry identifiers do not
228+
// inject extra path segments (cf. MCP-1111).
229+
func credentialConnectURL(baseURL, server string) string {
230+
return fmt.Sprintf("%s/api/v1/user/credentials/%s/connect",
231+
strings.TrimRight(baseURL, "/"), url.PathEscape(server))
232+
}
233+
234+
// renderCredentialsTable renders the list view. It only ever reads the
235+
// non-secret fields of CredentialStatus, so no secret can appear (FR-026).
236+
func renderCredentialsTable(creds []cliclient.CredentialStatus) string {
237+
if len(creds) == 0 {
238+
return "No brokered upstreams configured."
239+
}
240+
var b strings.Builder
241+
fmt.Fprintf(&b, "%-24s %-16s %-15s %-10s %-20s\n", "SERVER", "MODE", "STATUS", "TOKEN", "EXPIRES")
242+
b.WriteString(strings.Repeat("-", 90) + "\n")
243+
needsConnect := false
244+
for _, c := range creds {
245+
expires := "-"
246+
if c.ExpiresAt != nil {
247+
expires = c.ExpiresAt.Format("2006-01-02 15:04")
248+
}
249+
tokenType := c.TokenType
250+
if tokenType == "" {
251+
tokenType = "-"
252+
}
253+
marker := ""
254+
if c.ConnectPath != "" {
255+
marker = " *"
256+
needsConnect = true
257+
}
258+
fmt.Fprintf(&b, "%-24s %-16s %-15s %-10s %-20s\n",
259+
truncateCell(c.Server, 24), truncateCell(c.Mode, 16), c.Status+marker, truncateCell(tokenType, 10), expires)
260+
}
261+
if needsConnect {
262+
b.WriteString("\n* connectable: run 'mcpproxy credential connect <server>'\n")
263+
}
264+
return strings.TrimRight(b.String(), "\n")
265+
}
266+
267+
// renderCredentialDetail renders the single-server status view.
268+
func renderCredentialDetail(c cliclient.CredentialStatus) string {
269+
var b strings.Builder
270+
fmt.Fprintf(&b, "Server: %s\n", c.Server)
271+
fmt.Fprintf(&b, "Mode: %s\n", c.Mode)
272+
fmt.Fprintf(&b, "Status: %s\n", c.Status)
273+
if c.TokenType != "" {
274+
fmt.Fprintf(&b, "Token Type: %s\n", c.TokenType)
275+
}
276+
if len(c.Scopes) > 0 {
277+
fmt.Fprintf(&b, "Scopes: %s\n", strings.Join(c.Scopes, ", "))
278+
}
279+
if c.Audience != "" {
280+
fmt.Fprintf(&b, "Audience: %s\n", c.Audience)
281+
}
282+
if c.ObtainedVia != "" {
283+
fmt.Fprintf(&b, "Obtained Via: %s\n", c.ObtainedVia)
284+
}
285+
if c.ExpiresAt != nil {
286+
fmt.Fprintf(&b, "Expires: %s\n", c.ExpiresAt.Format("2006-01-02 15:04:05"))
287+
}
288+
if c.UpdatedAt != nil {
289+
fmt.Fprintf(&b, "Updated: %s\n", c.UpdatedAt.Format("2006-01-02 15:04:05"))
290+
}
291+
if c.ConnectPath != "" {
292+
fmt.Fprintf(&b, "Connect: mcpproxy credential connect %s\n", c.Server)
293+
}
294+
return strings.TrimRight(b.String(), "\n")
295+
}
296+
297+
func truncateCell(s string, maxLen int) string {
298+
if len(s) <= maxLen {
299+
return s
300+
}
301+
if maxLen <= 3 {
302+
return s[:maxLen]
303+
}
304+
return s[:maxLen-3] + "..."
305+
}
306+
307+
// emitCredentials prints the list in the resolved output format.
308+
func emitCredentials(creds []cliclient.CredentialStatus) error {
309+
switch ResolveOutputFormat() {
310+
case "json", "yaml":
311+
return emitFormatted(creds)
312+
default:
313+
fmt.Println(renderCredentialsTable(creds))
314+
return nil
315+
}
316+
}
317+
318+
func emitCredentialDetail(c cliclient.CredentialStatus) error {
319+
switch ResolveOutputFormat() {
320+
case "json", "yaml":
321+
return emitFormatted(c)
322+
default:
323+
fmt.Println(renderCredentialDetail(c))
324+
return nil
325+
}
326+
}
327+
328+
// emitFormatted renders arbitrary data via the resolved structured formatter.
329+
func emitFormatted(data interface{}) error {
330+
formatter, err := GetOutputFormatter()
331+
if err != nil {
332+
return fmt.Errorf("failed to get output formatter: %w", err)
333+
}
334+
out, err := formatter.Format(data)
335+
if err != nil {
336+
return fmt.Errorf("failed to format output: %w", err)
337+
}
338+
fmt.Println(out)
339+
return nil
340+
}

0 commit comments

Comments
 (0)