Files
coder/coderd/inline_mcp_servers_internal_test.go
Ethan f64ccfb836 feat: add experimental inline MCP servers (#29546)
Closes CODAGT-1131

Fifth PR in the inline MCP servers stack, on top of #29520. A chat owner
can now declare MCP servers by URL and headers on their own chat, and
chatd connects to them every turn alongside the org-configured ones.

They go in a new `inline_mcp_servers` field on `POST /api/v2/chats` and
`POST /api/v2/chats/{chat}/messages`, which replaces the whole set (omit
to keep, `[]` to remove). Root chats only. `GET /api/v2/chats/{chat}`
returns them, with the URL blanked for non-owners and header values
never returned. Subagents only get servers marked `allow_in_subagents`,
a root chat in explore mode gets none, and a root chat in plan mode gets
all of them.

URLs must be `https`, or `http` to an IP literal in
`CODER_MCP_ALLOWED_PRIVATE_CIDRS`. Header values have no minimum length,
so anything under the redactor's 8 byte floor from #29518 is accepted
but not redacted, which the docs call out. Every cap is exported from
`codersdk`.

It's behind the `chat-inline-mcp-servers` experiment, and the
`--disable-chat-caller-supplied-tools` kill switch from #29466 covers it
too. With either off, declaring servers gets a 403 and existing chats
stop connecting to theirs, but `[]` still works so a client can detach.

Load failures show up in the chat debug panel as failed connections, as
do org servers with an invalid config. Docs are hand-written in
`mcp-servers.md`.
2026-09-25 18:51:55 +10:00

295 lines
9.7 KiB
Go

package coderd
import (
"fmt"
"net/netip"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk"
)
func TestValidateInlineMCPServers(t *testing.T) {
t.Parallel()
valid := codersdk.InlineMCPServerRequest{
Slug: "private-tools",
URL: "https://mcp.example.com/v1",
Headers: map[string]string{
"Authorization": "Bearer private-token",
},
ToolAllowList: []string{"lookup"},
}
loopbackAllowed := []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}
longHeaderName := strings.Repeat("X", codersdk.MaxInlineMCPServerHeaderNameBytes+1)
tests := []struct {
name string
servers []codersdk.InlineMCPServerRequest
allowed []netip.Prefix
wantField string
wantDetail string
}{
{name: "ValidHTTPS", servers: []codersdk.InlineMCPServerRequest{valid}},
{
name: "ValidAllowlistedHTTPWithHeaders",
servers: []codersdk.InlineMCPServerRequest{{
Slug: "local",
URL: "http://127.0.0.1:3001/mcp",
Headers: map[string]string{"Authorization": "Bearer test-only"},
}},
allowed: loopbackAllowed,
},
{
name: "TooManyServers",
servers: func() []codersdk.InlineMCPServerRequest {
servers := make([]codersdk.InlineMCPServerRequest, codersdk.MaxInlineMCPServers+1)
for i := range servers {
servers[i] = codersdk.InlineMCPServerRequest{
Slug: fmt.Sprintf("server-%d", i),
URL: fmt.Sprintf("https://mcp-%d.example.com", i),
}
}
return servers
}(),
wantField: "inline_mcp_servers",
wantDetail: "at most",
},
{
name: "InvalidSlug",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private tools", URL: valid.URL}},
wantField: "inline_mcp_servers[0].slug",
wantDetail: "letters",
},
{
name: "DuplicateSlug",
servers: []codersdk.InlineMCPServerRequest{
valid,
{Slug: valid.Slug, URL: "https://other.example.com/mcp"},
},
wantField: "inline_mcp_servers[1].slug",
wantDetail: "unique",
},
{
name: "URLEmpty",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: ""}},
wantField: "inline_mcp_servers[0].url",
wantDetail: "is required",
},
{
name: "URLUnparseable",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: "https://mcp.example.com/%zz"}},
wantField: "inline_mcp_servers[0].url",
wantDetail: "valid URL",
},
{
name: "URLScheme",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: "ftp://mcp.example.com/mcp"}},
wantField: "inline_mcp_servers[0].url",
wantDetail: "scheme",
},
{
name: "URLMissingHost",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: "https:///mcp"}},
wantField: "inline_mcp_servers[0].url",
wantDetail: "host is required",
},
{
name: "URLFragment",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: "https://mcp.example.com/mcp#token"}},
wantField: "inline_mcp_servers[0].url",
wantDetail: "fragment",
},
{
name: "URLUserinfo",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: "https://user:password@mcp.example.com"}},
wantField: "inline_mcp_servers[0].url",
wantDetail: "userinfo",
},
{
name: "URLQuery",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: "https://mcp.example.com?token=secret"}},
wantField: "inline_mcp_servers[0].url",
wantDetail: "query",
},
{
name: "BlockedIPLiteral",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: "http://169.254.169.254/latest/meta-data"}},
wantField: "inline_mcp_servers[0].url",
wantDetail: "private or reserved",
},
{
name: "PrivateIPOutsideAllowlist",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: "https://10.0.0.1/mcp"}},
allowed: loopbackAllowed,
wantField: "inline_mcp_servers[0].url",
wantDetail: "private or reserved",
},
{
name: "HTTPHostname",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: "http://mcp.example.com"}},
allowed: loopbackAllowed,
wantField: "inline_mcp_servers[0].url",
wantDetail: "must use https",
},
{
name: "HTTPPublicIPLiteral",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: "http://8.8.8.8/mcp"}},
allowed: loopbackAllowed,
wantField: "inline_mcp_servers[0].url",
wantDetail: "must use https",
},
{
name: "ReservedHeader",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: valid.URL, Headers: map[string]string{"X-Coder-Chat-Id": "override"}}},
wantField: "inline_mcp_servers[0].headers[X-Coder-Chat-Id]",
wantDetail: "reserved",
},
{
name: "MCPProtocolHeader",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: valid.URL, Headers: map[string]string{"Mcp-Session-Id": "fixed-session"}}},
wantField: "inline_mcp_servers[0].headers[Mcp-Session-Id]",
wantDetail: "reserved",
},
{
name: "HeaderNameTooLong",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: valid.URL, Headers: map[string]string{longHeaderName: "header-value"}}},
wantField: "inline_mcp_servers[0].headers[" + longHeaderName + "]",
wantDetail: "must not exceed",
},
{
name: "HeaderNameInvalid",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: valid.URL, Headers: map[string]string{"Bad Header": "header-value"}}},
wantField: "inline_mcp_servers[0].headers[Bad Header]",
wantDetail: "header name is invalid",
},
{
name: "HeaderValueInvalid",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: valid.URL, Headers: map[string]string{"Authorization": "Bearer x\r\nX-Injected: 1"}}},
wantField: "inline_mcp_servers[0].headers[Authorization]",
wantDetail: "header value is invalid",
},
{
name: "TooManyHeaders",
servers: []codersdk.InlineMCPServerRequest{{
Slug: "private",
URL: valid.URL,
Headers: func() map[string]string {
headers := make(map[string]string, codersdk.MaxInlineMCPServerHeaders+1)
for i := 0; i <= codersdk.MaxInlineMCPServerHeaders; i++ {
headers[fmt.Sprintf("X-Test-%d", i)] = "header-value"
}
return headers
}(),
}},
wantField: "inline_mcp_servers[0].headers",
wantDetail: "at most",
},
{
name: "DuplicateHeaderNameCase",
servers: []codersdk.InlineMCPServerRequest{{
Slug: "private",
URL: valid.URL,
Headers: map[string]string{
"Authorization": "Bearer aaaaaaaa",
"authorization": "Bearer bbbbbbbb",
},
}},
wantField: "inline_mcp_servers[0].headers[authorization]",
wantDetail: `duplicates "Authorization"`,
},
{
name: "HeaderValueTooLarge",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: valid.URL, Headers: map[string]string{"Authorization": strings.Repeat("x", codersdk.MaxInlineMCPServerHeaderValueBytes+1)}}},
wantField: "inline_mcp_servers[0].headers[Authorization]",
wantDetail: "must not exceed",
},
{
name: "TooManyAllowedTools",
servers: []codersdk.InlineMCPServerRequest{{
Slug: "private",
URL: valid.URL,
ToolAllowList: func() []string {
names := make([]string, codersdk.MaxInlineMCPServerToolFilters+1)
for i := range names {
names[i] = fmt.Sprintf("tool-%d", i)
}
return names
}(),
}},
wantField: "inline_mcp_servers[0].tool_allow_list",
wantDetail: "at most",
},
{
name: "EmptyToolName",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: valid.URL, ToolAllowList: []string{""}}},
wantField: "inline_mcp_servers[0].tool_allow_list[0]",
wantDetail: "must not be empty",
},
{
name: "ToolNameTooLong",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: valid.URL, ToolDenyList: []string{strings.Repeat("x", codersdk.MaxInlineMCPServerToolNameBytes+1)}}},
wantField: "inline_mcp_servers[0].tool_deny_list[0]",
wantDetail: "must not exceed",
},
{
name: "ToolNameNullByte",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: valid.URL, ToolAllowList: []string{"look\x00up"}}},
wantField: "inline_mcp_servers[0].tool_allow_list[0]",
wantDetail: "null bytes",
},
{
name: "DuplicateToolName",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: valid.URL, ToolDenyList: []string{"delete", "delete"}}},
wantField: "inline_mcp_servers[0].tool_deny_list[1]",
wantDetail: "unique",
},
{
name: "URLTooLong",
servers: []codersdk.InlineMCPServerRequest{{Slug: "private", URL: "https://mcp.example.com/" + strings.Repeat("x", codersdk.MaxInlineMCPServerURLBytes)}},
wantField: "inline_mcp_servers[0].url",
wantDetail: "must not exceed",
},
{
name: "AllowAndDenyLists",
servers: []codersdk.InlineMCPServerRequest{{
Slug: "private",
URL: valid.URL,
ToolAllowList: []string{"lookup"},
ToolDenyList: []string{"delete"},
}},
wantField: "inline_mcp_servers[0].tool_deny_list",
wantDetail: "cannot be combined",
},
{
name: "AggregateSize",
servers: []codersdk.InlineMCPServerRequest{{
Slug: "private",
URL: valid.URL,
Headers: map[string]string{
"Authorization": strings.Repeat("x", codersdk.MaxInlineMCPServersBytes),
},
}},
wantField: "inline_mcp_servers",
wantDetail: "total size",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
validations := validateInlineMCPServers(tt.servers, tt.allowed)
if tt.wantField == "" {
require.Empty(t, validations)
return
}
require.NotEmpty(t, validations)
require.Equal(t, tt.wantField, validations[0].Field)
require.Contains(t, validations[0].Detail, tt.wantDetail)
})
}
}