diff --git a/coderd/mcp/mcp.go b/coderd/mcp/mcp.go index f17ab5ae7cd93..3696beff500a1 100644 --- a/coderd/mcp/mcp.go +++ b/coderd/mcp/mcp.go @@ -67,7 +67,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.streamableServer.ServeHTTP(w, r) } -// RegisterTools registers all available MCP tools with the server +// Register all available MCP tools with the server excluding: +// - ReportTask - which requires dependencies not available in the remote MCP context +// - ChatGPT search and fetch tools, which are redundant with the standard tools. func (s *Server) RegisterTools(client *codersdk.Client) error { if client == nil { return xerrors.New("client cannot be nil: MCP HTTP server requires authenticated client") @@ -79,10 +81,36 @@ func (s *Server) RegisterTools(client *codersdk.Client) error { return xerrors.Errorf("failed to initialize tool dependencies: %w", err) } - // Register all available tools, but exclude tools that require dependencies not available in the - // remote MCP context for _, tool := range toolsdk.All { - if tool.Name == toolsdk.ToolNameReportTask { + // the ReportTask tool requires dependencies not available in the remote MCP context + // the ChatGPT search and fetch tools are redundant with the standard tools. + if tool.Name == toolsdk.ToolNameReportTask || + tool.Name == toolsdk.ToolNameChatGPTSearch || tool.Name == toolsdk.ToolNameChatGPTFetch { + continue + } + + s.mcpServer.AddTools(mcpFromSDK(tool, toolDeps)) + } + return nil +} + +// ChatGPT tools are the search and fetch tools as defined in https://platform.openai.com/docs/mcp. +// We do not expose any extra ones because ChatGPT has an undocumented "Safety Scan" feature. +// In my experiments, if I included extra tools in the MCP server, ChatGPT would often - but not always - +// refuse to add Coder as a connector. +func (s *Server) RegisterChatGPTTools(client *codersdk.Client) error { + if client == nil { + return xerrors.New("client cannot be nil: MCP HTTP server requires authenticated client") + } + + // Create tool dependencies + toolDeps, err := toolsdk.NewDeps(client) + if err != nil { + return xerrors.Errorf("failed to initialize tool dependencies: %w", err) + } + + for _, tool := range toolsdk.All { + if tool.Name != toolsdk.ToolNameChatGPTSearch && tool.Name != toolsdk.ToolNameChatGPTFetch { continue } diff --git a/coderd/mcp/mcp_e2e_test.go b/coderd/mcp/mcp_e2e_test.go index 248786405fda9..b831d150c2c0d 100644 --- a/coderd/mcp/mcp_e2e_test.go +++ b/coderd/mcp/mcp_e2e_test.go @@ -1215,6 +1215,155 @@ func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) { }) } +func TestMCPHTTP_E2E_ChatGPTEndpoint(t *testing.T) { + t.Parallel() + + // Setup Coder server with authentication + coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + defer closer.Close() + + user := coderdtest.CreateFirstUser(t, coderClient) + + // Create template and workspace for testing search functionality + version := coderdtest.CreateTemplateVersion(t, coderClient, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, coderClient, version.ID) + template := coderdtest.CreateTemplate(t, coderClient, user.OrganizationID, version.ID) + + // Create MCP client pointing to the ChatGPT endpoint + mcpURL := api.AccessURL.String() + "/api/experimental/mcp/http?toolset=chatgpt" + + // Configure client with authentication headers using RFC 6750 Bearer token + mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL, + transport.WithHTTPHeaders(map[string]string{ + "Authorization": "Bearer " + coderClient.SessionToken(), + })) + require.NoError(t, err) + t.Cleanup(func() { + if closeErr := mcpClient.Close(); closeErr != nil { + t.Logf("Failed to close MCP client: %v", closeErr) + } + }) + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + defer cancel() + + // Start client + err = mcpClient.Start(ctx) + require.NoError(t, err) + + // Initialize connection + initReq := mcp.InitializeRequest{ + Params: mcp.InitializeParams{ + ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION, + ClientInfo: mcp.Implementation{ + Name: "test-chatgpt-client", + Version: "1.0.0", + }, + }, + } + + result, err := mcpClient.Initialize(ctx, initReq) + require.NoError(t, err) + require.Equal(t, mcpserver.MCPServerName, result.ServerInfo.Name) + require.Equal(t, mcp.LATEST_PROTOCOL_VERSION, result.ProtocolVersion) + require.NotNil(t, result.Capabilities) + + // Test tool listing - should only have search and fetch tools for ChatGPT + tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{}) + require.NoError(t, err) + require.NotEmpty(t, tools.Tools) + + // Verify we have exactly the ChatGPT tools and no others + var foundTools []string + for _, tool := range tools.Tools { + foundTools = append(foundTools, tool.Name) + } + + // ChatGPT endpoint should only expose search and fetch tools + assert.Contains(t, foundTools, toolsdk.ToolNameChatGPTSearch, "Should have ChatGPT search tool") + assert.Contains(t, foundTools, toolsdk.ToolNameChatGPTFetch, "Should have ChatGPT fetch tool") + assert.Len(t, foundTools, 2, "ChatGPT endpoint should only expose search and fetch tools") + + // Should NOT have other tools that are available in the standard endpoint + assert.NotContains(t, foundTools, toolsdk.ToolNameGetAuthenticatedUser, "Should not have authenticated user tool") + assert.NotContains(t, foundTools, toolsdk.ToolNameListWorkspaces, "Should not have list workspaces tool") + + t.Logf("ChatGPT endpoint tools: %v", foundTools) + + // Test search tool - search for templates + var searchTool *mcp.Tool + for _, tool := range tools.Tools { + if tool.Name == toolsdk.ToolNameChatGPTSearch { + searchTool = &tool + break + } + } + require.NotNil(t, searchTool, "Expected to find search tool") + + // Execute search for templates + searchReq := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: searchTool.Name, + Arguments: map[string]any{ + "query": "templates", + }, + }, + } + + searchResult, err := mcpClient.CallTool(ctx, searchReq) + require.NoError(t, err) + require.NotEmpty(t, searchResult.Content) + + // Verify the search result contains our template + assert.Len(t, searchResult.Content, 1) + if textContent, ok := searchResult.Content[0].(mcp.TextContent); ok { + assert.Equal(t, "text", textContent.Type) + assert.Contains(t, textContent.Text, template.ID.String(), "Search result should contain our test template") + t.Logf("Search result: %s", textContent.Text) + } else { + t.Errorf("Expected TextContent type, got %T", searchResult.Content[0]) + } + + // Test fetch tool + var fetchTool *mcp.Tool + for _, tool := range tools.Tools { + if tool.Name == toolsdk.ToolNameChatGPTFetch { + fetchTool = &tool + break + } + } + require.NotNil(t, fetchTool, "Expected to find fetch tool") + + // Execute fetch for the template + fetchReq := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: fetchTool.Name, + Arguments: map[string]any{ + "id": fmt.Sprintf("template:%s", template.ID.String()), + }, + }, + } + + fetchResult, err := mcpClient.CallTool(ctx, fetchReq) + require.NoError(t, err) + require.NotEmpty(t, fetchResult.Content) + + // Verify the fetch result contains template details + assert.Len(t, fetchResult.Content, 1) + if textContent, ok := fetchResult.Content[0].(mcp.TextContent); ok { + assert.Equal(t, "text", textContent.Type) + assert.Contains(t, textContent.Text, template.Name, "Fetch result should contain template name") + assert.Contains(t, textContent.Text, template.ID.String(), "Fetch result should contain template ID") + t.Logf("Fetch result contains template data") + } else { + t.Errorf("Expected TextContent type, got %T", fetchResult.Content[0]) + } + + t.Logf("ChatGPT endpoint E2E test successful: search and fetch tools working correctly") +} + // Helper function to parse URL safely in tests func mustParseURL(t *testing.T, rawURL string) *url.URL { u, err := url.Parse(rawURL) diff --git a/coderd/mcp_http.go b/coderd/mcp_http.go index 40aaaa1c40dd5..51082858fe55e 100644 --- a/coderd/mcp_http.go +++ b/coderd/mcp_http.go @@ -1,6 +1,7 @@ package coderd import ( + "fmt" "net/http" "cdr.dev/slog" @@ -11,7 +12,15 @@ import ( "github.com/coder/coder/v2/codersdk" ) +type MCPToolset string + +const ( + MCPToolsetStandard MCPToolset = "standard" + MCPToolsetChatGPT MCPToolset = "chatgpt" +) + // mcpHTTPHandler creates the MCP HTTP transport handler +// It supports a "toolset" query parameter to select the set of tools to register. func (api *API) mcpHTTPHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Create MCP server instance for each request @@ -23,14 +32,30 @@ func (api *API) mcpHTTPHandler() http.Handler { }) return } - authenticatedClient := codersdk.New(api.AccessURL) // Extract the original session token from the request authenticatedClient.SetSessionToken(httpmw.APITokenFromRequest(r)) - // Register tools with authenticated client - if err := mcpServer.RegisterTools(authenticatedClient); err != nil { - api.Logger.Warn(r.Context(), "failed to register MCP tools", slog.Error(err)) + toolset := MCPToolset(r.URL.Query().Get("toolset")) + // Default to standard toolset if no toolset is specified. + if toolset == "" { + toolset = MCPToolsetStandard + } + + switch toolset { + case MCPToolsetStandard: + if err := mcpServer.RegisterTools(authenticatedClient); err != nil { + api.Logger.Warn(r.Context(), "failed to register MCP tools", slog.Error(err)) + } + case MCPToolsetChatGPT: + if err := mcpServer.RegisterChatGPTTools(authenticatedClient); err != nil { + api.Logger.Warn(r.Context(), "failed to register MCP tools", slog.Error(err)) + } + default: + httpapi.Write(r.Context(), w, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Invalid toolset: %s", toolset), + }) + return } // Handle the MCP request diff --git a/codersdk/toolsdk/chatgpt.go b/codersdk/toolsdk/chatgpt.go new file mode 100644 index 0000000000000..c4bf5b5d4c174 --- /dev/null +++ b/codersdk/toolsdk/chatgpt.go @@ -0,0 +1,436 @@ +package toolsdk + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "golang.org/x/xerrors" + + "github.com/google/uuid" + + "github.com/coder/aisdk-go" + "github.com/coder/coder/v2/codersdk" +) + +type ObjectType string + +const ( + ObjectTypeTemplate ObjectType = "template" + ObjectTypeWorkspace ObjectType = "workspace" +) + +type ObjectID struct { + Type ObjectType + ID string +} + +func (o ObjectID) String() string { + return fmt.Sprintf("%s:%s", o.Type, o.ID) +} + +func parseObjectID(id string) (ObjectID, error) { + parts := strings.Split(id, ":") + if len(parts) != 2 || (parts[0] != "template" && parts[0] != "workspace") { + return ObjectID{}, xerrors.Errorf("invalid ID: %s", id) + } + return ObjectID{ + Type: ObjectType(parts[0]), + ID: parts[1], + }, nil +} + +func createObjectID(objectType ObjectType, id string) ObjectID { + return ObjectID{ + Type: objectType, + ID: id, + } +} + +func searchTemplates(ctx context.Context, deps Deps, query string) ([]SearchResultItem, error) { + serverURL := deps.ServerURL() + templates, err := deps.coderClient.Templates(ctx, codersdk.TemplateFilter{ + SearchQuery: query, + }) + if err != nil { + return nil, err + } + results := make([]SearchResultItem, len(templates)) + for i, template := range templates { + results[i] = SearchResultItem{ + ID: createObjectID(ObjectTypeTemplate, template.ID.String()).String(), + Title: template.DisplayName, + Text: template.Description, + URL: fmt.Sprintf("%s/templates/%s/%s", serverURL, template.OrganizationName, template.Name), + } + } + return results, nil +} + +func searchWorkspaces(ctx context.Context, deps Deps, query string) ([]SearchResultItem, error) { + serverURL := deps.ServerURL() + workspaces, err := deps.coderClient.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: query, + }) + if err != nil { + return nil, err + } + results := make([]SearchResultItem, len(workspaces.Workspaces)) + for i, workspace := range workspaces.Workspaces { + results[i] = SearchResultItem{ + ID: createObjectID(ObjectTypeWorkspace, workspace.ID.String()).String(), + Title: workspace.Name, + Text: fmt.Sprintf("Owner: %s\nTemplate: %s\nLatest transition: %s", workspace.OwnerName, workspace.TemplateDisplayName, workspace.LatestBuild.Transition), + URL: fmt.Sprintf("%s/%s/%s", serverURL, workspace.OwnerName, workspace.Name), + } + } + return results, nil +} + +type SearchQueryType string + +const ( + SearchQueryTypeTemplates SearchQueryType = "templates" + SearchQueryTypeWorkspaces SearchQueryType = "workspaces" +) + +type SearchQuery struct { + Type SearchQueryType + Query string +} + +func parseSearchQuery(query string) (SearchQuery, error) { + parts := strings.Split(query, "/") + queryType := SearchQueryType(parts[0]) + if !(queryType == SearchQueryTypeTemplates || queryType == SearchQueryTypeWorkspaces) { + return SearchQuery{}, xerrors.Errorf("invalid query: %s", query) + } + queryString := "" + if len(parts) > 1 { + queryString = strings.Join(parts[1:], "/") + } + return SearchQuery{ + Type: queryType, + Query: queryString, + }, nil +} + +type SearchArgs struct { + Query string `json:"query"` +} + +type SearchResultItem struct { + ID string `json:"id"` + Title string `json:"title"` + Text string `json:"text"` + URL string `json:"url"` +} + +type SearchResult struct { + Results []SearchResultItem `json:"results"` +} + +// Implements the "search" tool as described in https://platform.openai.com/docs/mcp#search-tool. +// From my experiments with ChatGPT, it has access to the description that is provided in the +// tool definition. This is in contrast to the "fetch" tool, where ChatGPT does not have access +// to the description. +var ChatGPTSearch = Tool[SearchArgs, SearchResult]{ + Tool: aisdk.Tool{ + Name: ToolNameChatGPTSearch, + // Note: the queries are passed directly to the list workspaces and list templates + // endpoints. The list of accepted parameters below is not exhaustive - some are omitted + // because they are not as useful in ChatGPT. + Description: `Search for templates, workspaces, and files in workspaces. + +To pick what you want to search for, use the following query formats: + +- ` + "`" + `templates/` + "`" + `: List templates. The query accepts the following, optional parameters delineated by whitespace: + - "name:" - Fuzzy search by template name (substring matching). Example: "name:docker" + - "organization:" - Filter by organization ID or name. Example: "organization:coder" + - "deprecated:" - Filter by deprecated status. Example: "deprecated:true" + - "deleted:" - Filter by deleted status. Example: "deleted:true" + - "has-ai-task:" - Filter by whether the template has an AI task. Example: "has-ai-task:true" +- ` + "`" + `workspaces/` + "`" + `: List workspaces. The query accepts the following, optional parameters delineated by whitespace: + - "owner:" - Filter by workspace owner (username or "me"). Example: "owner:alice" or "owner:me" + - "template:" - Filter by template name. Example: "template:web-development" + - "name:" - Filter by workspace name (substring matching). Example: "name:project" + - "organization:" - Filter by organization ID or name. Example: "organization:engineering" + - "status:" - Filter by workspace/build status. Values: starting, stopping, deleting, deleted, stopped, started, running, pending, canceling, canceled, failed. Example: "status:running" + - "has-agent:" - Filter by agent connectivity status. Values: connecting, connected, disconnected, timeout. Example: "has-agent:connected" + - "dormant:" - Filter dormant workspaces. Example: "dormant:true" + - "outdated:" - Filter workspaces using outdated template versions. Example: "outdated:true" + - "last_used_after:" - Filter workspaces last used after a specific date. Example: "last_used_after:2023-12-01T00:00:00Z" + - "last_used_before:" - Filter workspaces last used before a specific date. Example: "last_used_before:2023-12-31T23:59:59Z" + - "has-ai-task:" - Filter workspaces with AI tasks. Example: "has-ai-task:true" + - "param:" or "param:=" - Match workspaces by build parameters. Example: "param:environment=production" or "param:gpu" + +# Examples + +## Listing templates + +List all templates without any filters. + +` + "```" + `json +{ + "query": "templates" +} +` + "```" + ` + +List all templates with a "docker" substring in the name. + +` + "```" + `json +{ + "query": "templates/name:docker" +} +` + "```" + ` + +List templates in a specific organization. + +` + "```" + `json +{ + "query": "templates/organization:engineering" +} +` + "```" + ` + +List deprecated templates. + +` + "```" + `json +{ + "query": "templates/deprecated:true" +} +` + "```" + ` + +List templates that have AI tasks. + +` + "```" + `json +{ + "query": "templates/has-ai-task:true" +} +` + "```" + ` + +List templates with multiple filters - non-deprecated templates with "web" in the name. + +` + "```" + `json +{ + "query": "templates/name:web deprecated:false" +} +` + "```" + ` + +List deleted templates (requires appropriate permissions). + +` + "```" + `json +{ + "query": "templates/deleted:true" +} +` + "```" + ` + +## Listing workspaces + +List all workspaces belonging to the current user. + +` + "```" + `json +{ + "query": "workspaces/owner:me" +} +` + "```" + ` + +or + +` + "```" + `json +{ + "query": "workspaces" +} +` + "```" + ` + +List all workspaces belonging to a user with username "josh". + +` + "```" + `json +{ + "query": "workspaces/owner:josh" +} +` + "```" + ` + +List all running workspaces. + +` + "```" + `json +{ + "query": "workspaces/status:running" +} +` + "```" + ` + +List workspaces using a specific template. + +` + "```" + `json +{ + "query": "workspaces/template:web-development" +} +` + "```" + ` + +List dormant workspaces. + +` + "```" + `json +{ + "query": "workspaces/dormant:true" +} +` + "```" + ` + +List workspaces with connected agents. + +` + "```" + `json +{ + "query": "workspaces/has-agent:connected" +} +` + "```" + ` + +List workspaces with multiple filters - running workspaces owned by "alice". + +` + "```" + `json +{ + "query": "workspaces/owner:alice status:running" +} +` + "```" + ` +`, + Schema: aisdk.Schema{ + Properties: map[string]any{ + "query": map[string]any{ + "type": "string", + }, + }, + Required: []string{"query"}, + }, + }, + Handler: func(ctx context.Context, deps Deps, args SearchArgs) (SearchResult, error) { + query, err := parseSearchQuery(args.Query) + if err != nil { + return SearchResult{}, err + } + switch query.Type { + case SearchQueryTypeTemplates: + results, err := searchTemplates(ctx, deps, query.Query) + if err != nil { + return SearchResult{}, err + } + return SearchResult{Results: results}, nil + case SearchQueryTypeWorkspaces: + searchQuery := query.Query + if searchQuery == "" { + searchQuery = "owner:me" + } + results, err := searchWorkspaces(ctx, deps, searchQuery) + if err != nil { + return SearchResult{}, err + } + return SearchResult{Results: results}, nil + } + return SearchResult{}, xerrors.Errorf("reached unreachable code with query: %s", args.Query) + }, +} + +func fetchWorkspace(ctx context.Context, deps Deps, workspaceID string) (FetchResult, error) { + parsedID, err := uuid.Parse(workspaceID) + if err != nil { + return FetchResult{}, xerrors.Errorf("invalid workspace ID, must be a valid UUID: %w", err) + } + workspace, err := deps.coderClient.Workspace(ctx, parsedID) + if err != nil { + return FetchResult{}, err + } + workspaceJSON, err := json.Marshal(workspace) + if err != nil { + return FetchResult{}, xerrors.Errorf("failed to marshal workspace: %w", err) + } + return FetchResult{ + ID: workspace.ID.String(), + Title: workspace.Name, + Text: string(workspaceJSON), + URL: fmt.Sprintf("%s/%s/%s", deps.ServerURL(), workspace.OwnerName, workspace.Name), + }, nil +} + +func fetchTemplate(ctx context.Context, deps Deps, templateID string) (FetchResult, error) { + parsedID, err := uuid.Parse(templateID) + if err != nil { + return FetchResult{}, xerrors.Errorf("invalid template ID, must be a valid UUID: %w", err) + } + template, err := deps.coderClient.Template(ctx, parsedID) + if err != nil { + return FetchResult{}, err + } + templateJSON, err := json.Marshal(template) + if err != nil { + return FetchResult{}, xerrors.Errorf("failed to marshal template: %w", err) + } + return FetchResult{ + ID: template.ID.String(), + Title: template.DisplayName, + Text: string(templateJSON), + URL: fmt.Sprintf("%s/templates/%s/%s", deps.ServerURL(), template.OrganizationName, template.Name), + }, nil +} + +type FetchArgs struct { + ID string `json:"id"` +} + +type FetchResult struct { + ID string `json:"id"` + Title string `json:"title"` + Text string `json:"text"` + URL string `json:"url"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// Implements the "fetch" tool as described in https://platform.openai.com/docs/mcp#fetch-tool. +// From my experiments with ChatGPT, it seems that it does not see the description that is +// provided in the tool definition. ChatGPT sees "fetch" as a very simple tool that can take +// an ID returned by the "search" tool and return the full details of the object. +var ChatGPTFetch = Tool[FetchArgs, FetchResult]{ + Tool: aisdk.Tool{ + Name: ToolNameChatGPTFetch, + Description: `Fetch a template or workspace. + + ID is a unique identifier for the template or workspace. It is a combination of the type and the ID. + + # Examples + + Fetch a template with ID "56f13b5e-be0f-4a17-bdb2-aaacc3353ea7". + + ` + "```" + `json + { + "id": "template:56f13b5e-be0f-4a17-bdb2-aaacc3353ea7" + } + ` + "```" + ` + + Fetch a workspace with ID "fcb6fc42-ba88-4175-9508-88e6a554a61a". + + ` + "```" + `json + { + "id": "workspace:fcb6fc42-ba88-4175-9508-88e6a554a61a" + } + ` + "```" + ` + `, + + Schema: aisdk.Schema{ + Properties: map[string]any{ + "id": map[string]any{ + "type": "string", + }, + }, + Required: []string{"id"}, + }, + }, + Handler: func(ctx context.Context, deps Deps, args FetchArgs) (FetchResult, error) { + objectID, err := parseObjectID(args.ID) + if err != nil { + return FetchResult{}, err + } + switch objectID.Type { + case ObjectTypeTemplate: + return fetchTemplate(ctx, deps, objectID.ID) + case ObjectTypeWorkspace: + return fetchWorkspace(ctx, deps, objectID.ID) + } + return FetchResult{}, xerrors.Errorf("reached unreachable code with object ID: %s", args.ID) + }, +} diff --git a/codersdk/toolsdk/chatgpt_test.go b/codersdk/toolsdk/chatgpt_test.go new file mode 100644 index 0000000000000..c8a05ba41411b --- /dev/null +++ b/codersdk/toolsdk/chatgpt_test.go @@ -0,0 +1,566 @@ +// nolint:gocritic // This is a test package, so database types do not end up in the build +package toolsdk_test + +import ( + "encoding/json" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/toolsdk" +) + +func TestChatGPTSearch_TemplateSearch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query string + setupTemplates int + expectError bool + errorContains string + }{ + { + name: "ValidTemplatesQuery_MultipleTemplates", + query: "templates", + setupTemplates: 3, + expectError: false, + }, + { + name: "ValidTemplatesQuery_NoTemplates", + query: "templates", + setupTemplates: 0, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Setup + client, store := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + + // Create templates as needed + var expectedTemplates []database.Template + for i := 0; i < tt.setupTemplates; i++ { + template := dbfake.TemplateVersion(t, store). + Seed(database.TemplateVersion{ + OrganizationID: owner.OrganizationID, + CreatedBy: owner.UserID, + }).Do() + expectedTemplates = append(expectedTemplates, template.Template) + } + + // Create tool dependencies + deps, err := toolsdk.NewDeps(client) + require.NoError(t, err) + + // Execute tool + args := toolsdk.SearchArgs{Query: tt.query} + result, err := testTool(t, toolsdk.ChatGPTSearch, deps, args) + + // Verify results + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + return + } + + require.NoError(t, err) + require.Len(t, result.Results, tt.setupTemplates) + + // Validate result format for each template + templateIDsFound := make(map[string]bool) + for _, item := range result.Results { + require.NotEmpty(t, item.ID) + require.Contains(t, item.ID, "template:") + require.NotEmpty(t, item.Title) + require.Contains(t, item.URL, "/templates/") + + // Track that we found this template ID + templateIDsFound[item.ID] = true + } + + // Verify all expected templates are present + for _, expectedTemplate := range expectedTemplates { + expectedID := "template:" + expectedTemplate.ID.String() + require.True(t, templateIDsFound[expectedID], "Expected template %s not found in results", expectedID) + } + }) + } +} + +func TestChatGPTSearch_TemplateMultipleFilters(t *testing.T) { + t.Parallel() + + // Setup + client, store := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + org2 := dbgen.Organization(t, store, database.Organization{ + Name: "org2", + }) + + dbgen.Template(t, store, database.Template{ + OrganizationID: owner.OrganizationID, + CreatedBy: owner.UserID, + Name: "docker-development", // Name contains "docker" + DisplayName: "Docker Development", + Description: "A Docker-based development template", + }) + + // Create another template that doesn't contain "docker" + dbgen.Template(t, store, database.Template{ + OrganizationID: org2.ID, + CreatedBy: owner.UserID, + Name: "python-web", // Name doesn't contain "docker" + DisplayName: "Python Web", + Description: "A Python web development template", + }) + + // Create third template with "docker" in name + dockerTemplate2 := dbgen.Template(t, store, database.Template{ + OrganizationID: org2.ID, + CreatedBy: owner.UserID, + Name: "old-docker-template", // Name contains "docker" + DisplayName: "Old Docker Template", + Description: "An old Docker template", + }) + + // Create tool dependencies + deps, err := toolsdk.NewDeps(client) + require.NoError(t, err) + + args := toolsdk.SearchArgs{Query: "templates/name:docker organization:org2"} + result, err := testTool(t, toolsdk.ChatGPTSearch, deps, args) + + // Verify results + require.NoError(t, err) + require.Len(t, result.Results, 1, "Should match only the docker template in org2") + + expectedID := "template:" + dockerTemplate2.ID.String() + require.Equal(t, expectedID, result.Results[0].ID, "Should match the docker template in org2") +} + +func TestChatGPTSearch_WorkspaceSearch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query string + setupOwner string // "self" or "other" + setupWorkspace bool + expectError bool + errorContains string + }{ + { + name: "ValidWorkspacesQuery_CurrentUser", + query: "workspaces", + setupOwner: "self", + setupWorkspace: true, + expectError: false, + }, + { + name: "ValidWorkspacesQuery_CurrentUserMe", + query: "workspaces/owner:me", + setupOwner: "self", + setupWorkspace: true, + expectError: false, + }, + { + name: "ValidWorkspacesQuery_NoWorkspaces", + query: "workspaces", + setupOwner: "self", + setupWorkspace: false, + expectError: false, + }, + { + name: "ValidWorkspacesQuery_SpecificUser", + query: "workspaces/owner:otheruser", + setupOwner: "other", + setupWorkspace: true, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Setup + client, store := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + + var workspaceOwnerID uuid.UUID + var workspaceClient *codersdk.Client + if tt.setupOwner == "self" { + workspaceOwnerID = owner.UserID + workspaceClient = client + } else { + var workspaceOwner codersdk.User + workspaceClient, workspaceOwner = coderdtest.CreateAnotherUserMutators(t, client, owner.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) { + r.Username = "otheruser" + }) + workspaceOwnerID = workspaceOwner.ID + } + + // Create workspace if needed + var expectedWorkspace database.WorkspaceTable + if tt.setupWorkspace { + workspace := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + Name: "test-workspace", + OrganizationID: owner.OrganizationID, + OwnerID: workspaceOwnerID, + }).Do() + expectedWorkspace = workspace.Workspace + } + + // Create tool dependencies + deps, err := toolsdk.NewDeps(workspaceClient) + require.NoError(t, err) + + // Execute tool + args := toolsdk.SearchArgs{Query: tt.query} + result, err := testTool(t, toolsdk.ChatGPTSearch, deps, args) + + // Verify results + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + return + } + + require.NoError(t, err) + + if tt.setupWorkspace { + require.Len(t, result.Results, 1) + item := result.Results[0] + require.NotEmpty(t, item.ID) + require.Contains(t, item.ID, "workspace:") + require.Equal(t, expectedWorkspace.Name, item.Title) + require.Contains(t, item.Text, "Owner:") + require.Contains(t, item.Text, "Template:") + require.Contains(t, item.Text, "Latest transition:") + require.Contains(t, item.URL, expectedWorkspace.Name) + } else { + require.Len(t, result.Results, 0) + } + }) + } +} + +func TestChatGPTSearch_QueryParsing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query string + expectError bool + errorMsg string + }{ + { + name: "ValidTemplatesQuery", + query: "templates", + expectError: false, + }, + { + name: "ValidWorkspacesQuery", + query: "workspaces", + expectError: false, + }, + { + name: "ValidWorkspacesMeQuery", + query: "workspaces/owner:me", + expectError: false, + }, + { + name: "ValidWorkspacesUserQuery", + query: "workspaces/owner:testuser", + expectError: false, + }, + { + name: "InvalidQueryType", + query: "users", + expectError: true, + errorMsg: "invalid query", + }, + { + name: "EmptyQuery", + query: "", + expectError: true, + errorMsg: "invalid query", + }, + { + name: "MalformedQuery", + query: "invalidtype/somequery", + expectError: true, + errorMsg: "invalid query", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Setup minimal environment + client, _ := coderdtest.NewWithDatabase(t, nil) + coderdtest.CreateFirstUser(t, client) + + deps, err := toolsdk.NewDeps(client) + require.NoError(t, err) + + // Execute tool + args := toolsdk.SearchArgs{Query: tt.query} + _, err = testTool(t, toolsdk.ChatGPTSearch, deps, args) + + // Verify results + if tt.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestChatGPTFetch_TemplateFetch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupTemplate bool + objectID string // if empty, will use real template ID + expectError bool + errorContains string + }{ + { + name: "ValidTemplateFetch", + setupTemplate: true, + expectError: false, + }, + { + name: "NonExistentTemplateID", + setupTemplate: false, + objectID: "template:" + uuid.NewString(), + expectError: true, + errorContains: "Resource not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Setup + client, store := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + + var templateID string + var expectedTemplate database.Template + if tt.setupTemplate { + template := dbfake.TemplateVersion(t, store). + Seed(database.TemplateVersion{ + OrganizationID: owner.OrganizationID, + CreatedBy: owner.UserID, + }).Do() + expectedTemplate = template.Template + templateID = "template:" + template.Template.ID.String() + } else if tt.objectID != "" { + templateID = tt.objectID + } + + // Create tool dependencies + deps, err := toolsdk.NewDeps(client) + require.NoError(t, err) + + // Execute tool + args := toolsdk.FetchArgs{ID: templateID} + result, err := testTool(t, toolsdk.ChatGPTFetch, deps, args) + + // Verify results + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + return + } + + require.NoError(t, err) + require.Equal(t, expectedTemplate.ID.String(), result.ID) + require.Equal(t, expectedTemplate.DisplayName, result.Title) + require.NotEmpty(t, result.Text) + require.Contains(t, result.URL, "/templates/") + require.Contains(t, result.URL, expectedTemplate.Name) + + // Validate JSON marshaling + var templateData codersdk.Template + err = json.Unmarshal([]byte(result.Text), &templateData) + require.NoError(t, err) + require.Equal(t, expectedTemplate.ID, templateData.ID) + }) + } +} + +func TestChatGPTFetch_WorkspaceFetch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupWorkspace bool + objectID string // if empty, will use real workspace ID + expectError bool + errorContains string + }{ + { + name: "ValidWorkspaceFetch", + setupWorkspace: true, + expectError: false, + }, + { + name: "NonExistentWorkspaceID", + setupWorkspace: false, + objectID: "workspace:" + uuid.NewString(), + expectError: true, + errorContains: "Resource not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Setup + client, store := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + + var workspaceID string + var expectedWorkspace database.WorkspaceTable + if tt.setupWorkspace { + workspace := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OrganizationID: owner.OrganizationID, + OwnerID: owner.UserID, + }).Do() + expectedWorkspace = workspace.Workspace + workspaceID = "workspace:" + workspace.Workspace.ID.String() + } else if tt.objectID != "" { + workspaceID = tt.objectID + } + + // Create tool dependencies + deps, err := toolsdk.NewDeps(client) + require.NoError(t, err) + + // Execute tool + args := toolsdk.FetchArgs{ID: workspaceID} + result, err := testTool(t, toolsdk.ChatGPTFetch, deps, args) + + // Verify results + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + return + } + + require.NoError(t, err) + require.Equal(t, expectedWorkspace.ID.String(), result.ID) + require.Equal(t, expectedWorkspace.Name, result.Title) + require.NotEmpty(t, result.Text) + require.Contains(t, result.URL, expectedWorkspace.Name) + + // Validate JSON marshaling + var workspaceData codersdk.Workspace + err = json.Unmarshal([]byte(result.Text), &workspaceData) + require.NoError(t, err) + require.Equal(t, expectedWorkspace.ID, workspaceData.ID) + }) + } +} + +func TestChatGPTFetch_ObjectIDParsing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + objectID string + expectError bool + errorMsg string + }{ + { + name: "ValidTemplateID", + objectID: "template:" + uuid.NewString(), + expectError: false, + }, + { + name: "ValidWorkspaceID", + objectID: "workspace:" + uuid.NewString(), + expectError: false, + }, + { + name: "MissingColon", + objectID: "template" + uuid.NewString(), + expectError: true, + errorMsg: "invalid ID", + }, + { + name: "InvalidUUID", + objectID: "template:invalid-uuid", + expectError: true, + errorMsg: "invalid template ID, must be a valid UUID", + }, + { + name: "UnsupportedType", + objectID: "user:" + uuid.NewString(), + expectError: true, + errorMsg: "invalid ID", + }, + { + name: "EmptyID", + objectID: "", + expectError: true, + errorMsg: "invalid ID", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Setup minimal environment + client, _ := coderdtest.NewWithDatabase(t, nil) + coderdtest.CreateFirstUser(t, client) + + deps, err := toolsdk.NewDeps(client) + require.NoError(t, err) + + // Execute tool + args := toolsdk.FetchArgs{ID: tt.objectID} + _, err = testTool(t, toolsdk.ChatGPTFetch, deps, args) + + // Verify results + if tt.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorMsg) + } else { + // For valid formats, we expect it to fail on API call since IDs don't exist + // but parsing should succeed + require.Error(t, err) + require.Contains(t, err.Error(), "Resource not found") + } + }) + } +} diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index c6c37821e5234..7cb8cecb25234 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -36,6 +36,8 @@ const ( ToolNameCreateTemplate = "coder_create_template" ToolNameDeleteTemplate = "coder_delete_template" ToolNameWorkspaceBash = "coder_workspace_bash" + ToolNameChatGPTSearch = "search" + ToolNameChatGPTFetch = "fetch" ) func NewDeps(client *codersdk.Client, opts ...func(*Deps)) (Deps, error) { @@ -56,6 +58,13 @@ type Deps struct { report func(ReportTaskArgs) error } +func (d Deps) ServerURL() string { + serverURLCopy := *d.coderClient.URL + serverURLCopy.Path = "" + serverURLCopy.RawQuery = "" + return serverURLCopy.String() +} + func WithTaskReporter(fn func(ReportTaskArgs) error) func(*Deps) { return func(d *Deps) { d.report = fn @@ -194,6 +203,8 @@ var All = []GenericTool{ UploadTarFile.Generic(), UpdateTemplateActiveVersion.Generic(), WorkspaceBash.Generic(), + ChatGPTSearch.Generic(), + ChatGPTFetch.Generic(), } type ReportTaskArgs struct {