mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
## Problem VS Code discovers Coder's MCP endpoint as a path-qualified OAuth resource, but Coder advertises the deployment root. Accepting MCP-bound tokens also requires keeping REST-backed tool calls within the originating MCP resource rather than granting those tokens direct REST access. ## Fix - Publish path-aware protected resource metadata and authentication challenges; keep authorization-server metadata at the root. - Validate the MCP audience and delegate tool requests through a private in-process transport that retains identity, scopes, expiry, revocation, and streaming cancellation. - Reject unsupported MCP path aliases and validate SDK path components before URL resolution. - Add discovery, audience, authorization, endpoint-isolation, and transport lifecycle regression tests and update integration docs. ## Validation VS Code completed OAuth and returned the authenticated user through a real MCP tool. Official MCP SDK 1.30.1 completed discovery, registration, PKCE, tool execution, and refresh. These live checks preceded the final path hardening; the hardened paths are covered by automated regressions. Affected Go package tests, targeted race tests, backend/SDK lint, and targeted documentation checks passed during self-review. The exact PR commit was also checked separately from the uncommitted consent-scope fix. > [!NOTE] > Tokens are MCP-audience-bound, but grants still include `coder:all`; least-privilege scopes remain a separate requirement ([PLAT-639](https://linear.app/codercom/issue/PLAT-639)). Preview: https://coder.com/docs/@fix/mcp-internal-delegation/ai-coder/mcp-server <details> <summary>Implementation decisions and review context</summary> 1. Treat `/api/experimental/mcp/http` and its single-trailing-slash form as supported MCP resource paths; reject other aliases and descendants before route authentication. 2. Preserve deployment-root discovery and authorization-server metadata for existing REST integrations. 3. Bind private delegation to the authenticated API-key ID. Revalidate the original credential and enforce normal RBAC/scopes on every internal REST request; do not derive delegation from HTTP headers. 4. Keep internal HTTP, streaming, and WebSocket traffic in-process with no network fallback. Close the transport on outer-request cancellation and normal completion. 5. Validate workspace owner/name path segments before SDK URL construction, including tool-level tests that malformed arguments cannot select a different REST endpoint. 6. Keep compatibility and delegated tool execution together: discovery-only changes would allow authentication while leaving real MCP tool calls broken. </details> > 🤖 This PR was created with the help of Coder Agents, and needs a human review. 🧑💻
336 lines
14 KiB
Go
336 lines
14 KiB
Go
package codersdk
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// MCPEndpoint is the remote MCP HTTP endpoint path.
|
|
const MCPEndpoint = "/api/experimental/mcp/http"
|
|
|
|
// MCPServerOAuth2ConnectURL returns the URL the user should visit to
|
|
// start the OAuth2 flow for an MCP server. The frontend opens this
|
|
// in a new window/popup.
|
|
func (c *Client) MCPServerOAuth2ConnectURL(organizationID, id uuid.UUID) string {
|
|
return fmt.Sprintf("%s/api/v2/organizations/%s/mcp-servers/%s/oauth2/connect", c.URL.String(), organizationID, id)
|
|
}
|
|
|
|
// MCPServerOAuth2DisconnectResponse reports whether the removed token
|
|
// was also revoked at the OAuth provider.
|
|
type MCPServerOAuth2DisconnectResponse struct {
|
|
TokenRevoked bool `json:"token_revoked"`
|
|
TokenRevocationError string `json:"token_revocation_error,omitempty"`
|
|
}
|
|
|
|
// MCPServerOAuth2Disconnect removes the user's OAuth2 token for an
|
|
// MCP server. Use MCPServerOAuth2DisconnectWithResponse for the
|
|
// provider revocation outcome.
|
|
func (c *Client) MCPServerOAuth2Disconnect(ctx context.Context, id uuid.UUID) error {
|
|
_, err := c.MCPServerOAuth2DisconnectWithResponse(ctx, id)
|
|
return err
|
|
}
|
|
|
|
// MCPServerOAuth2DisconnectWithResponse removes the user's OAuth2
|
|
// token for an MCP server and reports the provider revocation outcome.
|
|
func (c *Client) MCPServerOAuth2DisconnectWithResponse(ctx context.Context, id uuid.UUID) (MCPServerOAuth2DisconnectResponse, error) {
|
|
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/mcp/servers/%s/oauth2/disconnect", id), nil)
|
|
if err != nil {
|
|
return MCPServerOAuth2DisconnectResponse{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
// Servers from before provider revocation respond 204 without a body.
|
|
if res.StatusCode == http.StatusNoContent {
|
|
return MCPServerOAuth2DisconnectResponse{}, nil
|
|
}
|
|
if res.StatusCode != http.StatusOK {
|
|
return MCPServerOAuth2DisconnectResponse{}, ReadBodyAsError(res)
|
|
}
|
|
var resp MCPServerOAuth2DisconnectResponse
|
|
return resp, ReadBodyAsJSON(res, &resp)
|
|
}
|
|
|
|
// MCPServerConfig represents an admin-configured MCP server.
|
|
type MCPServerConfig struct {
|
|
ID uuid.UUID `json:"id" format:"uuid"`
|
|
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
|
|
DisplayName string `json:"display_name"`
|
|
Slug string `json:"slug"`
|
|
Description string `json:"description"`
|
|
IconURL string `json:"icon_url"`
|
|
|
|
Transport string `json:"transport"` // "streamable_http" or "sse"
|
|
URL string `json:"url"`
|
|
|
|
AuthType string `json:"auth_type"` // "none", "oauth2", "api_key", "custom_headers", "user_oidc"
|
|
|
|
// OAuth2 fields (only populated for admins).
|
|
OAuth2ClientID string `json:"oauth2_client_id,omitempty"`
|
|
HasOAuth2Secret bool `json:"has_oauth2_secret"`
|
|
OAuth2AuthURL string `json:"oauth2_auth_url,omitempty"`
|
|
OAuth2TokenURL string `json:"oauth2_token_url,omitempty"`
|
|
OAuth2RevocationURL string `json:"oauth2_revocation_url,omitempty"`
|
|
OAuth2Scopes string `json:"oauth2_scopes,omitempty"`
|
|
|
|
// API key fields (only populated for admins).
|
|
APIKeyHeader string `json:"api_key_header,omitempty"`
|
|
HasAPIKey bool `json:"has_api_key"`
|
|
|
|
HasCustomHeaders bool `json:"has_custom_headers"`
|
|
|
|
// Tool governance.
|
|
ToolAllowList []string `json:"tool_allow_list"`
|
|
ToolDenyList []string `json:"tool_deny_list"`
|
|
|
|
// Availability policy set by admin.
|
|
Availability string `json:"availability"` // "force_on", "default_on", "default_off"
|
|
|
|
Enabled bool `json:"enabled"`
|
|
ModelIntent bool `json:"model_intent"`
|
|
AllowInPlanMode bool `json:"allow_in_plan_mode"`
|
|
|
|
// ForwardCoderHeaders forwards the same Coder identity headers we
|
|
// send to LLM providers (X-Coder-Owner-Id, X-Coder-Chat-Id, and the
|
|
// optional X-Coder-Subchat-Id and X-Coder-Workspace-Id) to this
|
|
// MCP server on every request. Off by default to avoid leaking
|
|
// chat identity to third-party servers.
|
|
ForwardCoderHeaders bool `json:"forward_coder_headers"`
|
|
HasSigningSecret bool `json:"has_signing_secret"`
|
|
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
|
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
|
|
|
// Per-user state (populated for non-admin requests).
|
|
AuthConnected bool `json:"auth_connected"`
|
|
}
|
|
|
|
// MCPServerConfigRole is a role a user or group holds in an MCP server
|
|
// config's access control list.
|
|
type MCPServerConfigRole string
|
|
|
|
const (
|
|
MCPServerConfigRoleRead MCPServerConfigRole = "read"
|
|
// MCPServerConfigRoleDeleted removes the principal's ACL entry when
|
|
// used in an update request.
|
|
MCPServerConfigRoleDeleted MCPServerConfigRole = ""
|
|
)
|
|
|
|
// MCPServerConfigACL is the resolved access control list of an MCP server
|
|
// config.
|
|
type MCPServerConfigACL struct {
|
|
Users []MCPServerConfigUser `json:"users"`
|
|
Groups []MCPServerConfigGroup `json:"groups"`
|
|
}
|
|
|
|
// MCPServerConfigUser is a user entry in an MCP server config ACL.
|
|
type MCPServerConfigUser struct {
|
|
MinimalUser
|
|
Role MCPServerConfigRole `json:"role" enums:"read"`
|
|
}
|
|
|
|
// MCPServerConfigGroup is a group entry in an MCP server config ACL.
|
|
type MCPServerConfigGroup struct {
|
|
Group
|
|
Role MCPServerConfigRole `json:"role" enums:"read"`
|
|
}
|
|
|
|
// UpdateMCPServerConfigACLRequest is a sparse update of an MCP server
|
|
// config ACL: only the listed principals change, and
|
|
// MCPServerConfigRoleDeleted removes an entry.
|
|
type UpdateMCPServerConfigACLRequest struct {
|
|
UserRoles map[string]MCPServerConfigRole `json:"user_roles,omitempty"`
|
|
GroupRoles map[string]MCPServerConfigRole `json:"group_roles,omitempty"`
|
|
}
|
|
|
|
// CreateMCPServerConfigRequest is the request to create a new MCP server config.
|
|
type CreateMCPServerConfigRequest struct {
|
|
DisplayName string `json:"display_name" validate:"required"`
|
|
Slug string `json:"slug" validate:"required"`
|
|
Description string `json:"description"`
|
|
IconURL string `json:"icon_url"`
|
|
|
|
Transport string `json:"transport" validate:"required,oneof=streamable_http sse"`
|
|
URL string `json:"url" validate:"required,url"`
|
|
|
|
AuthType string `json:"auth_type" validate:"required,oneof=none oauth2 api_key custom_headers user_oidc"`
|
|
OAuth2ClientID string `json:"oauth2_client_id,omitempty"`
|
|
OAuth2ClientSecret string `json:"oauth2_client_secret,omitempty"`
|
|
OAuth2AuthURL string `json:"oauth2_auth_url,omitempty" validate:"omitempty,url"`
|
|
OAuth2TokenURL string `json:"oauth2_token_url,omitempty" validate:"omitempty,url"`
|
|
// OAuth2RevocationURL is the provider's RFC 7009 revocation
|
|
// endpoint; auto-populated by OAuth2 discovery when omitted.
|
|
OAuth2RevocationURL string `json:"oauth2_revocation_url,omitempty" validate:"omitempty,url"`
|
|
OAuth2Scopes string `json:"oauth2_scopes,omitempty"`
|
|
APIKeyHeader string `json:"api_key_header,omitempty"`
|
|
APIKeyValue string `json:"api_key_value,omitempty"`
|
|
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
|
|
|
|
ToolAllowList []string `json:"tool_allow_list,omitempty"`
|
|
ToolDenyList []string `json:"tool_deny_list,omitempty"`
|
|
|
|
Availability string `json:"availability" validate:"required,oneof=force_on default_on default_off"`
|
|
Enabled bool `json:"enabled"`
|
|
ModelIntent bool `json:"model_intent"`
|
|
AllowInPlanMode bool `json:"allow_in_plan_mode"`
|
|
|
|
// ForwardCoderHeaders, when true, forwards Coder identity
|
|
// headers on every outgoing MCP request. See MCPServerConfig.
|
|
ForwardCoderHeaders bool `json:"forward_coder_headers"`
|
|
// SigningSecret signs forwarded identity headers and request bodies.
|
|
// Configure the same secret on the MCP server. It is never returned.
|
|
SigningSecret string `json:"signing_secret,omitempty"`
|
|
}
|
|
|
|
// UpdateMCPServerConfigRequest is the request to update an MCP server config.
|
|
type UpdateMCPServerConfigRequest struct {
|
|
DisplayName *string `json:"display_name,omitempty"`
|
|
Slug *string `json:"slug,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
IconURL *string `json:"icon_url,omitempty"`
|
|
|
|
Transport *string `json:"transport,omitempty" validate:"omitempty,oneof=streamable_http sse"`
|
|
URL *string `json:"url,omitempty" validate:"omitempty,url"`
|
|
|
|
AuthType *string `json:"auth_type,omitempty" validate:"omitempty,oneof=none oauth2 api_key custom_headers user_oidc"`
|
|
OAuth2ClientID *string `json:"oauth2_client_id,omitempty"`
|
|
OAuth2ClientSecret *string `json:"oauth2_client_secret,omitempty"`
|
|
OAuth2AuthURL *string `json:"oauth2_auth_url,omitempty" validate:"omitempty,url"`
|
|
OAuth2TokenURL *string `json:"oauth2_token_url,omitempty" validate:"omitempty,url"`
|
|
// OAuth2RevocationURL is validated in the handler because a
|
|
// validate tag would reject the pointer to "" that clears it.
|
|
OAuth2RevocationURL *string `json:"oauth2_revocation_url,omitempty"`
|
|
OAuth2Scopes *string `json:"oauth2_scopes,omitempty"`
|
|
APIKeyHeader *string `json:"api_key_header,omitempty"`
|
|
APIKeyValue *string `json:"api_key_value,omitempty"`
|
|
CustomHeaders *map[string]string `json:"custom_headers,omitempty"`
|
|
|
|
ToolAllowList *[]string `json:"tool_allow_list,omitempty"`
|
|
ToolDenyList *[]string `json:"tool_deny_list,omitempty"`
|
|
|
|
Availability *string `json:"availability,omitempty" validate:"omitempty,oneof=force_on default_on default_off"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
ModelIntent *bool `json:"model_intent,omitempty"`
|
|
AllowInPlanMode *bool `json:"allow_in_plan_mode,omitempty"`
|
|
|
|
// ForwardCoderHeaders, when set, updates whether Coder identity
|
|
// headers are forwarded on every outgoing MCP request.
|
|
ForwardCoderHeaders *bool `json:"forward_coder_headers,omitempty"`
|
|
// SigningSecret replaces the shared signing key. Omit to preserve it;
|
|
// an empty string clears it. It is never returned.
|
|
SigningSecret *string `json:"signing_secret,omitempty"`
|
|
}
|
|
|
|
func (c *Client) MCPServerConfigs(ctx context.Context, organizationID uuid.UUID) ([]MCPServerConfig, error) {
|
|
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/mcp-servers", organizationID), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return nil, ReadBodyAsError(res)
|
|
}
|
|
var configs []MCPServerConfig
|
|
return configs, ReadBodyAsJSON(res, &configs)
|
|
}
|
|
|
|
func (c *Client) MCPServerConfigByID(ctx context.Context, organizationID, id uuid.UUID) (MCPServerConfig, error) {
|
|
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/mcp-servers/%s", organizationID, id), nil)
|
|
if err != nil {
|
|
return MCPServerConfig{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return MCPServerConfig{}, ReadBodyAsError(res)
|
|
}
|
|
var config MCPServerConfig
|
|
return config, ReadBodyAsJSON(res, &config)
|
|
}
|
|
|
|
// MCPServerConfigACL returns the resolved ACL of an MCP server config.
|
|
func (c *Client) MCPServerConfigACL(ctx context.Context, organizationID, id uuid.UUID) (MCPServerConfigACL, error) {
|
|
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/mcp-servers/%s/acl", organizationID, id), nil)
|
|
if err != nil {
|
|
return MCPServerConfigACL{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return MCPServerConfigACL{}, ReadBodyAsError(res)
|
|
}
|
|
var acl MCPServerConfigACL
|
|
return acl, ReadBodyAsJSON(res, &acl)
|
|
}
|
|
|
|
// MCPServerConfigACLAvailable returns available users and groups that can be
|
|
// assigned to an MCP server config ACL.
|
|
func (c *Client) MCPServerConfigACLAvailable(ctx context.Context, organizationID, id uuid.UUID, req UsersRequest) (ACLAvailable, error) {
|
|
res, err := c.Request(ctx, http.MethodGet,
|
|
fmt.Sprintf("/api/v2/organizations/%s/mcp-servers/%s/acl/available", organizationID, id),
|
|
nil,
|
|
req.Pagination.asRequestOption(),
|
|
req.asRequestOption(),
|
|
)
|
|
if err != nil {
|
|
return ACLAvailable{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return ACLAvailable{}, ReadBodyAsError(res)
|
|
}
|
|
var available ACLAvailable
|
|
return available, ReadBodyAsJSON(res, &available)
|
|
}
|
|
|
|
// UpdateMCPServerConfigACL applies a sparse ACL update to an MCP server
|
|
// config.
|
|
func (c *Client) UpdateMCPServerConfigACL(ctx context.Context, organizationID, id uuid.UUID, req UpdateMCPServerConfigACLRequest) error {
|
|
res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/organizations/%s/mcp-servers/%s/acl", organizationID, id), req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusNoContent {
|
|
return ReadBodyAsError(res)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) CreateMCPServerConfig(ctx context.Context, organizationID uuid.UUID, req CreateMCPServerConfigRequest) (MCPServerConfig, error) {
|
|
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/organizations/%s/mcp-servers", organizationID), req)
|
|
if err != nil {
|
|
return MCPServerConfig{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusCreated {
|
|
return MCPServerConfig{}, ReadBodyAsError(res)
|
|
}
|
|
var config MCPServerConfig
|
|
return config, ReadBodyAsJSON(res, &config)
|
|
}
|
|
|
|
func (c *Client) UpdateMCPServerConfig(ctx context.Context, organizationID, id uuid.UUID, req UpdateMCPServerConfigRequest) (MCPServerConfig, error) {
|
|
res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/organizations/%s/mcp-servers/%s", organizationID, id), req)
|
|
if err != nil {
|
|
return MCPServerConfig{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return MCPServerConfig{}, ReadBodyAsError(res)
|
|
}
|
|
var config MCPServerConfig
|
|
return config, ReadBodyAsJSON(res, &config)
|
|
}
|
|
|
|
func (c *Client) DeleteMCPServerConfig(ctx context.Context, organizationID, id uuid.UUID) error {
|
|
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/organizations/%s/mcp-servers/%s", organizationID, id), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusNoContent {
|
|
return ReadBodyAsError(res)
|
|
}
|
|
return nil
|
|
}
|