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 = "" pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy