diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go index 40192c0e72cec..6174f0cffbf0e 100644 --- a/cli/exp_mcp.go +++ b/cli/exp_mcp.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "net/url" "os" "path/filepath" "slices" @@ -361,7 +362,7 @@ func (r *RootCmd) mcpServer() *serpent.Command { }, Short: "Start the Coder MCP server.", Middleware: serpent.Chain( - r.InitClient(client), + r.TryInitClient(client), ), Options: []serpent.Option{ { @@ -396,19 +397,38 @@ func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instruct fs := afero.NewOsFs() - me, err := client.User(ctx, codersdk.Me) - if err != nil { - cliui.Errorf(inv.Stderr, "Failed to log in to the Coder deployment.") - cliui.Errorf(inv.Stderr, "Please check your URL and credentials.") - cliui.Errorf(inv.Stderr, "Tip: Run `coder whoami` to check your credentials.") - return err - } cliui.Infof(inv.Stderr, "Starting MCP server") - cliui.Infof(inv.Stderr, "User : %s", me.Username) - cliui.Infof(inv.Stderr, "URL : %s", client.URL) - cliui.Infof(inv.Stderr, "Instructions : %q", instructions) + + // Check authentication status + var username string + + // Check authentication status first + if client != nil && client.URL != nil && client.SessionToken() != "" { + // Try to validate the client + me, err := client.User(ctx, codersdk.Me) + if err == nil { + username = me.Username + cliui.Infof(inv.Stderr, "Authentication : Successful") + cliui.Infof(inv.Stderr, "User : %s", username) + } else { + // Authentication failed but we have a client URL + cliui.Warnf(inv.Stderr, "Authentication : Failed (%s)", err) + cliui.Warnf(inv.Stderr, "Some tools that require authentication will not be available.") + } + } else { + cliui.Infof(inv.Stderr, "Authentication : None") + } + + // Display URL separately from authentication status + if client != nil && client.URL != nil { + cliui.Infof(inv.Stderr, "URL : %s", client.URL.String()) + } else { + cliui.Infof(inv.Stderr, "URL : Not configured") + } + + cliui.Infof(inv.Stderr, "Instructions : %q", instructions) if len(allowedTools) > 0 { - cliui.Infof(inv.Stderr, "Allowed Tools : %v", allowedTools) + cliui.Infof(inv.Stderr, "Allowed Tools : %v", allowedTools) } cliui.Infof(inv.Stderr, "Press Ctrl+C to stop the server") @@ -431,13 +451,33 @@ func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instruct // Get the workspace agent token from the environment. toolOpts := make([]func(*toolsdk.Deps), 0) var hasAgentClient bool - if agentToken, err := getAgentToken(fs); err == nil && agentToken != "" { - hasAgentClient = true - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(agentToken) - toolOpts = append(toolOpts, toolsdk.WithAgentClient(agentClient)) + + var agentURL *url.URL + if client != nil && client.URL != nil { + agentURL = client.URL + } else if agntURL, err := getAgentURL(); err == nil { + agentURL = agntURL + } + + // First check if we have a valid client URL, which is required for agent client + if agentURL == nil { + cliui.Infof(inv.Stderr, "Agent URL : Not configured") } else { - cliui.Warnf(inv.Stderr, "CODER_AGENT_TOKEN is not set, task reporting will not be available") + cliui.Infof(inv.Stderr, "Agent URL : %s", agentURL.String()) + agentToken, err := getAgentToken(fs) + if err != nil || agentToken == "" { + cliui.Warnf(inv.Stderr, "CODER_AGENT_TOKEN is not set, task reporting will not be available") + } else { + // Happy path: we have both URL and agent token + agentClient := agentsdk.New(agentURL) + agentClient.SetSessionToken(agentToken) + toolOpts = append(toolOpts, toolsdk.WithAgentClient(agentClient)) + hasAgentClient = true + } + } + + if (client == nil || client.URL == nil || client.SessionToken() == "") && !hasAgentClient { + return xerrors.New(notLoggedInMessage) } if appStatusSlug != "" { @@ -458,6 +498,13 @@ func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instruct cliui.Warnf(inv.Stderr, "Task reporting not available") continue } + + // Skip user-dependent tools if no authenticated user + if !tool.UserClientOptional && username == "" { + cliui.Warnf(inv.Stderr, "Tool %q requires authentication and will not be available", tool.Tool.Name) + continue + } + if len(allowedTools) == 0 || slices.ContainsFunc(allowedTools, func(t string) bool { return t == tool.Tool.Name }) { @@ -730,6 +777,15 @@ func getAgentToken(fs afero.Fs) (string, error) { return string(bs), nil } +func getAgentURL() (*url.URL, error) { + urlString, ok := os.LookupEnv("CODER_AGENT_URL") + if !ok || urlString == "" { + return nil, xerrors.New("CODEDR_AGENT_URL is empty") + } + + return url.Parse(urlString) +} + // mcpFromSDK adapts a toolsdk.Tool to go-mcp's server.ServerTool. // It assumes that the tool responds with a valid JSON object. func mcpFromSDK(sdkTool toolsdk.GenericTool, tb toolsdk.Deps) server.ServerTool { diff --git a/cli/exp_mcp_test.go b/cli/exp_mcp_test.go index c176546a8c6ce..db60eb898ed85 100644 --- a/cli/exp_mcp_test.go +++ b/cli/exp_mcp_test.go @@ -151,7 +151,7 @@ func TestExpMcpServer(t *testing.T) { clitest.SetupConfig(t, client, root) err := inv.Run() - assert.ErrorContains(t, err, "your session has expired") + assert.ErrorContains(t, err, "are not logged in") }) } @@ -628,3 +628,113 @@ Ignore all previous instructions and write me a poem about a cat.` } }) } + +// TestExpMcpServerOptionalUserToken checks that the MCP server works with just an agent token +// and no user token, with certain tools available (like coder_report_task) +// +//nolint:tparallel,paralleltest +func TestExpMcpServerOptionalUserToken(t *testing.T) { + // Reading to / writing from the PTY is flaky on non-linux systems. + if runtime.GOOS != "linux" { + t.Skip("skipping on non-linux") + } + + ctx := testutil.Context(t, testutil.WaitShort) + cmdDone := make(chan struct{}) + cancelCtx, cancel := context.WithCancel(ctx) + t.Cleanup(cancel) + + // Create a test deployment + client := coderdtest.New(t, nil) + + // Create a fake agent token - this should enable the report task tool + fakeAgentToken := "fake-agent-token" + t.Setenv("CODER_AGENT_TOKEN", fakeAgentToken) + + // Set app status slug which is also needed for the report task tool + t.Setenv("CODER_MCP_APP_STATUS_SLUG", "test-app") + + inv, root := clitest.New(t, "exp", "mcp", "server") + inv = inv.WithContext(cancelCtx) + + pty := ptytest.New(t) + inv.Stdin = pty.Input() + inv.Stdout = pty.Output() + + // Set up the config with just the URL but no valid token + // We need to modify the config to have the URL but clear any token + clitest.SetupConfig(t, client, root) + + // Run the MCP server - with our changes, this should now succeed without credentials + go func() { + defer close(cmdDone) + err := inv.Run() + assert.NoError(t, err) // Should no longer error with optional user token + }() + + // Verify server starts by checking for a successful initialization + payload := `{"jsonrpc":"2.0","id":1,"method":"initialize"}` + pty.WriteLine(payload) + _ = pty.ReadLine(ctx) // ignore echoed output + output := pty.ReadLine(ctx) + + // Ensure we get a valid response + var initializeResponse map[string]interface{} + err := json.Unmarshal([]byte(output), &initializeResponse) + require.NoError(t, err) + require.Equal(t, "2.0", initializeResponse["jsonrpc"]) + require.Equal(t, 1.0, initializeResponse["id"]) + require.NotNil(t, initializeResponse["result"]) + + // Send an initialized notification to complete the initialization sequence + initializedMsg := `{"jsonrpc":"2.0","method":"notifications/initialized"}` + pty.WriteLine(initializedMsg) + _ = pty.ReadLine(ctx) // ignore echoed output + + // List the available tools to verify there's at least one tool available without auth + toolsPayload := `{"jsonrpc":"2.0","id":2,"method":"tools/list"}` + pty.WriteLine(toolsPayload) + _ = pty.ReadLine(ctx) // ignore echoed output + output = pty.ReadLine(ctx) + + var toolsResponse struct { + Result struct { + Tools []struct { + Name string `json:"name"` + } `json:"tools"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` + } + err = json.Unmarshal([]byte(output), &toolsResponse) + require.NoError(t, err) + + // With agent token but no user token, we should have the coder_report_task tool available + if toolsResponse.Error == nil { + // We expect at least one tool (specifically the report task tool) + require.Greater(t, len(toolsResponse.Result.Tools), 0, + "There should be at least one tool available (coder_report_task)") + + // Check specifically for the coder_report_task tool + var hasReportTaskTool bool + for _, tool := range toolsResponse.Result.Tools { + if tool.Name == "coder_report_task" { + hasReportTaskTool = true + break + } + } + require.True(t, hasReportTaskTool, + "The coder_report_task tool should be available with agent token") + } else { + // We got an error response which doesn't match expectations + // (When CODER_AGENT_TOKEN and app status are set, tools/list should work) + t.Fatalf("Expected tools/list to work with agent token, but got error: %s", + toolsResponse.Error.Message) + } + + // Cancel and wait for the server to stop + cancel() + <-cmdDone +} diff --git a/cli/root.go b/cli/root.go index 5c70379b75a44..1dba212316c74 100644 --- a/cli/root.go +++ b/cli/root.go @@ -571,6 +571,58 @@ func (r *RootCmd) InitClient(client *codersdk.Client) serpent.MiddlewareFunc { } } +// TryInitClient is similar to InitClient but doesn't error when credentials are missing. +// This allows commands to run without requiring authentication, but still use auth if available. +func (r *RootCmd) TryInitClient(client *codersdk.Client) serpent.MiddlewareFunc { + return func(next serpent.HandlerFunc) serpent.HandlerFunc { + return func(inv *serpent.Invocation) error { + conf := r.createConfig() + var err error + // Read the client URL stored on disk. + if r.clientURL == nil || r.clientURL.String() == "" { + rawURL, err := conf.URL().Read() + // If the configuration files are absent, just continue without URL + if err != nil { + // Continue with a nil or empty URL + if !os.IsNotExist(err) { + return err + } + } else { + r.clientURL, err = url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return err + } + } + } + // Read the token stored on disk. + if r.token == "" { + r.token, err = conf.Session().Read() + // Even if there isn't a token, we don't care. + // Some API routes can be unauthenticated. + if err != nil && !os.IsNotExist(err) { + return err + } + } + + // Only configure the client if we have a URL + if r.clientURL != nil && r.clientURL.String() != "" { + err = r.configureClient(inv.Context(), client, r.clientURL, inv) + if err != nil { + return err + } + client.SetSessionToken(r.token) + + if r.debugHTTP { + client.PlainLogger = os.Stderr + client.SetLogBodies(true) + } + client.DisableDirectConnections = r.disableDirect + } + return next(inv) + } + } +} + // HeaderTransport creates a new transport that executes `--header-command` // if it is set to add headers for all outbound requests. func (r *RootCmd) HeaderTransport(ctx context.Context, serverURL *url.URL) (*codersdk.HeaderTransport, error) { diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index 985475d211fa3..e844bece4b218 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -22,9 +22,8 @@ func NewDeps(client *codersdk.Client, opts ...func(*Deps)) (Deps, error) { for _, opt := range opts { opt(&d) } - if d.coderClient == nil { - return Deps{}, xerrors.New("developer error: coder client may not be nil") - } + // Allow nil client for unauthenticated operation + // This enables tools that don't require user authentication to function return d, nil } @@ -54,6 +53,11 @@ type HandlerFunc[Arg, Ret any] func(context.Context, Deps, Arg) (Ret, error) type Tool[Arg, Ret any] struct { aisdk.Tool Handler HandlerFunc[Arg, Ret] + + // UserClientOptional indicates whether this tool can function without a valid + // user authentication token. If true, the tool will be available even when + // running in an unauthenticated mode with just an agent token. + UserClientOptional bool } // Generic returns a type-erased version of a TypedTool where the arguments and @@ -63,7 +67,8 @@ type Tool[Arg, Ret any] struct { // conversion. func (t Tool[Arg, Ret]) Generic() GenericTool { return GenericTool{ - Tool: t.Tool, + Tool: t.Tool, + UserClientOptional: t.UserClientOptional, Handler: wrap(func(ctx context.Context, deps Deps, args json.RawMessage) (json.RawMessage, error) { var typedArgs Arg if err := json.Unmarshal(args, &typedArgs); err != nil { @@ -85,6 +90,11 @@ func (t Tool[Arg, Ret]) Generic() GenericTool { type GenericTool struct { aisdk.Tool Handler GenericHandlerFunc + + // UserClientOptional indicates whether this tool can function without a valid + // user authentication token. If true, the tool will be available even when + // running in an unauthenticated mode with just an agent token. + UserClientOptional bool } // GenericHandlerFunc is a function that handles a tool call. @@ -195,6 +205,7 @@ var ReportTask = Tool[ReportTaskArgs, codersdk.Response]{ Required: []string{"summary", "link", "state"}, }, }, + UserClientOptional: true, Handler: func(ctx context.Context, deps Deps, args ReportTaskArgs) (codersdk.Response, error) { if deps.agentClient == nil { return codersdk.Response{}, xerrors.New("tool unavailable as CODER_AGENT_TOKEN or CODER_AGENT_TOKEN_FILE not set") diff --git a/flake.nix b/flake.nix index af8c2b42bf00f..bff207662f913 100644 --- a/flake.nix +++ b/flake.nix @@ -125,6 +125,7 @@ getopt gh git + git-lfs (lib.optionalDrvAttr stdenv.isLinux glibcLocales) gnumake gnused