Skip to content

Commit 79cd80e

Browse files
authored
feat: add MCP tools for ChatGPT (#19102)
Addresses coder/internal#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="/api/flow.js?q=https%3A%2F%2Fgithub.com%2Fcoder%2Fcoder%2Fcommit%2F%253Ca%2520href%3D"https://github.com/user-attachments/assets/ca31e57b-d18b-4998-9554-7a96a141527a">https://github.com/user-attachments/assets/ca31e57b-d18b-4998-9554-7a96a141527a" />
1 parent d4b4418 commit 79cd80e

File tree

6 files changed

+1223
-8
lines changed

6 files changed

+1223
-8
lines changed

coderd/mcp/mcp.go

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
6767
s.streamableServer.ServeHTTP(w, r)
6868
}
6969

70-
// RegisterTools registers all available MCP tools with the server
70+
// Register all available MCP tools with the server excluding:
71+
// - ReportTask - which requires dependencies not available in the remote MCP context
72+
// - ChatGPT search and fetch tools, which are redundant with the standard tools.
7173
func (s *Server) RegisterTools(client *codersdk.Client) error {
7274
if client == nil {
7375
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 {
7981
return xerrors.Errorf("failed to initialize tool dependencies: %w", err)
8082
}
8183

82-
// Register all available tools, but exclude tools that require dependencies not available in the
83-
// remote MCP context
8484
for _, tool := range toolsdk.All {
85-
if tool.Name == toolsdk.ToolNameReportTask {
85+
// the ReportTask tool requires dependencies not available in the remote MCP context
86+
// the ChatGPT search and fetch tools are redundant with the standard tools.
87+
if tool.Name == toolsdk.ToolNameReportTask ||
88+
tool.Name == toolsdk.ToolNameChatGPTSearch || tool.Name == toolsdk.ToolNameChatGPTFetch {
89+
continue
90+
}
91+
92+
s.mcpServer.AddTools(mcpFromSDK(tool, toolDeps))
93+
}
94+
return nil
95+
}
96+
97+
// ChatGPT tools are the search and fetch tools as defined in https://platform.openai.com/docs/mcp.
98+
// We do not expose any extra ones because ChatGPT has an undocumented "Safety Scan" feature.
99+
// In my experiments, if I included extra tools in the MCP server, ChatGPT would often - but not always -
100+
// refuse to add Coder as a connector.
101+
func (s *Server) RegisterChatGPTTools(client *codersdk.Client) error {
102+
if client == nil {
103+
return xerrors.New("client cannot be nil: MCP HTTP server requires authenticated client")
104+
}
105+
106+
// Create tool dependencies
107+
toolDeps, err := toolsdk.NewDeps(client)
108+
if err != nil {
109+
return xerrors.Errorf("failed to initialize tool dependencies: %w", err)
110+
}
111+
112+
for _, tool := range toolsdk.All {
113+
if tool.Name != toolsdk.ToolNameChatGPTSearch && tool.Name != toolsdk.ToolNameChatGPTFetch {
86114
continue
87115
}
88116

coderd/mcp/mcp_e2e_test.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1215,6 +1215,155 @@ func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) {
12151215
})
12161216
}
12171217

1218+
func TestMCPHTTP_E2E_ChatGPTEndpoint(t *testing.T) {
1219+
t.Parallel()
1220+
1221+
// Setup Coder server with authentication
1222+
coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
1223+
IncludeProvisionerDaemon: true,
1224+
})
1225+
defer closer.Close()
1226+
1227+
user := coderdtest.CreateFirstUser(t, coderClient)
1228+
1229+
// Create template and workspace for testing search functionality
1230+
version := coderdtest.CreateTemplateVersion(t, coderClient, user.OrganizationID, nil)
1231+
coderdtest.AwaitTemplateVersionJobCompleted(t, coderClient, version.ID)
1232+
template := coderdtest.CreateTemplate(t, coderClient, user.OrganizationID, version.ID)
1233+
1234+
// Create MCP client pointing to the ChatGPT endpoint
1235+
mcpURL := api.AccessURL.String() + "/api/experimental/mcp/http?toolset=chatgpt"
1236+
1237+
// Configure client with authentication headers using RFC 6750 Bearer token
1238+
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
1239+
transport.WithHTTPHeaders(map[string]string{
1240+
"Authorization": "Bearer " + coderClient.SessionToken(),
1241+
}))
1242+
require.NoError(t, err)
1243+
t.Cleanup(func() {
1244+
if closeErr := mcpClient.Close(); closeErr != nil {
1245+
t.Logf("Failed to close MCP client: %v", closeErr)
1246+
}
1247+
})
1248+
1249+
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
1250+
defer cancel()
1251+
1252+
// Start client
1253+
err = mcpClient.Start(ctx)
1254+
require.NoError(t, err)
1255+
1256+
// Initialize connection
1257+
initReq := mcp.InitializeRequest{
1258+
Params: mcp.InitializeParams{
1259+
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
1260+
ClientInfo: mcp.Implementation{
1261+
Name: "test-chatgpt-client",
1262+
Version: "1.0.0",
1263+
},
1264+
},
1265+
}
1266+
1267+
result, err := mcpClient.Initialize(ctx, initReq)
1268+
require.NoError(t, err)
1269+
require.Equal(t, mcpserver.MCPServerName, result.ServerInfo.Name)
1270+
require.Equal(t, mcp.LATEST_PROTOCOL_VERSION, result.ProtocolVersion)
1271+
require.NotNil(t, result.Capabilities)
1272+
1273+
// Test tool listing - should only have search and fetch tools for ChatGPT
1274+
tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{})
1275+
require.NoError(t, err)
1276+
require.NotEmpty(t, tools.Tools)
1277+
1278+
// Verify we have exactly the ChatGPT tools and no others
1279+
var foundTools []string
1280+
for _, tool := range tools.Tools {
1281+
foundTools = append(foundTools, tool.Name)
1282+
}
1283+
1284+
// ChatGPT endpoint should only expose search and fetch tools
1285+
assert.Contains(t, foundTools, toolsdk.ToolNameChatGPTSearch, "Should have ChatGPT search tool")
1286+
assert.Contains(t, foundTools, toolsdk.ToolNameChatGPTFetch, "Should have ChatGPT fetch tool")
1287+
assert.Len(t, foundTools, 2, "ChatGPT endpoint should only expose search and fetch tools")
1288+
1289+
// Should NOT have other tools that are available in the standard endpoint
1290+
assert.NotContains(t, foundTools, toolsdk.ToolNameGetAuthenticatedUser, "Should not have authenticated user tool")
1291+
assert.NotContains(t, foundTools, toolsdk.ToolNameListWorkspaces, "Should not have list workspaces tool")
1292+
1293+
t.Logf("ChatGPT endpoint tools: %v", foundTools)
1294+
1295+
// Test search tool - search for templates
1296+
var searchTool *mcp.Tool
1297+
for _, tool := range tools.Tools {
1298+
if tool.Name == toolsdk.ToolNameChatGPTSearch {
1299+
searchTool = &tool
1300+
break
1301+
}
1302+
}
1303+
require.NotNil(t, searchTool, "Expected to find search tool")
1304+
1305+
// Execute search for templates
1306+
searchReq := mcp.CallToolRequest{
1307+
Params: mcp.CallToolParams{
1308+
Name: searchTool.Name,
1309+
Arguments: map[string]any{
1310+
"query": "templates",
1311+
},
1312+
},
1313+
}
1314+
1315+
searchResult, err := mcpClient.CallTool(ctx, searchReq)
1316+
require.NoError(t, err)
1317+
require.NotEmpty(t, searchResult.Content)
1318+
1319+
// Verify the search result contains our template
1320+
assert.Len(t, searchResult.Content, 1)
1321+
if textContent, ok := searchResult.Content[0].(mcp.TextContent); ok {
1322+
assert.Equal(t, "text", textContent.Type)
1323+
assert.Contains(t, textContent.Text, template.ID.String(), "Search result should contain our test template")
1324+
t.Logf("Search result: %s", textContent.Text)
1325+
} else {
1326+
t.Errorf("Expected TextContent type, got %T", searchResult.Content[0])
1327+
}
1328+
1329+
// Test fetch tool
1330+
var fetchTool *mcp.Tool
1331+
for _, tool := range tools.Tools {
1332+
if tool.Name == toolsdk.ToolNameChatGPTFetch {
1333+
fetchTool = &tool
1334+
break
1335+
}
1336+
}
1337+
require.NotNil(t, fetchTool, "Expected to find fetch tool")
1338+
1339+
// Execute fetch for the template
1340+
fetchReq := mcp.CallToolRequest{
1341+
Params: mcp.CallToolParams{
1342+
Name: fetchTool.Name,
1343+
Arguments: map[string]any{
1344+
"id": fmt.Sprintf("template:%s", template.ID.String()),
1345+
},
1346+
},
1347+
}
1348+
1349+
fetchResult, err := mcpClient.CallTool(ctx, fetchReq)
1350+
require.NoError(t, err)
1351+
require.NotEmpty(t, fetchResult.Content)
1352+
1353+
// Verify the fetch result contains template details
1354+
assert.Len(t, fetchResult.Content, 1)
1355+
if textContent, ok := fetchResult.Content[0].(mcp.TextContent); ok {
1356+
assert.Equal(t, "text", textContent.Type)
1357+
assert.Contains(t, textContent.Text, template.Name, "Fetch result should contain template name")
1358+
assert.Contains(t, textContent.Text, template.ID.String(), "Fetch result should contain template ID")
1359+
t.Logf("Fetch result contains template data")
1360+
} else {
1361+
t.Errorf("Expected TextContent type, got %T", fetchResult.Content[0])
1362+
}
1363+
1364+
t.Logf("ChatGPT endpoint E2E test successful: search and fetch tools working correctly")
1365+
}
1366+
12181367
// Helper function to parse URL safely in tests
12191368
func mustParseURL(t *testing.T, rawURL string) *url.URL {
12201369
u, err := url.Parse(rawURL)

coderd/mcp_http.go

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package coderd
22

33
import (
4+
"fmt"
45
"net/http"
56

67
"cdr.dev/slog"
@@ -11,7 +12,15 @@ import (
1112
"github.com/coder/coder/v2/codersdk"
1213
)
1314

15+
type MCPToolset string
16+
17+
const (
18+
MCPToolsetStandard MCPToolset = "standard"
19+
MCPToolsetChatGPT MCPToolset = "chatgpt"
20+
)
21+
1422
// mcpHTTPHandler creates the MCP HTTP transport handler
23+
// It supports a "toolset" query parameter to select the set of tools to register.
1524
func (api *API) mcpHTTPHandler() http.Handler {
1625
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1726
// Create MCP server instance for each request
@@ -23,14 +32,30 @@ func (api *API) mcpHTTPHandler() http.Handler {
2332
})
2433
return
2534
}
26-
2735
authenticatedClient := codersdk.New(api.AccessURL)
2836
// Extract the original session token from the request
2937
authenticatedClient.SetSessionToken(httpmw.APITokenFromRequest(r))
3038

31-
// Register tools with authenticated client
32-
if err := mcpServer.RegisterTools(authenticatedClient); err != nil {
33-
api.Logger.Warn(r.Context(), "failed to register MCP tools", slog.Error(err))
39+
toolset := MCPToolset(r.URL.Query().Get("toolset"))
40+
// Default to standard toolset if no toolset is specified.
41+
if toolset == "" {
42+
toolset = MCPToolsetStandard
43+
}
44+
45+
switch toolset {
46+
case MCPToolsetStandard:
47+
if err := mcpServer.RegisterTools(authenticatedClient); err != nil {
48+
api.Logger.Warn(r.Context(), "failed to register MCP tools", slog.Error(err))
49+
}
50+
case MCPToolsetChatGPT:
51+
if err := mcpServer.RegisterChatGPTTools(authenticatedClient); err != nil {
52+
api.Logger.Warn(r.Context(), "failed to register MCP tools", slog.Error(err))
53+
}
54+
default:
55+
httpapi.Write(r.Context(), w, http.StatusBadRequest, codersdk.Response{
56+
Message: fmt.Sprintf("Invalid toolset: %s", toolset),
57+
})
58+
return
3459
}
3560

3661
// Handle the MCP request

0 commit comments

Comments
 (0)