Files
coder/coderd/database/sqlc.yaml
T
Kyle Carberry d8ff67fb68 feat: add MCP server configuration backend for chats (#23227)
## Summary

Adds the database schema, API endpoints, SDK types, and encryption
wrappers for admin-managed MCP (Model Context Protocol) server
configurations that chatd can consume. This is the backend foundation
for allowing external MCP tools (Sentry, Linear, GitHub, etc.) to be
used during AI chat sessions.

## Database

Two new tables:
- **`mcp_server_configs`**: Admin-managed server definitions with URL,
transport (Streamable HTTP / SSE), auth config (none / OAuth2 / API key
/ custom headers), tool allow/deny lists, and an availability policy
(`force_on` / `default_on` / `default_off`). Includes CHECK constraints
on transport, auth_type, and availability values.
- **`mcp_server_user_tokens`**: Per-user OAuth2 tokens for servers
requiring individual authentication. Cascades on user/config deletion.

New column on `chats` table:
- **`mcp_server_ids UUID[]`**: Per-chat MCP server selection, following
the same pattern as `model_config_id` — passed at chat creation,
changeable per-message with nil-means-no-change semantics.

## API Endpoints

All routes are under `/api/experimental/mcp/servers/` and gated behind
the `agents` experiment.

**Admin endpoints** (`ResourceDeploymentConfig` auth):
- `POST /` — Create MCP server config
- `PATCH /{id}` — Update MCP server config (full-replace)
- `DELETE /{id}` — Delete MCP server config

**Authenticated endpoints** (all users, enabled servers only for
non-admins):
- `GET /` — List configs (admins see all, members see enabled-only with
admin fields redacted)
- `GET /{id}` — Get config by ID (with `auth_connected` populated
per-user)

**OAuth2 per-user auth flow:**
- `GET /{id}/oauth2/connect` — Initiate OAuth2 flow (state cookie CSRF
protection)
- `GET /{id}/oauth2/callback` — Handle OAuth2 callback, store tokens
- `DELETE /{id}/oauth2/disconnect` — Remove stored OAuth2 tokens

## Security

- **Secrets never returned**: `OAuth2ClientSecret`, `APIKeyValue`, and
`CustomHeaders` are never in API responses — only boolean indicators
(`has_oauth2_secret`, `has_api_key`, `has_custom_headers`).
- **Field redaction for non-admins**: `convertMCPServerConfigRedacted`
strips `OAuth2ClientID`, auth URLs, scopes, and `APIKeyHeader` from
non-admin responses.
- **dbcrypt encryption at rest**: All 5 secret fields use `dbcrypt_keys`
encryption with full encrypt-on-write / decrypt-on-read wrappers (11
dbcrypt method overrides + 2 helpers), following the same pattern as
`chat_providers.api_key`.
- **OAuth2 CSRF protection**: State parameter stored in `HttpOnly`
cookie with `HTTPCookies.Apply()` for correct `Secure`/`SameSite` behind
TLS-terminating proxies.
- **dbauthz authorization**: All 18 querier methods have authorization
wrappers. Read operations use `ActionRead`, write operations use
`ActionUpdate` on `ResourceDeploymentConfig`.

## Governance Model

| Control | Implementation |
|---------|---------------|
| **Global kill switch** | `enabled` defaults to `false` |
| **Availability policy** | `force_on` (always injected), `default_on`
(pre-selected), `default_off` (opt-in) |
| **Per-chat selection** | `mcp_server_ids` on `CreateChatRequest` /
`CreateChatMessageRequest` |
| **Auth gate** | OAuth2 servers require per-user auth before tools are
injected |
| **Tool-level allow/deny** | Arrays on `mcp_server_configs` for
granular tool filtering |
| **Secrets encrypted at rest** | Uses `dbcrypt_keys` (same pattern as
`chat_providers.api_key`) |

## Tests

8 test functions covering:
- Full CRUD lifecycle (create, list, update, delete)
- Non-admin visibility filtering (enabled-only, field redaction)
- `auth_connected` population for OAuth2 vs non-OAuth2 servers
- Availability policy validation (valid values + invalid rejection)
- Unique slug enforcement (409 Conflict)
- OAuth2 disconnect idempotency
- Chat creation with `mcp_server_ids` persistence

## Known Limitations (Deferred)

These are documented and intentional for an experimental feature:
- **Audit logging** not yet wired — will add when feature stabilizes
- **Cross-field validation** (e.g., OAuth2 fields required when
`auth_type=oauth2`) — admin-only endpoint, will add when stabilizing
- **`force_on` auto-injection** — query exists but not yet wired into
chatd tool injection (follow-up)
- **Additional test coverage** — 403 auth tests, GET-by-ID tests,
callback CSRF tests planned for follow-up

## What's NOT in this PR

- Frontend UI (admin panel + chat picker)
- Actual MCP client connections (`chatd/chatmcp/` manager)
- Tool injection into `chatloop/`
2026-03-19 14:07:36 +00:00

267 lines
10 KiB
YAML

# sqlc is used to generate types from sql schema language.
# It was chosen to ensure type-safety when interacting with
# the database.
version: "2"
cloud:
# This is the static ID for the coder project.
project: "01HEP08N3WKWRFZT3ZZ9Q37J8X"
sql:
- schema: "./dump.sql"
queries: "./queries"
engine: "postgresql"
# This only works if you are running a local postgres database with the
# schema loaded and migrations run. Run `make sqlc-vet` to run the linter.
database:
uri: "${SQLC_DATABASE_URL}"
analyzer:
database: false
rules:
- sqlc/db-prepare
- do-not-use-public-schema-in-queries
gen:
go:
package: "database"
out: "./queries"
emit_interface: true
emit_json_tags: true
emit_db_tags: true
emit_enum_valid_method: true
emit_all_enum_values: true
overrides:
- column: "api_keys.scopes"
go_type:
type: "APIKeyScopes"
- column: "api_keys.allow_list"
go_type:
type: "AllowList"
- db_type: "agent_id_name_pair"
go_type:
type: "AgentIDNamePair"
# Used in 'CustomRoles' query to filter by (name,organization_id)
- db_type: "name_organization_pair"
go_type:
type: "NameOrganizationPair"
- db_type: "tagset"
go_type:
type: "StringMap"
- column: "custom_roles.site_permissions"
go_type:
type: "CustomRolePermissions"
- column: "custom_roles.org_permissions"
go_type:
type: "CustomRolePermissions"
- column: "custom_roles.user_permissions"
go_type:
type: "CustomRolePermissions"
- column: "custom_roles.member_permissions"
go_type:
type: "CustomRolePermissions"
- column: "provisioner_daemons.tags"
go_type:
type: "StringMap"
- column: "provisioner_keys.tags"
go_type:
type: "StringMap"
- column: "provisioner_jobs.tags"
go_type:
type: "StringMap"
- column: "users.rbac_roles"
go_type: "github.com/lib/pq.StringArray"
- column: "templates.user_acl"
go_type:
type: "TemplateACL"
- column: "templates.group_acl"
go_type:
type: "TemplateACL"
- column: "template_with_names.user_acl"
go_type:
type: "TemplateACL"
- column: "template_with_names.group_acl"
go_type:
type: "TemplateACL"
- column: "template_usage_stats.app_usage_mins"
go_type:
type: "StringMapOfInt"
- column: "tasks_with_status.workspace_user_acl"
go_type:
type: "WorkspaceACL"
- column: "tasks_with_status.workspace_group_acl"
go_type:
type: "WorkspaceACL"
- column: "workspaces.user_acl"
go_type:
type: "WorkspaceACL"
- column: "workspaces.group_acl"
go_type:
type: "WorkspaceACL"
- column: "workspaces_expanded.user_acl"
go_type:
type: "WorkspaceACL"
- column: "workspaces_expanded.group_acl"
go_type:
type: "WorkspaceACL"
- column: "workspaces_expanded.user_acl_display_info"
go_type:
type: "WorkspaceACLDisplayInfo"
- column: "workspaces_expanded.group_acl_display_info"
go_type:
type: "WorkspaceACLDisplayInfo"
- column: "notification_templates.actions"
go_type:
type: "[]byte"
- column: "notification_messages.payload"
go_type:
type: "[]byte"
- column: "provisioner_job_stats.*_secs"
go_type:
type: "float64"
- column: "user_links.claims"
go_type:
type: "UserLinkClaims"
# Workaround for sqlc not interpreting the left join correctly.
- column: "tasks_with_status.workspace_build_number"
go_type: "database/sql.NullInt32"
- column: "tasks_with_status.status"
go_type:
type: "TaskStatus"
- column: "tasks_with_status.workspace_agent_lifecycle_state"
go_type:
type: "NullWorkspaceAgentLifecycleState"
- column: "tasks_with_status.workspace_app_health"
go_type:
type: "NullWorkspaceAppHealth"
# Workaround for sqlc not interpreting the left join correctly
# in the combined telemetry query.
- column: "task_event_data.start_build_number"
go_type: "database/sql.NullInt32"
- column: "task_event_data.stop_build_created_at"
go_type: "database/sql.NullTime"
- column: "task_event_data.stop_build_reason"
go_type:
type: "NullBuildReason"
- column: "task_event_data.start_build_created_at"
go_type: "database/sql.NullTime"
- column: "task_event_data.start_build_reason"
go_type:
type: "NullBuildReason"
- column: "task_event_data.last_working_status_at"
go_type: "database/sql.NullTime"
- column: "task_event_data.first_status_after_resume_at"
go_type: "database/sql.NullTime"
- db_type: "pg_catalog.numeric"
go_type:
import: "github.com/shopspring/decimal"
type: "Decimal"
package: "decimal"
- db_type: "pg_catalog.numeric"
nullable: true
go_type:
import: "github.com/shopspring/decimal"
type: "NullDecimal"
package: "decimal"
rename:
group_member: GroupMemberTable
group_members_expanded: GroupMember
template: TemplateTable
template_with_name: Template
workspace_build: WorkspaceBuildTable
workspace_build_with_user: WorkspaceBuild
workspace: WorkspaceTable
workspaces_expanded: Workspace
task: TaskTable
tasks_with_status: Task
template_version: TemplateVersionTable
template_version_with_user: TemplateVersion
api_key: APIKey
api_key_scope: APIKeyScope
api_key_scope_all: APIKeyScopeAll
api_key_scope_application_connect: APIKeyScopeApplicationConnect
api_version: APIVersion
avatar_url: AvatarURL
created_by_avatar_url: CreatedByAvatarURL
dbcrypt_key: DBCryptKey
session_count_vscode: SessionCountVSCode
session_count_jetbrains: SessionCountJetBrains
session_count_reconnecting_pty: SessionCountReconnectingPTY
session_count_ssh: SessionCountSSH
connection_median_latency_ms: ConnectionMedianLatencyMS
login_type_oidc: LoginTypeOIDC
oauth_access_token: OAuthAccessToken
oauth_access_token_key_id: OAuthAccessTokenKeyID
oauth_expiry: OAuthExpiry
oauth_id_token: OAuthIDToken
oauth_refresh_token: OAuthRefreshToken
oauth_refresh_token_key_id: OAuthRefreshTokenKeyID
oauth_extra: OAuthExtra
parameter_type_system_hcl: ParameterTypeSystemHCL
userstatus: UserStatus
gitsshkey: GitSSHKey
rbac_roles: RBACRoles
ip_address: IPAddress
ip_addresses: IPAddresses
ids: IDs
jwt: JWT
user_acl: UserACL
group_acl: GroupACL
workspace_user_acl: WorkspaceUserACL
workspace_group_acl: WorkspaceGroupACL
user_acl_display_info: UserACLDisplayInfo
group_acl_display_info: GroupACLDisplayInfo
troubleshooting_url: TroubleshootingURL
default_ttl: DefaultTTL
motd_file: MOTDFile
uuid: UUID
failure_ttl: FailureTTL
time_til_dormant_autodelete: TimeTilDormantAutoDelete
eof: EOF
template_ids: TemplateIDs
active_user_ids: ActiveUserIDs
display_app_ssh_helper: DisplayAppSSHHelper
oauth2_provider_app: OAuth2ProviderApp
oauth2_provider_app_secret: OAuth2ProviderAppSecret
oauth2_provider_app_code: OAuth2ProviderAppCode
oauth2_provider_app_token: OAuth2ProviderAppToken
api_key_id: APIKeyID
callback_url: CallbackURL
login_type_oauth2_provider_app: LoginTypeOAuth2ProviderApp
crypto_key_feature_workspace_apps_api_key: CryptoKeyFeatureWorkspaceAppsAPIKey
crypto_key_feature_oidc_convert: CryptoKeyFeatureOIDCConvert
stale_interval_ms: StaleIntervalMS
has_ai_task: HasAITask
ai_task_sidebar_app_id: AITaskSidebarAppID
latest_build_has_ai_task: LatestBuildHasAITask
cors_behavior: CorsBehavior
aibridge_interception: AIBridgeInterception
aibridge_tool_usage: AIBridgeToolUsage
aibridge_token_usage: AIBridgeTokenUsage
aibridge_user_prompt: AIBridgeUserPrompt
aibridge_model_thought: AIBridgeModelThought
mcp_server_config: MCPServerConfig
mcp_server_configs: MCPServerConfigs
mcp_server_user_token: MCPServerUserToken
mcp_server_user_tokens: MCPServerUserTokens
mcp_server_tool_snapshot: MCPServerToolSnapshot
mcp_server_tool_snapshots: MCPServerToolSnapshots
mcp_server_config_id: MCPServerConfigID
mcp_server_ids: MCPServerIDs
icon_url: IconURL
oauth2_client_id: OAuth2ClientID
oauth2_client_secret: OAuth2ClientSecret
oauth2_client_secret_key_id: OAuth2ClientSecretKeyID
oauth2_auth_url: OAuth2AuthURL
oauth2_token_url: OAuth2TokenURL
oauth2_scopes: OAuth2Scopes
api_key_header: APIKeyHeader
api_key_value: APIKeyValue
api_key_value_key_id: APIKeyValueKeyID
custom_headers_key_id: CustomHeadersKeyID
tools_json: ToolsJSON
access_token_key_id: AccessTokenKeyID
refresh_token_key_id: RefreshTokenKeyID
rules:
- name: do-not-use-public-schema-in-queries
message: "do not use public schema in queries"
# FIXME: It would be great to run sqlc-vet against `migrations` directory and `dump.sql`.
rule: >
query.sql.matches(r'[^a-z]public\.')