mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
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`.
300 lines
10 KiB
Go
300 lines
10 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"maps"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/net/http/httpguts"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/safedial"
|
|
)
|
|
|
|
var inlineMCPServerSlugPattern = regexp.MustCompile(fmt.Sprintf(`^[A-Za-z0-9][A-Za-z0-9_-]{0,%d}$`, codersdk.MaxInlineMCPServerSlugBytes-1))
|
|
|
|
func writeChatCallerSuppliedToolsDisabled(ctx context.Context, rw http.ResponseWriter) {
|
|
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
|
Message: "Caller-supplied tools are disabled on this deployment.",
|
|
Detail: "The server runs with --disable-chat-caller-supplied-tools. Remove unsafe_dynamic_tools and inline_mcp_servers from the request.",
|
|
})
|
|
}
|
|
|
|
func writeInlineMCPServersExperimentRequired(ctx context.Context, rw http.ResponseWriter) {
|
|
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
|
Message: "Inline MCP servers are not enabled on this deployment.",
|
|
Detail: fmt.Sprintf("Enable the %s experiment.", codersdk.ExperimentChatInlineMCPServers),
|
|
})
|
|
}
|
|
|
|
func writeInlineMCPServersInvalid(ctx context.Context, rw http.ResponseWriter, validations []codersdk.ValidationError) {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Invalid inline_mcp_servers.",
|
|
Validations: validations,
|
|
})
|
|
}
|
|
|
|
// chatInlineMCPServers returns the inline MCP servers declared on a chat,
|
|
// without header values.
|
|
func (api *API) chatInlineMCPServers(ctx context.Context, chatID uuid.UUID) ([]codersdk.InlineMCPServer, error) {
|
|
rows, err := api.Database.GetChatMCPServersByChatID(ctx, chatID)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("get chat MCP servers: %w", err)
|
|
}
|
|
servers := make([]codersdk.InlineMCPServer, 0, len(rows))
|
|
for _, row := range rows {
|
|
server, err := db2sdk.InlineMCPServer(row)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
servers = append(servers, server)
|
|
}
|
|
return servers, nil
|
|
}
|
|
|
|
func validateInlineMCPServers(
|
|
servers []codersdk.InlineMCPServerRequest,
|
|
allowedIPRanges []netip.Prefix,
|
|
) []codersdk.ValidationError {
|
|
if len(servers) > codersdk.MaxInlineMCPServers {
|
|
return []codersdk.ValidationError{{
|
|
Field: "inline_mcp_servers",
|
|
Detail: fmt.Sprintf("must contain at most %d servers", codersdk.MaxInlineMCPServers),
|
|
}}
|
|
}
|
|
|
|
if inlineMCPServersSize(servers) > codersdk.MaxInlineMCPServersBytes {
|
|
return []codersdk.ValidationError{{
|
|
Field: "inline_mcp_servers",
|
|
Detail: fmt.Sprintf(
|
|
"total size must not exceed %d bytes",
|
|
codersdk.MaxInlineMCPServersBytes,
|
|
),
|
|
}}
|
|
}
|
|
|
|
seenSlugs := make(map[string]struct{}, len(servers))
|
|
var validations []codersdk.ValidationError
|
|
for i, server := range servers {
|
|
slugField := fmt.Sprintf("inline_mcp_servers[%d].slug", i)
|
|
if !inlineMCPServerSlugPattern.MatchString(server.Slug) {
|
|
validations = append(validations, codersdk.ValidationError{
|
|
Field: slugField,
|
|
Detail: fmt.Sprintf(
|
|
"must be 1 to %d ASCII letters, numbers, underscores, or hyphens, and must start with a letter or number",
|
|
codersdk.MaxInlineMCPServerSlugBytes,
|
|
),
|
|
})
|
|
} else if _, ok := seenSlugs[server.Slug]; ok {
|
|
validations = append(validations, codersdk.ValidationError{
|
|
Field: slugField,
|
|
Detail: "must be unique within inline_mcp_servers",
|
|
})
|
|
} else {
|
|
seenSlugs[server.Slug] = struct{}{}
|
|
}
|
|
|
|
if urlValidation := validateInlineMCPServerURL(i, server.URL, allowedIPRanges); urlValidation != nil {
|
|
validations = append(validations, *urlValidation)
|
|
}
|
|
|
|
if len(server.Headers) > codersdk.MaxInlineMCPServerHeaders {
|
|
validations = append(validations, codersdk.ValidationError{
|
|
Field: fmt.Sprintf("inline_mcp_servers[%d].headers", i),
|
|
Detail: fmt.Sprintf(
|
|
"must contain at most %d headers",
|
|
codersdk.MaxInlineMCPServerHeaders,
|
|
),
|
|
})
|
|
}
|
|
// Header names are case-insensitive identifiers: the transport
|
|
// canonicalizes them on the wire, so two spellings of one name
|
|
// would collide nondeterministically. Track the first spelling
|
|
// of each canonical name and reject repeats.
|
|
seen := make(map[string]string, len(server.Headers))
|
|
for _, name := range slices.Sorted(maps.Keys(server.Headers)) {
|
|
field := fmt.Sprintf("inline_mcp_servers[%d].headers[%s]", i, name)
|
|
value := server.Headers[name]
|
|
canonical := http.CanonicalHeaderKey(name)
|
|
switch {
|
|
case seen[canonical] != "":
|
|
validations = append(validations, codersdk.ValidationError{
|
|
Field: field,
|
|
Detail: fmt.Sprintf("header name duplicates %q; header names are case-insensitive", seen[canonical]),
|
|
})
|
|
case len(name) > codersdk.MaxInlineMCPServerHeaderNameBytes:
|
|
validations = append(validations, codersdk.ValidationError{
|
|
Field: field,
|
|
Detail: fmt.Sprintf("header name must not exceed %d bytes", codersdk.MaxInlineMCPServerHeaderNameBytes),
|
|
})
|
|
case !httpguts.ValidHeaderFieldName(name):
|
|
validations = append(validations, codersdk.ValidationError{
|
|
Field: field,
|
|
Detail: "header name is invalid",
|
|
})
|
|
case inlineMCPHeaderReserved(name):
|
|
validations = append(validations, codersdk.ValidationError{
|
|
Field: field,
|
|
Detail: "header name is reserved",
|
|
})
|
|
case len(value) > codersdk.MaxInlineMCPServerHeaderValueBytes:
|
|
validations = append(validations, codersdk.ValidationError{
|
|
Field: field,
|
|
Detail: fmt.Sprintf("header value must not exceed %d bytes", codersdk.MaxInlineMCPServerHeaderValueBytes),
|
|
})
|
|
case !httpguts.ValidHeaderFieldValue(value):
|
|
validations = append(validations, codersdk.ValidationError{
|
|
Field: field,
|
|
Detail: "header value is invalid",
|
|
})
|
|
}
|
|
seen[canonical] = name
|
|
}
|
|
|
|
if len(server.ToolAllowList) > 0 && len(server.ToolDenyList) > 0 {
|
|
validations = append(validations, codersdk.ValidationError{
|
|
Field: fmt.Sprintf("inline_mcp_servers[%d].tool_deny_list", i),
|
|
Detail: "cannot be combined with tool_allow_list",
|
|
})
|
|
}
|
|
validations = append(validations, validateInlineMCPToolFilter(i, "tool_allow_list", server.ToolAllowList)...)
|
|
validations = append(validations, validateInlineMCPToolFilter(i, "tool_deny_list", server.ToolDenyList)...)
|
|
}
|
|
return validations
|
|
}
|
|
|
|
func validateInlineMCPServerURL(
|
|
index int,
|
|
rawURL string,
|
|
allowedIPRanges []netip.Prefix,
|
|
) *codersdk.ValidationError {
|
|
field := fmt.Sprintf("inline_mcp_servers[%d].url", index)
|
|
invalid := func(detail string) *codersdk.ValidationError {
|
|
return &codersdk.ValidationError{Field: field, Detail: detail}
|
|
}
|
|
if len(rawURL) == 0 {
|
|
return invalid("is required")
|
|
}
|
|
if len(rawURL) > codersdk.MaxInlineMCPServerURLBytes {
|
|
return invalid(fmt.Sprintf("must not exceed %d bytes", codersdk.MaxInlineMCPServerURLBytes))
|
|
}
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return invalid("must be a valid URL")
|
|
}
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return invalid("scheme must be http or https")
|
|
}
|
|
if parsed.Host == "" {
|
|
return invalid("host is required")
|
|
}
|
|
if parsed.User != nil {
|
|
return invalid("must not contain userinfo")
|
|
}
|
|
if parsed.RawQuery != "" || parsed.ForceQuery {
|
|
return invalid("must not contain a query string")
|
|
}
|
|
if parsed.Fragment != "" {
|
|
return invalid("must not contain a fragment")
|
|
}
|
|
ip, err := netip.ParseAddr(parsed.Hostname())
|
|
isIPLiteral := err == nil
|
|
if isIPLiteral && safedial.CheckAddr(ip, safedial.WithAllowedPrefixes(allowedIPRanges...)) != nil {
|
|
return invalid("host is in a private or reserved IP range")
|
|
}
|
|
// Plaintext is only allowed to an IP literal the operator allowlisted.
|
|
// Hostnames resolve at connect time, so they cannot be trusted here.
|
|
inAllowedRange := isIPLiteral && slices.ContainsFunc(allowedIPRanges, func(prefix netip.Prefix) bool {
|
|
return prefix.Contains(ip.Unmap())
|
|
})
|
|
if parsed.Scheme == "http" && !inAllowedRange {
|
|
return invalid("must use https, or http to an IP literal in an allowed private range")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateInlineMCPToolFilter(index int, name string, values []string) []codersdk.ValidationError {
|
|
field := fmt.Sprintf("inline_mcp_servers[%d].%s", index, name)
|
|
if len(values) > codersdk.MaxInlineMCPServerToolFilters {
|
|
return []codersdk.ValidationError{{
|
|
Field: field,
|
|
Detail: fmt.Sprintf("must contain at most %d tool names", codersdk.MaxInlineMCPServerToolFilters),
|
|
}}
|
|
}
|
|
seen := make(map[string]struct{}, len(values))
|
|
var validations []codersdk.ValidationError
|
|
for i, value := range values {
|
|
itemField := fmt.Sprintf("%s[%d]", field, i)
|
|
_, duplicate := seen[value]
|
|
switch {
|
|
case value == "":
|
|
validations = append(validations, codersdk.ValidationError{Field: itemField, Detail: "tool name must not be empty"})
|
|
case len(value) > codersdk.MaxInlineMCPServerToolNameBytes:
|
|
validations = append(validations, codersdk.ValidationError{
|
|
Field: itemField,
|
|
Detail: fmt.Sprintf("tool name must not exceed %d bytes", codersdk.MaxInlineMCPServerToolNameBytes),
|
|
})
|
|
case strings.ContainsRune(value, '\x00'):
|
|
validations = append(validations, codersdk.ValidationError{Field: itemField, Detail: "tool name must not contain null bytes"})
|
|
case duplicate:
|
|
validations = append(validations, codersdk.ValidationError{Field: itemField, Detail: "tool name must be unique within the list"})
|
|
default:
|
|
seen[value] = struct{}{}
|
|
}
|
|
}
|
|
return validations
|
|
}
|
|
|
|
func inlineMCPServersSize(servers []codersdk.InlineMCPServerRequest) int {
|
|
total := 0
|
|
for _, server := range servers {
|
|
total += len(server.Slug) + len(server.URL)
|
|
for name, value := range server.Headers {
|
|
total += len(name) + len(value)
|
|
}
|
|
for _, name := range server.ToolAllowList {
|
|
total += len(name)
|
|
}
|
|
for _, name := range server.ToolDenyList {
|
|
total += len(name)
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
func inlineMCPHeaderReserved(name string) bool {
|
|
canonical := strings.ToLower(http.CanonicalHeaderKey(name))
|
|
if strings.HasPrefix(canonical, "proxy-") || strings.HasPrefix(canonical, "x-coder-") {
|
|
return true
|
|
}
|
|
switch canonical {
|
|
case "host",
|
|
"content-length",
|
|
"connection",
|
|
"transfer-encoding",
|
|
"trailer",
|
|
"upgrade",
|
|
"te",
|
|
"keep-alive",
|
|
"accept",
|
|
"accept-encoding",
|
|
"content-type",
|
|
"last-event-id",
|
|
"mcp-protocol-version",
|
|
"mcp-session-id":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|