diff --git a/cmd/mcp.go b/cmd/mcp.go new file mode 100644 index 0000000..2b3d326 --- /dev/null +++ b/cmd/mcp.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// tael mcp — how to point an MCP client at this workspace. The endpoint +// speaks the Model Context Protocol over stateless Streamable HTTP and +// authenticates with the same tokens as the rest of the API, so the +// wiring is one URL and one header. + +var mcpCmd = &cobra.Command{ + Use: "mcp", + Short: "Connect an MCP client (Claude Code, Cursor, ...) to your workspace", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + endpoint := resolveConfiguration(command).BaseURL + "/api/v1/mcp" + if rendered, renderError := renderJSON(command, map[string]string{ + "endpoint": endpoint, + "transport": "http", + "header": "Authorization: Bearer ", + }); rendered || renderError != nil { + return renderError + } + out := command.OutOrStdout() + fmt.Fprintf(out, "Tael speaks the Model Context Protocol at:\n\n %s\n\n", endpoint) + fmt.Fprint(out, "Authenticate with a workspace token — make one with `tael tokens create ` — then:\n\n") + fmt.Fprintf(out, " claude mcp add tael --transport http %s \\\n --header \"Authorization: Bearer \"\n\n", endpoint) + fmt.Fprint(out, `Any other MCP client takes the same URL and header (transport "http" or +"streamable-http"). Read tools: list_apps, get_app, get_app_status, list_deploys, +get_app_logs, get_pipeline, get_workspace_status, list_incidents, list_tasks and +get_task. Write tools: deploy_app and create_task. +`) + return nil + }, +} + +func init() { + setRequiresAuth(mcpCmd, false) + rootCmd.AddCommand(mcpCmd) +} diff --git a/cmd/mcp_test.go b/cmd/mcp_test.go new file mode 100644 index 0000000..60c64d8 --- /dev/null +++ b/cmd/mcp_test.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestMCPPrintsTheEndpointAndTheWiring(t *testing.T) { + // The command never calls the API; the server only anchors the base URL. + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + output, runError := runCommand(t, server, "mcp") + if runError != nil { + t.Fatalf("tael mcp failed: %v", runError) + } + if !strings.Contains(output, server.URL+"/api/v1/mcp") { + t.Fatalf("expected the endpoint under the configured base URL, got:\n%s", output) + } + if !strings.Contains(output, "claude mcp add tael") { + t.Fatalf("expected a ready-to-paste client command, got:\n%s", output) + } + if !strings.Contains(output, "tael tokens create") { + t.Fatalf("expected the token hint, got:\n%s", output) + } +} + +func TestMCPJSONOutput(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + output, runError := runCommand(t, server, "mcp", "-o", "json") + if runError != nil { + t.Fatalf("tael mcp -o json failed: %v", runError) + } + var payload struct { + Endpoint string `json:"endpoint"` + Transport string `json:"transport"` + } + if unmarshalError := json.Unmarshal([]byte(output), &payload); unmarshalError != nil { + t.Fatalf("decoding JSON output: %v (output %q)", unmarshalError, output) + } + if payload.Endpoint != server.URL+"/api/v1/mcp" || payload.Transport != "http" { + t.Fatalf("unexpected payload: %+v", payload) + } +}