Skip to content

feat: add MCP tools for ChatGPT #19102

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Aug 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions coderd/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
}

Expand Down
149 changes: 149 additions & 0 deletions coderd/mcp/mcp_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 29 additions & 4 deletions coderd/mcp_http.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package coderd

import (
"fmt"
"net/http"

"cdr.dev/slog"
Expand All @@ -11,7 +12,15 @@ import (
"github.com/coder/coder/v2/codersdk"
)

type MCPToolset string

const (
MCPToolsetStandard MCPToolset = "standard"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might define Standard one to be the default empty value, then we don't need to do additional checks later on.

Suggested change
MCPToolsetStandard MCPToolset = "standard"
MCPToolsetStandard MCPToolset = ""

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat trick, but it feels unintuitive - especially if we add debug logging with toolset names later. I'd be surprised to see this pattern as a reader.

MCPToolsetChatGPT MCPToolset = "chatgpt"
)

// mcpHTTPHandler creates the MCP HTTP transport handler
// It supports a "toolset" query parameter to select the set of tools to register.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to expose toolsets specifically, or do we want to add a simple ?source=chatgpt and then internally map to the corresponding toolsets?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I conceptually prefer toolsets over sources. It feels more appropriate for the client to choose which tools it wants to access, rather than having the server adapt to the client.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense.

Would it be possible to specify multiple toolsets then?

Say we split the param on , and then composite them into a new one. Then one could have a ?toolset=chatgpt,workspaces only to get the chatgpt and workspace-related tools.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That could definitely be a later-stage extension.

func (api *API) mcpHTTPHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Create MCP server instance for each request
Expand All @@ -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
Expand Down
Loading
Loading