feat: add MCP tools for ChatGPT (#19102)

Addresses https://github.com/coder/internal/issues/772.

Adds the toolset query parameter to the `/api/experimental/mcp/http` endpoint, which, when set to "chatgpt", exposes new `fetch` and `search` tools compatible with ChatGPT, as described in the
[ChatGPT docs](https://platform.openai.com/docs/mcp). These tools are
exposed in isolation because in my usage I found that ChatGPT refuses to
connect to Coder if it sees additional MCP tools.

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