mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+32
-4
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user