From a61194dfa938ceb3411d5418152273e45c4fd04d Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 28 Jul 2025 14:51:57 +0000 Subject: [PATCH 01/11] feat: search and fetch mcp tools --- coderd/coderd.go | 6 +- coderd/mcp/mcp.go | 12 +- coderd/mcp/mcp_test.go | 4 +- coderd/mcp_http.go | 39 ++++- codersdk/toolsdk/chatgpt.go | 340 ++++++++++++++++++++++++++++++++++++ codersdk/toolsdk/toolsdk.go | 4 + 6 files changed, 391 insertions(+), 14 deletions(-) create mode 100644 codersdk/toolsdk/chatgpt.go diff --git a/coderd/coderd.go b/coderd/coderd.go index 26bf4a7bf9b63..a1282ac4db609 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -996,8 +996,12 @@ func New(options *Options) *API { r.Use( httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2, codersdk.ExperimentMCPServerHTTP), ) + // MCP HTTP transport endpoint with mandatory authentication - r.Mount("/http", api.mcpHTTPHandler()) + r.Mount("/http", api.standardMCPHTTPHandler()) + // ChatGPT gets a dedicated endpoint with a limited set of tools. + // See the docstring of the chatgptMCPHTTPHandler for more details. + r.Mount("/chatgpt", api.chatgptMCPHTTPHandler()) }) }) diff --git a/coderd/mcp/mcp.go b/coderd/mcp/mcp.go index f17ab5ae7cd93..0b77078ceb1da 100644 --- a/coderd/mcp/mcp.go +++ b/coderd/mcp/mcp.go @@ -67,8 +67,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.streamableServer.ServeHTTP(w, r) } -// RegisterTools registers all available MCP tools with the server -func (s *Server) RegisterTools(client *codersdk.Client) error { +// RegisterTools registers MCP tools with the server +func (s *Server) RegisterTools(client *codersdk.Client, tools []toolsdk.GenericTool) error { if client == nil { return xerrors.New("client cannot be nil: MCP HTTP server requires authenticated client") } @@ -79,13 +79,7 @@ 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 { - continue - } - + for _, tool := range tools { s.mcpServer.AddTools(mcpFromSDK(tool, toolDeps)) } return nil diff --git a/coderd/mcp/mcp_test.go b/coderd/mcp/mcp_test.go index 0c53c899b9830..860a43e09d50a 100644 --- a/coderd/mcp/mcp_test.go +++ b/coderd/mcp/mcp_test.go @@ -110,13 +110,13 @@ func TestMCPHTTP_ToolRegistration(t *testing.T) { require.NoError(t, err) // Test registering tools with nil client should return error - err = server.RegisterTools(nil) + err = server.RegisterTools(nil, toolsdk.All) require.Error(t, err) require.Contains(t, err.Error(), "client cannot be nil", "Should reject nil client with appropriate error message") // Test registering tools with valid client should succeed client := &codersdk.Client{} - err = server.RegisterTools(client) + err = server.RegisterTools(client, toolsdk.All) require.NoError(t, err) // Verify that all expected tools are available in the toolsdk diff --git a/coderd/mcp_http.go b/coderd/mcp_http.go index 40aaaa1c40dd5..7362b068d5a36 100644 --- a/coderd/mcp_http.go +++ b/coderd/mcp_http.go @@ -9,10 +9,11 @@ import ( "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/mcp" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/toolsdk" ) // mcpHTTPHandler creates the MCP HTTP transport handler -func (api *API) mcpHTTPHandler() http.Handler { +func (api *API) mcpHTTPHandler(tools []toolsdk.GenericTool) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Create MCP server instance for each request mcpServer, err := mcp.NewServer(api.Logger.Named("mcp")) @@ -29,7 +30,7 @@ func (api *API) mcpHTTPHandler() http.Handler { authenticatedClient.SetSessionToken(httpmw.APITokenFromRequest(r)) // Register tools with authenticated client - if err := mcpServer.RegisterTools(authenticatedClient); err != nil { + if err := mcpServer.RegisterTools(authenticatedClient, tools); err != nil { api.Logger.Warn(r.Context(), "failed to register MCP tools", slog.Error(err)) } @@ -37,3 +38,37 @@ func (api *API) mcpHTTPHandler() http.Handler { mcpServer.ServeHTTP(w, r) }) } + +// standardMCPHTTPHandler sets up the MCP HTTP transport handler for the standard tools. +// Standard tools are all tools except for the report task, ChatGPT search, and ChatGPT fetch tools. +func (api *API) standardMCPHTTPHandler() http.Handler { + mcpTools := []toolsdk.GenericTool{} + // Register all available tools, but exclude: + // - ReportTask - which requires dependencies not available in the remote MCP context + // - ChatGPT search and fetch tools, which are redundant with the standard tools. + for _, tool := range toolsdk.All { + if tool.Name == toolsdk.ToolNameReportTask || + tool.Name == toolsdk.ToolNameChatGPTSearch || tool.Name == toolsdk.ToolNameChatGPTFetch { + continue + } + mcpTools = append(mcpTools, tool) + } + return api.mcpHTTPHandler(mcpTools) +} + +// chatgptMCPHTTPHandler sets up the MCP HTTP transport handler for the ChatGPT tools. +// 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 refuse +// to add Coder as a connector. +func (api *API) chatgptMCPHTTPHandler() http.Handler { + mcpTools := []toolsdk.GenericTool{} + // Register only the ChatGPT search and fetch tools. + for _, tool := range toolsdk.All { + if !(tool.Name == toolsdk.ToolNameChatGPTSearch || tool.Name == toolsdk.ToolNameChatGPTFetch) { + continue + } + mcpTools = append(mcpTools, tool) + } + return api.mcpHTTPHandler(mcpTools) +} diff --git a/codersdk/toolsdk/chatgpt.go b/codersdk/toolsdk/chatgpt.go new file mode 100644 index 0000000000000..90dafe31b17c4 --- /dev/null +++ b/codersdk/toolsdk/chatgpt.go @@ -0,0 +1,340 @@ +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" +) + +func getServerURL(deps Deps) string { + serverURLCopy := *deps.coderClient.URL + serverURLCopy.Path = "" + serverURLCopy.RawQuery = "" + return serverURLCopy.String() +} + +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) ([]SearchResultItem, error) { + serverURL := getServerURL(deps) + templates, err := deps.coderClient.Templates(ctx, codersdk.TemplateFilter{}) + 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, owner string) ([]SearchResultItem, error) { + serverURL := getServerURL(deps) + if owner == "" { + owner = "me" + } + workspaces, err := deps.coderClient.Workspaces(ctx, codersdk.WorkspaceFilter{ + Owner: owner, + }) + 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", owner, workspace.TemplateDisplayName, workspace.LatestBuild.Transition), + URL: fmt.Sprintf("%s/%s/%s", serverURL, owner, workspace.Name), + } + } + return results, nil +} + +type SearchQueryType string + +const ( + SearchQueryTypeTemplates SearchQueryType = "templates" + SearchQueryTypeWorkspaces SearchQueryType = "workspaces" +) + +type SearchQuery struct { + Type SearchQueryType + WorkspaceOwner string +} + +func parseSearchQuery(query string) (SearchQuery, error) { + parts := strings.Split(query, ":") + switch SearchQueryType(parts[0]) { + case SearchQueryTypeTemplates: + // expected format: templates + return SearchQuery{ + Type: SearchQueryTypeTemplates, + }, nil + case SearchQueryTypeWorkspaces: + // expected format: workspaces:owner + owner := "me" + if len(parts) == 2 { + owner = parts[1] + } else if len(parts) != 1 { + return SearchQuery{}, xerrors.Errorf("invalid query: %s", query) + } + return SearchQuery{ + Type: SearchQueryTypeWorkspaces, + WorkspaceOwner: owner, + }, nil + } + return SearchQuery{}, xerrors.Errorf("invalid query: %s", query) +} + +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, + Description: `Search for templates, workspaces, and files in workspaces. + +To pick what you want to search for, use the following query formats: + +- ` + "`" + `templates` + "`" + `: List all templates. This query is not parameterized. +- ` + "`" + `workspaces:$owner` + "`" + `: List workspaces belonging to a user. If owner is not specified, the current user is used. The special value ` + "`" + `me` + "`" + ` can be used to search for workspaces owned by the current user. + +# Examples + +## Listing templates + +List all templates. + +` + "```" + `json +{ + "query": "templates" +} +` + "```" + ` + +## Listing workspaces + +List all workspaces belonging to the current user. + +` + "```" + `json +{ + "query": "workspaces:me" +} +` + "```" + ` + +or + +` + "```" + `json +{ + "query": "workspaces" +} +` + "```" + ` + +List all workspaces belonging to a user with username "josh". + +` + "```" + `json +{ + "query": "workspaces:josh" +} +` + "```" + ` +`, + 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) + if err != nil { + return SearchResult{}, err + } + return SearchResult{Results: results}, nil + case SearchQueryTypeWorkspaces: + results, err := searchWorkspaces(ctx, deps, query.WorkspaceOwner) + 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", getServerURL(deps), 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", getServerURL(deps), 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/toolsdk.go b/codersdk/toolsdk/toolsdk.go index c6c37821e5234..72e660e8f3e70 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) { @@ -194,6 +196,8 @@ var All = []GenericTool{ UploadTarFile.Generic(), UpdateTemplateActiveVersion.Generic(), WorkspaceBash.Generic(), + ChatGPTSearch.Generic(), + ChatGPTFetch.Generic(), } type ReportTaskArgs struct { From a48445e269c566f74d9deee0583de81e6965960b Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 29 Jul 2025 17:31:38 +0000 Subject: [PATCH 02/11] tests --- coderd/mcp/mcp_e2e_test.go | 149 +++++++++ codersdk/toolsdk/chatgpt_test.go | 514 +++++++++++++++++++++++++++++++ 2 files changed, 663 insertions(+) create mode 100644 codersdk/toolsdk/chatgpt_test.go diff --git a/coderd/mcp/mcp_e2e_test.go b/coderd/mcp/mcp_e2e_test.go index 248786405fda9..9db3c2911f9de 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/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) + defer func() { + if closeErr := mcpClient.Close(); closeErr != nil { + t.Logf("Failed to close MCP client: %v", closeErr) + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 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/codersdk/toolsdk/chatgpt_test.go b/codersdk/toolsdk/chatgpt_test.go new file mode 100644 index 0000000000000..60ff9cc0b5351 --- /dev/null +++ b/codersdk/toolsdk/chatgpt_test.go @@ -0,0 +1,514 @@ +// 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/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_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:me", + setupOwner: "self", + setupWorkspace: true, + expectError: false, + }, + { + name: "ValidWorkspacesQuery_NoWorkspaces", + query: "workspaces", + setupOwner: "self", + setupWorkspace: false, + expectError: false, + }, + { + name: "ValidWorkspacesQuery_SpecificUser", + query: "workspaces: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:me", + expectError: false, + }, + { + name: "ValidWorkspacesUserQuery", + query: "workspaces:testuser", + expectError: false, + }, + { + name: "InvalidQueryType", + query: "users", + expectError: true, + errorMsg: "invalid query", + }, + { + name: "EmptyQuery", + query: "", + expectError: true, + errorMsg: "invalid query", + }, + { + name: "MalformedQuery", + query: "workspaces:user:extra", + 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") + } + }) + } +} From 50bae9d3bd99cd7561c8740c5bbd269d3820c252 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Thu, 31 Jul 2025 15:24:24 +0000 Subject: [PATCH 03/11] feat: pass search query directly to endpoints --- codersdk/toolsdk/chatgpt.go | 179 ++++++++++++++++++++++++++++-------- 1 file changed, 141 insertions(+), 38 deletions(-) diff --git a/codersdk/toolsdk/chatgpt.go b/codersdk/toolsdk/chatgpt.go index 90dafe31b17c4..163b60fbe09f1 100644 --- a/codersdk/toolsdk/chatgpt.go +++ b/codersdk/toolsdk/chatgpt.go @@ -55,9 +55,11 @@ func createObjectID(objectType ObjectType, id string) ObjectID { } } -func searchTemplates(ctx context.Context, deps Deps) ([]SearchResultItem, error) { +func searchTemplates(ctx context.Context, deps Deps, query string) ([]SearchResultItem, error) { serverURL := getServerURL(deps) - templates, err := deps.coderClient.Templates(ctx, codersdk.TemplateFilter{}) + templates, err := deps.coderClient.Templates(ctx, codersdk.TemplateFilter{ + SearchQuery: query, + }) if err != nil { return nil, err } @@ -73,13 +75,10 @@ func searchTemplates(ctx context.Context, deps Deps) ([]SearchResultItem, error) return results, nil } -func searchWorkspaces(ctx context.Context, deps Deps, owner string) ([]SearchResultItem, error) { +func searchWorkspaces(ctx context.Context, deps Deps, query string) ([]SearchResultItem, error) { serverURL := getServerURL(deps) - if owner == "" { - owner = "me" - } workspaces, err := deps.coderClient.Workspaces(ctx, codersdk.WorkspaceFilter{ - Owner: owner, + FilterQuery: query, }) if err != nil { return nil, err @@ -89,8 +88,8 @@ func searchWorkspaces(ctx context.Context, deps Deps, owner string) ([]SearchRes results[i] = SearchResultItem{ ID: createObjectID(ObjectTypeWorkspace, workspace.ID.String()).String(), Title: workspace.Name, - Text: fmt.Sprintf("Owner: %s\nTemplate: %s\nLatest transition: %s", owner, workspace.TemplateDisplayName, workspace.LatestBuild.Transition), - URL: fmt.Sprintf("%s/%s/%s", serverURL, owner, 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 @@ -104,32 +103,24 @@ const ( ) type SearchQuery struct { - Type SearchQueryType - WorkspaceOwner string + Type SearchQueryType + Query string } func parseSearchQuery(query string) (SearchQuery, error) { - parts := strings.Split(query, ":") - switch SearchQueryType(parts[0]) { - case SearchQueryTypeTemplates: - // expected format: templates - return SearchQuery{ - Type: SearchQueryTypeTemplates, - }, nil - case SearchQueryTypeWorkspaces: - // expected format: workspaces:owner - owner := "me" - if len(parts) == 2 { - owner = parts[1] - } else if len(parts) != 1 { - return SearchQuery{}, xerrors.Errorf("invalid query: %s", query) - } - return SearchQuery{ - Type: SearchQueryTypeWorkspaces, - WorkspaceOwner: owner, - }, nil + parts := strings.Split(query, "/") + queryType := SearchQueryType(parts[0]) + if !(queryType == SearchQueryTypeTemplates || queryType == SearchQueryTypeWorkspaces) { + return SearchQuery{}, xerrors.Errorf("invalid query: %s", query) } - 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 { @@ -154,18 +145,38 @@ type SearchResult struct { 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 all templates. This query is not parameterized. -- ` + "`" + `workspaces:$owner` + "`" + `: List workspaces belonging to a user. If owner is not specified, the current user is used. The special value ` + "`" + `me` + "`" + ` can be used to search for workspaces owned by the current user. +- ` + "`" + `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. +List all templates without any filters. ` + "```" + `json { @@ -173,13 +184,61 @@ List all 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:me" + "query": "workspaces/owner:me" } ` + "```" + ` @@ -195,7 +254,47 @@ List all workspaces belonging to a user with username "josh". ` + "```" + `json { - "query": "workspaces:josh" + "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" } ` + "```" + ` `, @@ -215,13 +314,17 @@ List all workspaces belonging to a user with username "josh". } switch query.Type { case SearchQueryTypeTemplates: - results, err := searchTemplates(ctx, deps) + results, err := searchTemplates(ctx, deps, query.Query) if err != nil { return SearchResult{}, err } return SearchResult{Results: results}, nil case SearchQueryTypeWorkspaces: - results, err := searchWorkspaces(ctx, deps, query.WorkspaceOwner) + searchQuery := query.Query + if searchQuery == "" { + searchQuery = "owner:me" + } + results, err := searchWorkspaces(ctx, deps, searchQuery) if err != nil { return SearchResult{}, err } From ec576595976ab027574b822d3d2fc00173c2b25a Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Thu, 31 Jul 2025 15:50:22 +0000 Subject: [PATCH 04/11] chore: update tests --- codersdk/toolsdk/chatgpt_test.go | 72 +++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/codersdk/toolsdk/chatgpt_test.go b/codersdk/toolsdk/chatgpt_test.go index 60ff9cc0b5351..f456e2690723a 100644 --- a/codersdk/toolsdk/chatgpt_test.go +++ b/codersdk/toolsdk/chatgpt_test.go @@ -11,6 +11,7 @@ import ( "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" ) @@ -99,6 +100,67 @@ func TestChatGPTSearch_TemplateSearch(t *testing.T) { } } +func TestChatGPTSearch_TemplateMultipleFilters(t *testing.T) { + t.Parallel() + + // Setup + client, store := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + + // Create templates directly with specific names for testing filters + dockerTemplate1 := 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: owner.OrganizationID, + 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: owner.OrganizationID, + 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) + + // Execute tool with name filter - should only return templates with "docker" in name + args := toolsdk.SearchArgs{Query: "templates/name:docker"} + result, err := testTool(t, toolsdk.ChatGPTSearch, deps, args) + + // Verify results + require.NoError(t, err) + require.Len(t, result.Results, 2, "Should match both docker templates") + + // Validate the results contain both docker templates + templateIDs := make(map[string]bool) + for _, item := range result.Results { + require.NotEmpty(t, item.ID) + require.Contains(t, item.ID, "template:") + require.Contains(t, item.URL, "/templates/") + templateIDs[item.ID] = true + } + + expectedID1 := "template:" + dockerTemplate1.ID.String() + expectedID2 := "template:" + dockerTemplate2.ID.String() + require.True(t, templateIDs[expectedID1], "Should contain first docker template") + require.True(t, templateIDs[expectedID2], "Should contain second docker template") +} + func TestChatGPTSearch_WorkspaceSearch(t *testing.T) { t.Parallel() @@ -119,7 +181,7 @@ func TestChatGPTSearch_WorkspaceSearch(t *testing.T) { }, { name: "ValidWorkspacesQuery_CurrentUserMe", - query: "workspaces:me", + query: "workspaces/owner:me", setupOwner: "self", setupWorkspace: true, expectError: false, @@ -133,7 +195,7 @@ func TestChatGPTSearch_WorkspaceSearch(t *testing.T) { }, { name: "ValidWorkspacesQuery_SpecificUser", - query: "workspaces:otheruser", + query: "workspaces/owner:otheruser", setupOwner: "other", setupWorkspace: true, expectError: false, @@ -229,12 +291,12 @@ func TestChatGPTSearch_QueryParsing(t *testing.T) { }, { name: "ValidWorkspacesMeQuery", - query: "workspaces:me", + query: "workspaces/owner:me", expectError: false, }, { name: "ValidWorkspacesUserQuery", - query: "workspaces:testuser", + query: "workspaces/owner:testuser", expectError: false, }, { @@ -251,7 +313,7 @@ func TestChatGPTSearch_QueryParsing(t *testing.T) { }, { name: "MalformedQuery", - query: "workspaces:user:extra", + query: "invalidtype/somequery", expectError: true, errorMsg: "invalid query", }, From f86a119669acfcd8fdfb960b3d8ef6a39dde16ac Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Thu, 31 Jul 2025 15:53:15 +0000 Subject: [PATCH 05/11] chore: move getServerURL under Deps --- codersdk/toolsdk/chatgpt.go | 15 ++++----------- codersdk/toolsdk/toolsdk.go | 7 +++++++ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/codersdk/toolsdk/chatgpt.go b/codersdk/toolsdk/chatgpt.go index 163b60fbe09f1..51bcb26334a92 100644 --- a/codersdk/toolsdk/chatgpt.go +++ b/codersdk/toolsdk/chatgpt.go @@ -14,13 +14,6 @@ import ( "github.com/coder/coder/v2/codersdk" ) -func getServerURL(deps Deps) string { - serverURLCopy := *deps.coderClient.URL - serverURLCopy.Path = "" - serverURLCopy.RawQuery = "" - return serverURLCopy.String() -} - type ObjectType string const ( @@ -56,7 +49,7 @@ func createObjectID(objectType ObjectType, id string) ObjectID { } func searchTemplates(ctx context.Context, deps Deps, query string) ([]SearchResultItem, error) { - serverURL := getServerURL(deps) + serverURL := deps.getServerURL() templates, err := deps.coderClient.Templates(ctx, codersdk.TemplateFilter{ SearchQuery: query, }) @@ -76,7 +69,7 @@ func searchTemplates(ctx context.Context, deps Deps, query string) ([]SearchResu } func searchWorkspaces(ctx context.Context, deps Deps, query string) ([]SearchResultItem, error) { - serverURL := getServerURL(deps) + serverURL := deps.getServerURL() workspaces, err := deps.coderClient.Workspaces(ctx, codersdk.WorkspaceFilter{ FilterQuery: query, }) @@ -351,7 +344,7 @@ func fetchWorkspace(ctx context.Context, deps Deps, workspaceID string) (FetchRe ID: workspace.ID.String(), Title: workspace.Name, Text: string(workspaceJSON), - URL: fmt.Sprintf("%s/%s/%s", getServerURL(deps), workspace.OwnerName, workspace.Name), + URL: fmt.Sprintf("%s/%s/%s", deps.getServerURL(), workspace.OwnerName, workspace.Name), }, nil } @@ -372,7 +365,7 @@ func fetchTemplate(ctx context.Context, deps Deps, templateID string) (FetchResu ID: template.ID.String(), Title: template.DisplayName, Text: string(templateJSON), - URL: fmt.Sprintf("%s/templates/%s/%s", getServerURL(deps), template.OrganizationName, template.Name), + URL: fmt.Sprintf("%s/templates/%s/%s", deps.getServerURL(), template.OrganizationName, template.Name), }, nil } diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index 72e660e8f3e70..ed154481f9d44 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -58,6 +58,13 @@ type Deps struct { report func(ReportTaskArgs) error } +func (d Deps) getServerURL() 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 From 2faad08a805ab413150a18a6549ed2a7af1f2ffe Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Thu, 31 Jul 2025 16:10:42 +0000 Subject: [PATCH 06/11] chore: replace dedicated chatgpt endpoint with query param --- coderd/coderd.go | 5 +-- coderd/mcp/mcp_e2e_test.go | 2 +- coderd/mcp_http.go | 82 +++++++++++++++++++++----------------- 3 files changed, 48 insertions(+), 41 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index a1282ac4db609..c64f1f073d948 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -998,10 +998,7 @@ func New(options *Options) *API { ) // MCP HTTP transport endpoint with mandatory authentication - r.Mount("/http", api.standardMCPHTTPHandler()) - // ChatGPT gets a dedicated endpoint with a limited set of tools. - // See the docstring of the chatgptMCPHTTPHandler for more details. - r.Mount("/chatgpt", api.chatgptMCPHTTPHandler()) + r.Mount("/http", api.mcpHTTPHandler()) }) }) diff --git a/coderd/mcp/mcp_e2e_test.go b/coderd/mcp/mcp_e2e_test.go index 9db3c2911f9de..512fe448917b9 100644 --- a/coderd/mcp/mcp_e2e_test.go +++ b/coderd/mcp/mcp_e2e_test.go @@ -1232,7 +1232,7 @@ func TestMCPHTTP_E2E_ChatGPTEndpoint(t *testing.T) { template := coderdtest.CreateTemplate(t, coderClient, user.OrganizationID, version.ID) // Create MCP client pointing to the ChatGPT endpoint - mcpURL := api.AccessURL.String() + "/api/experimental/mcp/chatgpt" + 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, diff --git a/coderd/mcp_http.go b/coderd/mcp_http.go index 7362b068d5a36..4017773becaba 100644 --- a/coderd/mcp_http.go +++ b/coderd/mcp_http.go @@ -12,9 +12,53 @@ import ( "github.com/coder/coder/v2/codersdk/toolsdk" ) +type MCPToolset string + +const ( + MCPToolsetStandard MCPToolset = "standard" + MCPToolsetChatGPT MCPToolset = "chatgpt" +) + // mcpHTTPHandler creates the MCP HTTP transport handler -func (api *API) mcpHTTPHandler(tools []toolsdk.GenericTool) http.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) { + toolset := MCPToolset(r.URL.Query().Get("toolset")) + // Default to standard toolset if no toolset is specified. + if toolset == "" { + toolset = MCPToolsetStandard + } + + mcpTools := []toolsdk.GenericTool{} + switch toolset { + case MCPToolsetStandard: + // Register all available tools, but exclude: + // - ReportTask - which requires dependencies not available in the remote MCP context + // - ChatGPT search and fetch tools, which are redundant with the standard tools. + for _, tool := range toolsdk.All { + if tool.Name == toolsdk.ToolNameReportTask || + tool.Name == toolsdk.ToolNameChatGPTSearch || tool.Name == toolsdk.ToolNameChatGPTFetch { + continue + } + mcpTools = append(mcpTools, tool) + } + case MCPToolsetChatGPT: + // 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. + for _, tool := range toolsdk.All { + if tool.Name == toolsdk.ToolNameChatGPTSearch || tool.Name == toolsdk.ToolNameChatGPTFetch { + mcpTools = append(mcpTools, tool) + } + } + default: + httpapi.Write(r.Context(), w, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid toolset", + }) + return + } + // Create MCP server instance for each request mcpServer, err := mcp.NewServer(api.Logger.Named("mcp")) if err != nil { @@ -30,7 +74,7 @@ func (api *API) mcpHTTPHandler(tools []toolsdk.GenericTool) http.Handler { authenticatedClient.SetSessionToken(httpmw.APITokenFromRequest(r)) // Register tools with authenticated client - if err := mcpServer.RegisterTools(authenticatedClient, tools); err != nil { + if err := mcpServer.RegisterTools(authenticatedClient, mcpTools); err != nil { api.Logger.Warn(r.Context(), "failed to register MCP tools", slog.Error(err)) } @@ -38,37 +82,3 @@ func (api *API) mcpHTTPHandler(tools []toolsdk.GenericTool) http.Handler { mcpServer.ServeHTTP(w, r) }) } - -// standardMCPHTTPHandler sets up the MCP HTTP transport handler for the standard tools. -// Standard tools are all tools except for the report task, ChatGPT search, and ChatGPT fetch tools. -func (api *API) standardMCPHTTPHandler() http.Handler { - mcpTools := []toolsdk.GenericTool{} - // Register all available tools, but exclude: - // - ReportTask - which requires dependencies not available in the remote MCP context - // - ChatGPT search and fetch tools, which are redundant with the standard tools. - for _, tool := range toolsdk.All { - if tool.Name == toolsdk.ToolNameReportTask || - tool.Name == toolsdk.ToolNameChatGPTSearch || tool.Name == toolsdk.ToolNameChatGPTFetch { - continue - } - mcpTools = append(mcpTools, tool) - } - return api.mcpHTTPHandler(mcpTools) -} - -// chatgptMCPHTTPHandler sets up the MCP HTTP transport handler for the ChatGPT tools. -// 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 refuse -// to add Coder as a connector. -func (api *API) chatgptMCPHTTPHandler() http.Handler { - mcpTools := []toolsdk.GenericTool{} - // Register only the ChatGPT search and fetch tools. - for _, tool := range toolsdk.All { - if !(tool.Name == toolsdk.ToolNameChatGPTSearch || tool.Name == toolsdk.ToolNameChatGPTFetch) { - continue - } - mcpTools = append(mcpTools, tool) - } - return api.mcpHTTPHandler(mcpTools) -} From 4afd149d192458f4bb46b1b0ec1c1cde4184a9f8 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Thu, 31 Jul 2025 16:12:51 +0000 Subject: [PATCH 07/11] chore: nits --- coderd/mcp/mcp_e2e_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/mcp/mcp_e2e_test.go b/coderd/mcp/mcp_e2e_test.go index 512fe448917b9..b831d150c2c0d 100644 --- a/coderd/mcp/mcp_e2e_test.go +++ b/coderd/mcp/mcp_e2e_test.go @@ -1240,13 +1240,13 @@ func TestMCPHTTP_E2E_ChatGPTEndpoint(t *testing.T) { "Authorization": "Bearer " + coderClient.SessionToken(), })) require.NoError(t, err) - defer func() { + t.Cleanup(func() { if closeErr := mcpClient.Close(); closeErr != nil { t.Logf("Failed to close MCP client: %v", closeErr) } - }() + }) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) defer cancel() // Start client From 9f938b9f479ef8e96100fb589864089f864bfe57 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Thu, 31 Jul 2025 16:58:26 +0000 Subject: [PATCH 08/11] chore: refactor RegisterTools --- coderd/mcp/mcp.go | 40 ++++++++++++++++++++++++++--- coderd/mcp/mcp_test.go | 4 +-- coderd/mcp_http.go | 58 ++++++++++++++---------------------------- 3 files changed, 58 insertions(+), 44 deletions(-) diff --git a/coderd/mcp/mcp.go b/coderd/mcp/mcp.go index 0b77078ceb1da..9f3b37b03ce97 100644 --- a/coderd/mcp/mcp.go +++ b/coderd/mcp/mcp.go @@ -67,8 +67,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.streamableServer.ServeHTTP(w, r) } -// RegisterTools registers MCP tools with the server -func (s *Server) RegisterTools(client *codersdk.Client, tools []toolsdk.GenericTool) error { +// 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,7 +81,39 @@ func (s *Server) RegisterTools(client *codersdk.Client, tools []toolsdk.GenericT return xerrors.Errorf("failed to initialize tool dependencies: %w", err) } - for _, tool := range tools { + for _, tool := range toolsdk.All { + // 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 + } + s.mcpServer.AddTools(mcpFromSDK(tool, toolDeps)) } return nil diff --git a/coderd/mcp/mcp_test.go b/coderd/mcp/mcp_test.go index 860a43e09d50a..0c53c899b9830 100644 --- a/coderd/mcp/mcp_test.go +++ b/coderd/mcp/mcp_test.go @@ -110,13 +110,13 @@ func TestMCPHTTP_ToolRegistration(t *testing.T) { require.NoError(t, err) // Test registering tools with nil client should return error - err = server.RegisterTools(nil, toolsdk.All) + err = server.RegisterTools(nil) require.Error(t, err) require.Contains(t, err.Error(), "client cannot be nil", "Should reject nil client with appropriate error message") // Test registering tools with valid client should succeed client := &codersdk.Client{} - err = server.RegisterTools(client, toolsdk.All) + err = server.RegisterTools(client) require.NoError(t, err) // Verify that all expected tools are available in the toolsdk diff --git a/coderd/mcp_http.go b/coderd/mcp_http.go index 4017773becaba..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" @@ -9,7 +10,6 @@ import ( "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/mcp" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/toolsdk" ) type MCPToolset string @@ -23,61 +23,41 @@ const ( // 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 + mcpServer, err := mcp.NewServer(api.Logger.Named("mcp")) + if err != nil { + api.Logger.Error(r.Context(), "failed to create MCP server", slog.Error(err)) + httpapi.Write(r.Context(), w, http.StatusInternalServerError, codersdk.Response{ + Message: "MCP server initialization failed", + }) + return + } + authenticatedClient := codersdk.New(api.AccessURL) + // Extract the original session token from the request + authenticatedClient.SetSessionToken(httpmw.APITokenFromRequest(r)) + toolset := MCPToolset(r.URL.Query().Get("toolset")) // Default to standard toolset if no toolset is specified. if toolset == "" { toolset = MCPToolsetStandard } - mcpTools := []toolsdk.GenericTool{} switch toolset { case MCPToolsetStandard: - // Register all available tools, but exclude: - // - ReportTask - which requires dependencies not available in the remote MCP context - // - ChatGPT search and fetch tools, which are redundant with the standard tools. - for _, tool := range toolsdk.All { - if tool.Name == toolsdk.ToolNameReportTask || - tool.Name == toolsdk.ToolNameChatGPTSearch || tool.Name == toolsdk.ToolNameChatGPTFetch { - continue - } - mcpTools = append(mcpTools, tool) + if err := mcpServer.RegisterTools(authenticatedClient); err != nil { + api.Logger.Warn(r.Context(), "failed to register MCP tools", slog.Error(err)) } case MCPToolsetChatGPT: - // 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. - for _, tool := range toolsdk.All { - if tool.Name == toolsdk.ToolNameChatGPTSearch || tool.Name == toolsdk.ToolNameChatGPTFetch { - mcpTools = append(mcpTools, tool) - } + 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: "Invalid toolset", + Message: fmt.Sprintf("Invalid toolset: %s", toolset), }) return } - // Create MCP server instance for each request - mcpServer, err := mcp.NewServer(api.Logger.Named("mcp")) - if err != nil { - api.Logger.Error(r.Context(), "failed to create MCP server", slog.Error(err)) - httpapi.Write(r.Context(), w, http.StatusInternalServerError, codersdk.Response{ - Message: "MCP server initialization failed", - }) - 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, mcpTools); err != nil { - api.Logger.Warn(r.Context(), "failed to register MCP tools", slog.Error(err)) - } - // Handle the MCP request mcpServer.ServeHTTP(w, r) }) From e50627d6e45f7b6ef3863f4201b02d8e3f73ee1b Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Thu, 31 Jul 2025 17:01:24 +0000 Subject: [PATCH 09/11] fix: typo --- coderd/mcp/mcp.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/mcp/mcp.go b/coderd/mcp/mcp.go index 9f3b37b03ce97..3696beff500a1 100644 --- a/coderd/mcp/mcp.go +++ b/coderd/mcp/mcp.go @@ -110,7 +110,7 @@ func (s *Server) RegisterChatGPTTools(client *codersdk.Client) error { } for _, tool := range toolsdk.All { - if tool.Name == toolsdk.ToolNameChatGPTSearch || tool.Name == toolsdk.ToolNameChatGPTFetch { + if tool.Name != toolsdk.ToolNameChatGPTSearch && tool.Name != toolsdk.ToolNameChatGPTFetch { continue } From 9fef13efa6bab92ff22fcf4c6ff3e11a08a4a18c Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Thu, 31 Jul 2025 17:44:40 +0000 Subject: [PATCH 10/11] chore: update test --- coderd/coderd.go | 1 - codersdk/toolsdk/chatgpt_test.go | 30 ++++++++++-------------------- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index c64f1f073d948..26bf4a7bf9b63 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -996,7 +996,6 @@ func New(options *Options) *API { r.Use( httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2, codersdk.ExperimentMCPServerHTTP), ) - // MCP HTTP transport endpoint with mandatory authentication r.Mount("/http", api.mcpHTTPHandler()) }) diff --git a/codersdk/toolsdk/chatgpt_test.go b/codersdk/toolsdk/chatgpt_test.go index f456e2690723a..c8a05ba41411b 100644 --- a/codersdk/toolsdk/chatgpt_test.go +++ b/codersdk/toolsdk/chatgpt_test.go @@ -106,9 +106,11 @@ func TestChatGPTSearch_TemplateMultipleFilters(t *testing.T) { // Setup client, store := coderdtest.NewWithDatabase(t, nil) owner := coderdtest.CreateFirstUser(t, client) + org2 := dbgen.Organization(t, store, database.Organization{ + Name: "org2", + }) - // Create templates directly with specific names for testing filters - dockerTemplate1 := dbgen.Template(t, store, database.Template{ + dbgen.Template(t, store, database.Template{ OrganizationID: owner.OrganizationID, CreatedBy: owner.UserID, Name: "docker-development", // Name contains "docker" @@ -118,7 +120,7 @@ func TestChatGPTSearch_TemplateMultipleFilters(t *testing.T) { // Create another template that doesn't contain "docker" dbgen.Template(t, store, database.Template{ - OrganizationID: owner.OrganizationID, + OrganizationID: org2.ID, CreatedBy: owner.UserID, Name: "python-web", // Name doesn't contain "docker" DisplayName: "Python Web", @@ -127,7 +129,7 @@ func TestChatGPTSearch_TemplateMultipleFilters(t *testing.T) { // Create third template with "docker" in name dockerTemplate2 := dbgen.Template(t, store, database.Template{ - OrganizationID: owner.OrganizationID, + OrganizationID: org2.ID, CreatedBy: owner.UserID, Name: "old-docker-template", // Name contains "docker" DisplayName: "Old Docker Template", @@ -138,27 +140,15 @@ func TestChatGPTSearch_TemplateMultipleFilters(t *testing.T) { deps, err := toolsdk.NewDeps(client) require.NoError(t, err) - // Execute tool with name filter - should only return templates with "docker" in name - args := toolsdk.SearchArgs{Query: "templates/name:docker"} + 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, 2, "Should match both docker templates") - - // Validate the results contain both docker templates - templateIDs := make(map[string]bool) - for _, item := range result.Results { - require.NotEmpty(t, item.ID) - require.Contains(t, item.ID, "template:") - require.Contains(t, item.URL, "/templates/") - templateIDs[item.ID] = true - } + require.Len(t, result.Results, 1, "Should match only the docker template in org2") - expectedID1 := "template:" + dockerTemplate1.ID.String() - expectedID2 := "template:" + dockerTemplate2.ID.String() - require.True(t, templateIDs[expectedID1], "Should contain first docker template") - require.True(t, templateIDs[expectedID2], "Should contain second docker template") + 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) { From 5efa6d91a0c2f76f2793a6886aac534f199b8ae3 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Sun, 3 Aug 2025 16:37:27 +0000 Subject: [PATCH 11/11] chore: rename getServerURL to ServerURL --- codersdk/toolsdk/chatgpt.go | 8 ++++---- codersdk/toolsdk/toolsdk.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/codersdk/toolsdk/chatgpt.go b/codersdk/toolsdk/chatgpt.go index 51bcb26334a92..c4bf5b5d4c174 100644 --- a/codersdk/toolsdk/chatgpt.go +++ b/codersdk/toolsdk/chatgpt.go @@ -49,7 +49,7 @@ func createObjectID(objectType ObjectType, id string) ObjectID { } func searchTemplates(ctx context.Context, deps Deps, query string) ([]SearchResultItem, error) { - serverURL := deps.getServerURL() + serverURL := deps.ServerURL() templates, err := deps.coderClient.Templates(ctx, codersdk.TemplateFilter{ SearchQuery: query, }) @@ -69,7 +69,7 @@ func searchTemplates(ctx context.Context, deps Deps, query string) ([]SearchResu } func searchWorkspaces(ctx context.Context, deps Deps, query string) ([]SearchResultItem, error) { - serverURL := deps.getServerURL() + serverURL := deps.ServerURL() workspaces, err := deps.coderClient.Workspaces(ctx, codersdk.WorkspaceFilter{ FilterQuery: query, }) @@ -344,7 +344,7 @@ func fetchWorkspace(ctx context.Context, deps Deps, workspaceID string) (FetchRe ID: workspace.ID.String(), Title: workspace.Name, Text: string(workspaceJSON), - URL: fmt.Sprintf("%s/%s/%s", deps.getServerURL(), workspace.OwnerName, workspace.Name), + URL: fmt.Sprintf("%s/%s/%s", deps.ServerURL(), workspace.OwnerName, workspace.Name), }, nil } @@ -365,7 +365,7 @@ func fetchTemplate(ctx context.Context, deps Deps, templateID string) (FetchResu ID: template.ID.String(), Title: template.DisplayName, Text: string(templateJSON), - URL: fmt.Sprintf("%s/templates/%s/%s", deps.getServerURL(), template.OrganizationName, template.Name), + URL: fmt.Sprintf("%s/templates/%s/%s", deps.ServerURL(), template.OrganizationName, template.Name), }, nil } diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index ed154481f9d44..7cb8cecb25234 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -58,7 +58,7 @@ type Deps struct { report func(ReportTaskArgs) error } -func (d Deps) getServerURL() string { +func (d Deps) ServerURL() string { serverURLCopy := *d.coderClient.URL serverURLCopy.Path = "" serverURLCopy.RawQuery = ""