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
+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 {