From ee4cd13df4d0aaf15bda9dcb508fbb4c7b3eae1f Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 28 Jul 2026 21:07:38 +0000 Subject: [PATCH 01/59] feat(coderd): org-scope mcp server config schema Add organization_id to mcp_server_configs, backfilling every existing row to the default organization and making slug uniqueness per-organization, as the schema stage of CODAGT-711. Runtime behavior is preserved during the stage window: every path that fetches an MCP server config for a chat (generation preparation, Explore subagent snapshots, and the chat create/update request validation) accepts configs from the chat's organization or the default organization, marked for removal at the B3 org-scoping cutover. New configs are created in the default organization, with the organization resolved under a call-scoped chatd subject so callers holding only deployment_config permissions are unaffected. - Migration 000561: nullable add, backfill to default org (aborting loudly when none exists), SET NOT NULL, replace mcp_server_configs_slug_key with UNIQUE (organization_id, slug), org index; the down migration restores the deployment-wide slug constraint. - GetMCPServerConfigBySlug becomes GetMCPServerConfigBySlugAndOrganization; enabled/forced queries gain ByOrganization variants; the insert gains the org column; unfiltered list queries stay for the old handlers until B3. - dbgen.MCPServerConfig seeds the default org; subjectChatd gains site-level organization read for the fallback resolution. - Fixture + Stepper test covering the backfill, secret-pair preservation, per-org slug uniqueness, and the down migration. --- coderd/database/dbauthz/dbauthz.go | 23 +- coderd/database/dbauthz/dbauthz_test.go | 37 +++- coderd/database/dbgen/dbgen.go | 10 + coderd/database/dbmetrics/querymetrics.go | 24 ++- coderd/database/dbmock/dbmock.go | 42 +++- coderd/database/dump.sql | 10 +- coderd/database/foreign_key_constraint.go | 1 + ...cp_server_configs_organization_id.down.sql | 10 + ..._mcp_server_configs_organization_id.up.sql | 32 +++ ..._mcp_server_configs_organization_id.up.sql | 151 ++++++++++++++ coderd/database/models.go | 1 + coderd/database/querier.go | 4 +- coderd/database/queries.sql.go | 196 ++++++++++++++++-- coderd/database/queries/mcpserverconfigs.sql | 30 ++- coderd/database/unique_constraint.go | 2 +- coderd/exp_chats.go | 62 +++++- coderd/exp_chats_test.go | 194 +++++++++++++++++ coderd/mcp.go | 29 +++ coderd/mcp_test.go | 64 ++++++ coderd/x/chatd/generation_preparer.go | 75 ++++++- .../generation_preparer_internal_test.go | 179 ++++++++++++++++ coderd/x/chatd/subagent.go | 2 +- enterprise/dbcrypt/dbcrypt.go | 30 ++- enterprise/dbcrypt/dbcrypt_internal_test.go | 15 +- 24 files changed, 1170 insertions(+), 53 deletions(-) create mode 100644 coderd/database/migrations/000565_mcp_server_configs_organization_id.down.sql create mode 100644 coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000565_mcp_server_configs_organization_id.up.sql diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 743e51538b9bb..afba8e08a3ae5 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -795,6 +795,11 @@ var ( rbac.ResourceWorkspace.Type: {policy.ActionRead, policy.ActionUpdate}, rbac.ResourceDeploymentConfig.Type: {policy.ActionRead}, rbac.ResourceUser.Type: {policy.ActionReadPersonal}, + // TODO(mafredri): remove after CODAGT-711 B3 + // (org-scoping cutover). The chat-org-then-default-org + // fallback for MCP server configs resolves the default + // organization under the chatd subject. + rbac.ResourceOrganization.Type: {policy.ActionRead}, }), User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{}, @@ -3849,6 +3854,13 @@ func (q *querier) GetForcedMCPServerConfigs(ctx context.Context) ([]database.MCP return q.db.GetForcedMCPServerConfigs(ctx) } +func (q *querier) GetForcedMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return nil, err + } + return q.db.GetForcedMCPServerConfigsByOrganization(ctx, organizationID) +} + func (q *querier) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) { return fetchWithAction(q.log, q.auth, policy.ActionReadPersonal, q.db.GetGitSSHKey)(ctx, userID) } @@ -4054,11 +4066,11 @@ func (q *querier) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (dat return q.db.GetMCPServerConfigByID(ctx, id) } -func (q *querier) GetMCPServerConfigBySlug(ctx context.Context, slug string) (database.MCPServerConfig, error) { +func (q *querier) GetMCPServerConfigByOrganizationAndSlug(ctx context.Context, arg database.GetMCPServerConfigByOrganizationAndSlugParams) (database.MCPServerConfig, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { return database.MCPServerConfig{}, err } - return q.db.GetMCPServerConfigBySlug(ctx, slug) + return q.db.GetMCPServerConfigByOrganizationAndSlug(ctx, arg) } func (q *querier) GetMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { @@ -4075,6 +4087,13 @@ func (q *querier) GetMCPServerConfigsByIDs(ctx context.Context, ids []uuid.UUID) return q.db.GetMCPServerConfigsByIDs(ctx, ids) } +func (q *querier) GetMCPServerConfigsByIDsAndOrganizations(ctx context.Context, arg database.GetMCPServerConfigsByIDsAndOrganizationsParams) ([]database.MCPServerConfig, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return nil, err + } + return q.db.GetMCPServerConfigsByIDsAndOrganizations(ctx, arg) +} + func (q *querier) GetMCPServerUserToken(ctx context.Context, arg database.GetMCPServerUserTokenParams) (database.MCPServerUserToken, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { return database.MCPServerUserToken{}, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 38d8435e71bd2..04139647c80f2 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1657,16 +1657,26 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetForcedMCPServerConfigs(gomock.Any()).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.MCPServerConfig{configA, configB}) })) + s.Run("GetForcedMCPServerConfigsByOrganization", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + orgID := uuid.New() + configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: orgID, Availability: "force_on"}) + configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: orgID, Availability: "force_on"}) + dbm.EXPECT().GetForcedMCPServerConfigsByOrganization(gomock.Any(), orgID).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() + check.Args(orgID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.MCPServerConfig{configA, configB}) + })) s.Run("GetMCPServerConfigByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { config := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) dbm.EXPECT().GetMCPServerConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes() check.Args(config.ID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(config) })) - s.Run("GetMCPServerConfigBySlug", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - slug := "test-mcp-server" - config := testutil.Fake(s.T(), faker, database.MCPServerConfig{Slug: slug}) - dbm.EXPECT().GetMCPServerConfigBySlug(gomock.Any(), slug).Return(config, nil).AnyTimes() - check.Args(slug).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(config) + s.Run("GetMCPServerConfigByOrganizationAndSlug", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + arg := database.GetMCPServerConfigByOrganizationAndSlugParams{ + OrganizationID: uuid.New(), + Slug: "test-mcp-server", + } + config := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: arg.OrganizationID, Slug: arg.Slug}) + dbm.EXPECT().GetMCPServerConfigByOrganizationAndSlug(gomock.Any(), arg).Return(config, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(config) })) s.Run("GetMCPServerConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) @@ -1674,6 +1684,16 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetMCPServerConfigs(gomock.Any()).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.MCPServerConfig{configA, configB}) })) + s.Run("GetMCPServerConfigsByIDsAndOrganizations", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + arg := database.GetMCPServerConfigsByIDsAndOrganizationsParams{ + IDs: []uuid.UUID{uuid.New(), uuid.New()}, + OrganizationIds: []uuid.UUID{uuid.New(), uuid.New()}, + } + configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{ID: arg.IDs[0], OrganizationID: arg.OrganizationIds[0]}) + configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{ID: arg.IDs[1], OrganizationID: arg.OrganizationIds[1]}) + dbm.EXPECT().GetMCPServerConfigsByIDsAndOrganizations(gomock.Any(), arg).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.MCPServerConfig{configA, configB}) + })) s.Run("GetMCPServerConfigsByIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) @@ -7564,6 +7584,13 @@ func TestAsChatd(t *testing.T) { err = auth.Authorize(ctx, actor, policy.ActionUpdate, rbac.ResourceDeploymentConfig) require.Error(t, err, "deployment config update should not be allowed") + // Organization read (needed for the MCP server config + // chat-org-then-default-org fallback). + err = auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceOrganization) + require.NoError(t, err, "organization read should be allowed") + err = auth.Authorize(ctx, actor, policy.ActionUpdate, rbac.ResourceOrganization) + require.Error(t, err, "organization update should not be allowed") + // User read_personal (needed for GetUserChatCustomPrompt). err = auth.Authorize(ctx, actor, policy.ActionReadPersonal, rbac.ResourceUser) require.NoError(t, err, "user read_personal should be allowed") diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index df4f2cc2afb43..973cd673a9308 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -328,6 +328,15 @@ func ChatProvider(t testing.TB, db database.Store, seed database.ChatProvider, m func MCPServerConfig(t testing.TB, db database.Store, seed database.MCPServerConfig) database.MCPServerConfig { t.Helper() + // New configs belong to the default organization, matching the + // org-less shape they had before configs became org-scoped. + organizationID := seed.OrganizationID + if organizationID == uuid.Nil { + defaultOrg, err := db.GetDefaultOrganization(genCtx) + require.NoError(t, err, "get default organization") + organizationID = defaultOrg.ID + } + // CreatedBy and UpdatedBy are user FKs, so default fixtures create a user. createdBy := seed.CreatedBy.UUID if createdBy == uuid.Nil { @@ -339,6 +348,7 @@ func MCPServerConfig(t testing.TB, db database.Store, seed database.MCPServerCon } cfg, err := db.InsertMCPServerConfig(genCtx, database.InsertMCPServerConfigParams{ + OrganizationID: organizationID, DisplayName: takeFirst(seed.DisplayName, "Test MCP Server"), Slug: takeFirst(seed.Slug, testutil.GetRandomName(t)), Description: seed.Description, diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 66d40bbb23959..cd76b51c81e06 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2089,6 +2089,14 @@ func (m queryMetricsStore) GetForcedMCPServerConfigs(ctx context.Context) ([]dat return r0, r1 } +func (m queryMetricsStore) GetForcedMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { + start := time.Now() + r0, r1 := m.s.GetForcedMCPServerConfigsByOrganization(ctx, organizationID) + m.queryLatencies.WithLabelValues("GetForcedMCPServerConfigsByOrganization").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetForcedMCPServerConfigsByOrganization").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) { start := time.Now() r0, r1 := m.s.GetGitSSHKey(ctx, userID) @@ -2321,11 +2329,11 @@ func (m queryMetricsStore) GetMCPServerConfigByID(ctx context.Context, id uuid.U return r0, r1 } -func (m queryMetricsStore) GetMCPServerConfigBySlug(ctx context.Context, slug string) (database.MCPServerConfig, error) { +func (m queryMetricsStore) GetMCPServerConfigByOrganizationAndSlug(ctx context.Context, arg database.GetMCPServerConfigByOrganizationAndSlugParams) (database.MCPServerConfig, error) { start := time.Now() - r0, r1 := m.s.GetMCPServerConfigBySlug(ctx, slug) - m.queryLatencies.WithLabelValues("GetMCPServerConfigBySlug").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetMCPServerConfigBySlug").Inc() + r0, r1 := m.s.GetMCPServerConfigByOrganizationAndSlug(ctx, arg) + m.queryLatencies.WithLabelValues("GetMCPServerConfigByOrganizationAndSlug").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetMCPServerConfigByOrganizationAndSlug").Inc() return r0, r1 } @@ -2345,6 +2353,14 @@ func (m queryMetricsStore) GetMCPServerConfigsByIDs(ctx context.Context, ids []u return r0, r1 } +func (m queryMetricsStore) GetMCPServerConfigsByIDsAndOrganizations(ctx context.Context, arg database.GetMCPServerConfigsByIDsAndOrganizationsParams) ([]database.MCPServerConfig, error) { + start := time.Now() + r0, r1 := m.s.GetMCPServerConfigsByIDsAndOrganizations(ctx, arg) + m.queryLatencies.WithLabelValues("GetMCPServerConfigsByIDsAndOrganizations").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetMCPServerConfigsByIDsAndOrganizations").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetMCPServerUserToken(ctx context.Context, arg database.GetMCPServerUserTokenParams) (database.MCPServerUserToken, error) { start := time.Now() r0, r1 := m.s.GetMCPServerUserToken(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 01d8960437173..6e137297252f5 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3885,6 +3885,21 @@ func (mr *MockStoreMockRecorder) GetForcedMCPServerConfigs(ctx any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetForcedMCPServerConfigs", reflect.TypeOf((*MockStore)(nil).GetForcedMCPServerConfigs), ctx) } +// GetForcedMCPServerConfigsByOrganization mocks base method. +func (m *MockStore) GetForcedMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetForcedMCPServerConfigsByOrganization", ctx, organizationID) + ret0, _ := ret[0].([]database.MCPServerConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetForcedMCPServerConfigsByOrganization indicates an expected call of GetForcedMCPServerConfigsByOrganization. +func (mr *MockStoreMockRecorder) GetForcedMCPServerConfigsByOrganization(ctx, organizationID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetForcedMCPServerConfigsByOrganization", reflect.TypeOf((*MockStore)(nil).GetForcedMCPServerConfigsByOrganization), ctx, organizationID) +} + // GetGitSSHKey mocks base method. func (m *MockStore) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) { m.ctrl.T.Helper() @@ -4320,19 +4335,19 @@ func (mr *MockStoreMockRecorder) GetMCPServerConfigByID(ctx, id any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigByID", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigByID), ctx, id) } -// GetMCPServerConfigBySlug mocks base method. -func (m *MockStore) GetMCPServerConfigBySlug(ctx context.Context, slug string) (database.MCPServerConfig, error) { +// GetMCPServerConfigByOrganizationAndSlug mocks base method. +func (m *MockStore) GetMCPServerConfigByOrganizationAndSlug(ctx context.Context, arg database.GetMCPServerConfigByOrganizationAndSlugParams) (database.MCPServerConfig, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetMCPServerConfigBySlug", ctx, slug) + ret := m.ctrl.Call(m, "GetMCPServerConfigByOrganizationAndSlug", ctx, arg) ret0, _ := ret[0].(database.MCPServerConfig) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetMCPServerConfigBySlug indicates an expected call of GetMCPServerConfigBySlug. -func (mr *MockStoreMockRecorder) GetMCPServerConfigBySlug(ctx, slug any) *gomock.Call { +// GetMCPServerConfigByOrganizationAndSlug indicates an expected call of GetMCPServerConfigByOrganizationAndSlug. +func (mr *MockStoreMockRecorder) GetMCPServerConfigByOrganizationAndSlug(ctx, arg any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigBySlug", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigBySlug), ctx, slug) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigByOrganizationAndSlug", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigByOrganizationAndSlug), ctx, arg) } // GetMCPServerConfigs mocks base method. @@ -4365,6 +4380,21 @@ func (mr *MockStoreMockRecorder) GetMCPServerConfigsByIDs(ctx, ids any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigsByIDs", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigsByIDs), ctx, ids) } +// GetMCPServerConfigsByIDsAndOrganizations mocks base method. +func (m *MockStore) GetMCPServerConfigsByIDsAndOrganizations(ctx context.Context, arg database.GetMCPServerConfigsByIDsAndOrganizationsParams) ([]database.MCPServerConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMCPServerConfigsByIDsAndOrganizations", ctx, arg) + ret0, _ := ret[0].([]database.MCPServerConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetMCPServerConfigsByIDsAndOrganizations indicates an expected call of GetMCPServerConfigsByIDsAndOrganizations. +func (mr *MockStoreMockRecorder) GetMCPServerConfigsByIDsAndOrganizations(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigsByIDsAndOrganizations", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigsByIDsAndOrganizations), ctx, arg) +} + // GetMCPServerUserToken mocks base method. func (m *MockStore) GetMCPServerUserToken(ctx context.Context, arg database.GetMCPServerUserTokenParams) (database.MCPServerUserToken, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index ee444df91696b..7ed8084694a0c 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -2514,6 +2514,7 @@ CREATE TABLE mcp_server_configs ( allow_in_plan_mode boolean DEFAULT false NOT NULL, forward_coder_headers boolean DEFAULT false NOT NULL, oauth2_revocation_url text DEFAULT ''::text NOT NULL, + organization_id uuid NOT NULL, CONSTRAINT mcp_server_configs_auth_type_check CHECK ((auth_type = ANY (ARRAY['none'::text, 'oauth2'::text, 'api_key'::text, 'custom_headers'::text, 'user_oidc'::text]))), CONSTRAINT mcp_server_configs_availability_check CHECK ((availability = ANY (ARRAY['force_on'::text, 'default_on'::text, 'default_off'::text]))), CONSTRAINT mcp_server_configs_transport_check CHECK ((transport = ANY (ARRAY['streamable_http'::text, 'sse'::text]))) @@ -4404,10 +4405,10 @@ ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_pkey PRIMARY KEY (id); ALTER TABLE ONLY mcp_server_configs - ADD CONSTRAINT mcp_server_configs_pkey PRIMARY KEY (id); + ADD CONSTRAINT mcp_server_configs_organization_id_slug_key UNIQUE (organization_id, slug); ALTER TABLE ONLY mcp_server_configs - ADD CONSTRAINT mcp_server_configs_slug_key UNIQUE (slug); + ADD CONSTRAINT mcp_server_configs_pkey PRIMARY KEY (id); ALTER TABLE ONLY mcp_server_user_tokens ADD CONSTRAINT mcp_server_user_tokens_mcp_server_config_id_user_id_key UNIQUE (mcp_server_config_id, user_id); @@ -4872,6 +4873,8 @@ CREATE INDEX idx_mcp_server_configs_enabled ON mcp_server_configs USING btree (e CREATE INDEX idx_mcp_server_configs_forced ON mcp_server_configs USING btree (enabled, availability) WHERE ((enabled = true) AND (availability = 'force_on'::text)); +CREATE INDEX idx_mcp_server_configs_organization_id ON mcp_server_configs USING btree (organization_id); + CREATE INDEX idx_mcp_server_user_tokens_user_id ON mcp_server_user_tokens USING btree (user_id); CREATE INDEX idx_notification_messages_status ON notification_messages USING btree (status); @@ -5296,6 +5299,9 @@ ALTER TABLE ONLY mcp_server_configs ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_oauth2_client_secret_key_id_fkey FOREIGN KEY (oauth2_client_secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); +ALTER TABLE ONLY mcp_server_configs + ADD CONSTRAINT mcp_server_configs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_updated_by_fkey FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index a24e3d73b5c7b..f760c4c426fc9 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -60,6 +60,7 @@ const ( ForeignKeyMcpServerConfigsCreatedBy ForeignKeyConstraint = "mcp_server_configs_created_by_fkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL; ForeignKeyMcpServerConfigsCustomHeadersKeyID ForeignKeyConstraint = "mcp_server_configs_custom_headers_key_id_fkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_custom_headers_key_id_fkey FOREIGN KEY (custom_headers_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyMcpServerConfigsOauth2ClientSecretKeyID ForeignKeyConstraint = "mcp_server_configs_oauth2_client_secret_key_id_fkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_oauth2_client_secret_key_id_fkey FOREIGN KEY (oauth2_client_secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyMcpServerConfigsOrganizationID ForeignKeyConstraint = "mcp_server_configs_organization_id_fkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; ForeignKeyMcpServerConfigsUpdatedBy ForeignKeyConstraint = "mcp_server_configs_updated_by_fkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_updated_by_fkey FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL; ForeignKeyMcpServerUserTokensAccessTokenKeyID ForeignKeyConstraint = "mcp_server_user_tokens_access_token_key_id_fkey" // ALTER TABLE ONLY mcp_server_user_tokens ADD CONSTRAINT mcp_server_user_tokens_access_token_key_id_fkey FOREIGN KEY (access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyMcpServerUserTokensMcpServerConfigID ForeignKeyConstraint = "mcp_server_user_tokens_mcp_server_config_id_fkey" // ALTER TABLE ONLY mcp_server_user_tokens ADD CONSTRAINT mcp_server_user_tokens_mcp_server_config_id_fkey FOREIGN KEY (mcp_server_config_id) REFERENCES mcp_server_configs(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000565_mcp_server_configs_organization_id.down.sql b/coderd/database/migrations/000565_mcp_server_configs_organization_id.down.sql new file mode 100644 index 0000000000000..d4505385a2197 --- /dev/null +++ b/coderd/database/migrations/000565_mcp_server_configs_organization_id.down.sql @@ -0,0 +1,10 @@ +-- Restore the deployment-wide slug uniqueness before dropping the org +-- column. This is safe only because every row lives in the default +-- organization during the schema-stage window; the org-scoping cutover +-- (CODAGT-711 B3) removes this assumption. +ALTER TABLE mcp_server_configs DROP CONSTRAINT mcp_server_configs_organization_id_slug_key; +ALTER TABLE mcp_server_configs ADD CONSTRAINT mcp_server_configs_slug_key UNIQUE (slug); + +DROP INDEX idx_mcp_server_configs_organization_id; + +ALTER TABLE mcp_server_configs DROP COLUMN organization_id; diff --git a/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql new file mode 100644 index 0000000000000..72ab28dc1bc80 --- /dev/null +++ b/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql @@ -0,0 +1,32 @@ +-- Org-scope MCP server configs: every config belongs to exactly one +-- organization. This migration backfills all existing rows to the default +-- organization; runtime behavior is preserved by a chat-org-then-default-org +-- lookup window that ends at the org-scoping cutover (CODAGT-711 B3). + +-- Step 1: Add the nullable column with FK (000467 recipe). +ALTER TABLE mcp_server_configs + ADD COLUMN organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE; + +-- Step 2: Backfill every row to the default organization. Abort loudly if +-- the deployment has no default organization; a silent partial backfill +-- would fail the NOT NULL step with an opaque error. +DO $$ +DECLARE + default_org_id UUID; +BEGIN + SELECT id INTO default_org_id FROM organizations WHERE is_default = true LIMIT 1; + IF default_org_id IS NULL THEN + RAISE EXCEPTION 'cannot backfill mcp_server_configs.organization_id: no default organization exists'; + END IF; + UPDATE mcp_server_configs SET organization_id = default_org_id; +END $$; + +-- Step 3: Enforce NOT NULL going forward. +ALTER TABLE mcp_server_configs ALTER COLUMN organization_id SET NOT NULL; + +-- Step 4: Slug uniqueness becomes per-organization. +ALTER TABLE mcp_server_configs DROP CONSTRAINT mcp_server_configs_slug_key; +ALTER TABLE mcp_server_configs ADD CONSTRAINT mcp_server_configs_organization_id_slug_key UNIQUE (organization_id, slug); + +-- Step 5: Index for efficient lookups by organization. +CREATE INDEX idx_mcp_server_configs_organization_id ON mcp_server_configs (organization_id); diff --git a/coderd/database/migrations/testdata/fixtures/000565_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/testdata/fixtures/000565_mcp_server_configs_organization_id.up.sql new file mode 100644 index 0000000000000..ac5c3a05445a5 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000565_mcp_server_configs_organization_id.up.sql @@ -0,0 +1,151 @@ +-- Exercises the 000561 org column: an MCP server config (with a user token +-- and a chat referencing it) carries the new organization_id, keeping later +-- migrations and the final down sweep honest about the FK. The row is +-- inserted at 000561 with organization_id already set because fixtures run +-- after the migration of the same version. + +INSERT INTO organizations ( + id, + name, + display_name, + description, + icon, + created_at, + updated_at, + is_default, + deleted, + default_org_member_roles +) VALUES ( + 'f5610000-0000-4000-8000-000000000001', + 'fixture-mcp-org', + 'Fixture MCP Org', + '', + '', + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00', + FALSE, + FALSE, + '{}' +); + +INSERT INTO ai_providers ( + id, + type, + name, + display_name, + enabled, + base_url, + created_at, + updated_at +) VALUES ( + 'f5610000-0000-4000-8000-000000000003', + 'openai', + 'fixture-mcp-ai-provider', + 'Fixture MCP AI Provider', + TRUE, + 'https://example.com', + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' +); + +INSERT INTO chat_model_configs ( + id, + model, + display_name, + ai_provider_id, + context_limit, + compression_threshold, + created_at, + updated_at +) VALUES ( + 'f5610000-0000-4000-8000-000000000004', + 'fixture-model', + 'Fixture Model', + 'f5610000-0000-4000-8000-000000000003', + 128000, + 70, + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' +); + +INSERT INTO dbcrypt_keys (number, active_key_digest, test) +VALUES (561000, 'fixture-000561-key-digest', 'fixture-000561'); + +-- MCP server config with a ciphertext-shaped secret pair (value + key ID) +-- to assert the backfill leaves secret columns byte-identical. The key ID +-- references dbcrypt_keys(active_key_digest). +INSERT INTO mcp_server_configs ( + id, + organization_id, + display_name, + slug, + url, + auth_type, + api_key_value, + api_key_value_key_id, + availability, + enabled, + created_by, + updated_by, + created_at, + updated_at +) +SELECT + 'f5610000-0000-4000-8000-000000000005', + (SELECT id FROM organizations WHERE is_default = true LIMIT 1), + 'Fixture Org Backfill MCP Server', + 'fixture-org-backfill-mcp-server', + 'https://mcp.example.com/org-backfill', + 'api_key', + 'fixture-ciphertext', + 'fixture-000561-key-digest', + 'default_on', + TRUE, + u.id, + u.id, + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' +FROM users u +ORDER BY u.created_at, u.id +LIMIT 1; + +INSERT INTO mcp_server_user_tokens ( + id, + mcp_server_config_id, + user_id, + access_token, + token_type, + created_at, + updated_at +) +SELECT + 'f5610000-0000-4000-8000-000000000006', + 'f5610000-0000-4000-8000-000000000005', + id, + 'fixture-org-backfill-access-token', + 'Bearer', + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' +FROM users +ORDER BY created_at, id +LIMIT 1; + +INSERT INTO chats ( + id, + owner_id, + organization_id, + last_model_config_id, + title, + mcp_server_ids, + created_at, + updated_at +) VALUES ( + 'f5610000-0000-4000-8000-000000000007', + (SELECT id FROM users ORDER BY created_at, id LIMIT 1), + 'f5610000-0000-4000-8000-000000000001', + 'f5610000-0000-4000-8000-000000000004', + 'Fixture MCP Org Backfill Chat', + '{f5610000-0000-4000-8000-000000000005}'::uuid[], + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' +); diff --git a/coderd/database/models.go b/coderd/database/models.go index cff3b4c6bf446..c77c730781ede 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -5455,6 +5455,7 @@ type MCPServerConfig struct { AllowInPlanMode bool `db:"allow_in_plan_mode" json:"allow_in_plan_mode"` ForwardCoderHeaders bool `db:"forward_coder_headers" json:"forward_coder_headers"` OAuth2RevocationURL string `db:"oauth2_revocation_url" json:"oauth2_revocation_url"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` } type MCPServerUserToken struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index b7ccde5502925..d4e7bcc6c583f 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -580,6 +580,7 @@ type sqlcQuerier interface { // param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25 GetFilteredInboxNotificationsByUserID(ctx context.Context, arg GetFilteredInboxNotificationsByUserIDParams) ([]InboxNotification, error) GetForcedMCPServerConfigs(ctx context.Context) ([]MCPServerConfig, error) + GetForcedMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]MCPServerConfig, error) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) GetGroupAIBudget(ctx context.Context, groupID uuid.UUID) (GroupAIBudget, error) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) @@ -645,9 +646,10 @@ type sqlcQuerier interface { GetLicenses(ctx context.Context) ([]License, error) GetLogoURL(ctx context.Context) (string, error) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (MCPServerConfig, error) - GetMCPServerConfigBySlug(ctx context.Context, slug string) (MCPServerConfig, error) + GetMCPServerConfigByOrganizationAndSlug(ctx context.Context, arg GetMCPServerConfigByOrganizationAndSlugParams) (MCPServerConfig, error) GetMCPServerConfigs(ctx context.Context) ([]MCPServerConfig, error) GetMCPServerConfigsByIDs(ctx context.Context, ids []uuid.UUID) ([]MCPServerConfig, error) + GetMCPServerConfigsByIDsAndOrganizations(ctx context.Context, arg GetMCPServerConfigsByIDsAndOrganizationsParams) ([]MCPServerConfig, error) GetMCPServerUserToken(ctx context.Context, arg GetMCPServerUserTokenParams) (MCPServerUserToken, error) GetMCPServerUserTokensByUserID(ctx context.Context, userID uuid.UUID) ([]MCPServerUserToken, error) // Must be called from within a transaction. The row lock is released diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 39c721ce36023..43be80abaf58b 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -17135,7 +17135,7 @@ func (q *sqlQuerier) DeleteMCPServerUserToken(ctx context.Context, arg DeleteMCP const getEnabledMCPServerConfigs = `-- name: GetEnabledMCPServerConfigs :many SELECT - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id FROM mcp_server_configs WHERE @@ -17185,6 +17185,7 @@ func (q *sqlQuerier) GetEnabledMCPServerConfigs(ctx context.Context) ([]MCPServe &i.AllowInPlanMode, &i.ForwardCoderHeaders, &i.OAuth2RevocationURL, + &i.OrganizationID, ); err != nil { return nil, err } @@ -17201,7 +17202,7 @@ func (q *sqlQuerier) GetEnabledMCPServerConfigs(ctx context.Context) ([]MCPServe const getForcedMCPServerConfigs = `-- name: GetForcedMCPServerConfigs :many SELECT - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id FROM mcp_server_configs WHERE @@ -17252,6 +17253,76 @@ func (q *sqlQuerier) GetForcedMCPServerConfigs(ctx context.Context) ([]MCPServer &i.AllowInPlanMode, &i.ForwardCoderHeaders, &i.OAuth2RevocationURL, + &i.OrganizationID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getForcedMCPServerConfigsByOrganization = `-- name: GetForcedMCPServerConfigsByOrganization :many +SELECT + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id +FROM + mcp_server_configs +WHERE + organization_id = $1::uuid + AND enabled = TRUE + AND availability = 'force_on' +ORDER BY + display_name ASC +` + +func (q *sqlQuerier) GetForcedMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]MCPServerConfig, error) { + rows, err := q.db.QueryContext(ctx, getForcedMCPServerConfigsByOrganization, organizationID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []MCPServerConfig + for rows.Next() { + var i MCPServerConfig + if err := rows.Scan( + &i.ID, + &i.DisplayName, + &i.Slug, + &i.Description, + &i.IconURL, + &i.Transport, + &i.Url, + &i.AuthType, + &i.OAuth2ClientID, + &i.OAuth2ClientSecret, + &i.OAuth2ClientSecretKeyID, + &i.OAuth2AuthURL, + &i.OAuth2TokenURL, + &i.OAuth2Scopes, + &i.APIKeyHeader, + &i.APIKeyValue, + &i.APIKeyValueKeyID, + &i.CustomHeaders, + &i.CustomHeadersKeyID, + pq.Array(&i.ToolAllowList), + pq.Array(&i.ToolDenyList), + &i.Availability, + &i.Enabled, + &i.CreatedBy, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.ModelIntent, + &i.AllowInPlanMode, + &i.ForwardCoderHeaders, + &i.OAuth2RevocationURL, + &i.OrganizationID, ); err != nil { return nil, err } @@ -17268,7 +17339,7 @@ func (q *sqlQuerier) GetForcedMCPServerConfigs(ctx context.Context) ([]MCPServer const getMCPServerConfigByID = `-- name: GetMCPServerConfigByID :one SELECT - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id FROM mcp_server_configs WHERE @@ -17310,21 +17381,28 @@ func (q *sqlQuerier) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) ( &i.AllowInPlanMode, &i.ForwardCoderHeaders, &i.OAuth2RevocationURL, + &i.OrganizationID, ) return i, err } -const getMCPServerConfigBySlug = `-- name: GetMCPServerConfigBySlug :one +const getMCPServerConfigByOrganizationAndSlug = `-- name: GetMCPServerConfigByOrganizationAndSlug :one SELECT - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id FROM mcp_server_configs WHERE - slug = $1::text + organization_id = $1::uuid + AND slug = $2::text ` -func (q *sqlQuerier) GetMCPServerConfigBySlug(ctx context.Context, slug string) (MCPServerConfig, error) { - row := q.db.QueryRowContext(ctx, getMCPServerConfigBySlug, slug) +type GetMCPServerConfigByOrganizationAndSlugParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Slug string `db:"slug" json:"slug"` +} + +func (q *sqlQuerier) GetMCPServerConfigByOrganizationAndSlug(ctx context.Context, arg GetMCPServerConfigByOrganizationAndSlugParams) (MCPServerConfig, error) { + row := q.db.QueryRowContext(ctx, getMCPServerConfigByOrganizationAndSlug, arg.OrganizationID, arg.Slug) var i MCPServerConfig err := row.Scan( &i.ID, @@ -17358,13 +17436,14 @@ func (q *sqlQuerier) GetMCPServerConfigBySlug(ctx context.Context, slug string) &i.AllowInPlanMode, &i.ForwardCoderHeaders, &i.OAuth2RevocationURL, + &i.OrganizationID, ) return i, err } const getMCPServerConfigs = `-- name: GetMCPServerConfigs :many SELECT - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id FROM mcp_server_configs ORDER BY @@ -17412,6 +17491,7 @@ func (q *sqlQuerier) GetMCPServerConfigs(ctx context.Context) ([]MCPServerConfig &i.AllowInPlanMode, &i.ForwardCoderHeaders, &i.OAuth2RevocationURL, + &i.OrganizationID, ); err != nil { return nil, err } @@ -17428,7 +17508,7 @@ func (q *sqlQuerier) GetMCPServerConfigs(ctx context.Context) ([]MCPServerConfig const getMCPServerConfigsByIDs = `-- name: GetMCPServerConfigsByIDs :many SELECT - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id FROM mcp_server_configs WHERE @@ -17478,6 +17558,80 @@ func (q *sqlQuerier) GetMCPServerConfigsByIDs(ctx context.Context, ids []uuid.UU &i.AllowInPlanMode, &i.ForwardCoderHeaders, &i.OAuth2RevocationURL, + &i.OrganizationID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getMCPServerConfigsByIDsAndOrganizations = `-- name: GetMCPServerConfigsByIDsAndOrganizations :many +SELECT + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id +FROM + mcp_server_configs +WHERE + id = ANY($1::uuid[]) + AND organization_id = ANY($2::uuid[]) +ORDER BY + display_name ASC +` + +type GetMCPServerConfigsByIDsAndOrganizationsParams struct { + IDs []uuid.UUID `db:"ids" json:"ids"` + OrganizationIds []uuid.UUID `db:"organization_ids" json:"organization_ids"` +} + +func (q *sqlQuerier) GetMCPServerConfigsByIDsAndOrganizations(ctx context.Context, arg GetMCPServerConfigsByIDsAndOrganizationsParams) ([]MCPServerConfig, error) { + rows, err := q.db.QueryContext(ctx, getMCPServerConfigsByIDsAndOrganizations, pq.Array(arg.IDs), pq.Array(arg.OrganizationIds)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []MCPServerConfig + for rows.Next() { + var i MCPServerConfig + if err := rows.Scan( + &i.ID, + &i.DisplayName, + &i.Slug, + &i.Description, + &i.IconURL, + &i.Transport, + &i.Url, + &i.AuthType, + &i.OAuth2ClientID, + &i.OAuth2ClientSecret, + &i.OAuth2ClientSecretKeyID, + &i.OAuth2AuthURL, + &i.OAuth2TokenURL, + &i.OAuth2Scopes, + &i.APIKeyHeader, + &i.APIKeyValue, + &i.APIKeyValueKeyID, + &i.CustomHeaders, + &i.CustomHeadersKeyID, + pq.Array(&i.ToolAllowList), + pq.Array(&i.ToolDenyList), + &i.Availability, + &i.Enabled, + &i.CreatedBy, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.ModelIntent, + &i.AllowInPlanMode, + &i.ForwardCoderHeaders, + &i.OAuth2RevocationURL, + &i.OrganizationID, ); err != nil { return nil, err } @@ -17574,6 +17728,7 @@ func (q *sqlQuerier) GetMCPServerUserTokensByUserID(ctx context.Context, userID const insertMCPServerConfig = `-- name: InsertMCPServerConfig :one INSERT INTO mcp_server_configs ( + organization_id, display_name, slug, description, @@ -17603,7 +17758,7 @@ INSERT INTO mcp_server_configs ( created_by, updated_by ) VALUES ( - $1::text, + $1::uuid, $2::text, $3::text, $4::text, @@ -17622,21 +17777,23 @@ INSERT INTO mcp_server_configs ( $17::text, $18::text, $19::text, - $20::text[], + $20::text, $21::text[], - $22::text, - $23::boolean, + $22::text[], + $23::text, $24::boolean, $25::boolean, $26::boolean, - $27::uuid, - $28::uuid + $27::boolean, + $28::uuid, + $29::uuid ) RETURNING - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id ` type InsertMCPServerConfigParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` DisplayName string `db:"display_name" json:"display_name"` Slug string `db:"slug" json:"slug"` Description string `db:"description" json:"description"` @@ -17669,6 +17826,7 @@ type InsertMCPServerConfigParams struct { func (q *sqlQuerier) InsertMCPServerConfig(ctx context.Context, arg InsertMCPServerConfigParams) (MCPServerConfig, error) { row := q.db.QueryRowContext(ctx, insertMCPServerConfig, + arg.OrganizationID, arg.DisplayName, arg.Slug, arg.Description, @@ -17731,6 +17889,7 @@ func (q *sqlQuerier) InsertMCPServerConfig(ctx context.Context, arg InsertMCPSer &i.AllowInPlanMode, &i.ForwardCoderHeaders, &i.OAuth2RevocationURL, + &i.OrganizationID, ) return i, err } @@ -17818,7 +17977,7 @@ SET WHERE id = $28::uuid RETURNING - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id ` type UpdateMCPServerConfigParams struct { @@ -17916,6 +18075,7 @@ func (q *sqlQuerier) UpdateMCPServerConfig(ctx context.Context, arg UpdateMCPSer &i.AllowInPlanMode, &i.ForwardCoderHeaders, &i.OAuth2RevocationURL, + &i.OrganizationID, ) return i, err } diff --git a/coderd/database/queries/mcpserverconfigs.sql b/coderd/database/queries/mcpserverconfigs.sql index ad21c95f7dbb7..9a48df0a51769 100644 --- a/coderd/database/queries/mcpserverconfigs.sql +++ b/coderd/database/queries/mcpserverconfigs.sql @@ -6,13 +6,14 @@ FROM WHERE id = @id::uuid; --- name: GetMCPServerConfigBySlug :one +-- name: GetMCPServerConfigByOrganizationAndSlug :one SELECT * FROM mcp_server_configs WHERE - slug = @slug::text; + organization_id = @organization_id::uuid + AND slug = @slug::text; -- name: GetMCPServerConfigs :many SELECT @@ -42,6 +43,17 @@ WHERE ORDER BY display_name ASC; +-- name: GetMCPServerConfigsByIDsAndOrganizations :many +SELECT + * +FROM + mcp_server_configs +WHERE + id = ANY(@ids::uuid[]) + AND organization_id = ANY(@organization_ids::uuid[]) +ORDER BY + display_name ASC; + -- name: GetForcedMCPServerConfigs :many SELECT * @@ -53,8 +65,21 @@ WHERE ORDER BY display_name ASC; +-- name: GetForcedMCPServerConfigsByOrganization :many +SELECT + * +FROM + mcp_server_configs +WHERE + organization_id = @organization_id::uuid + AND enabled = TRUE + AND availability = 'force_on' +ORDER BY + display_name ASC; + -- name: InsertMCPServerConfig :one INSERT INTO mcp_server_configs ( + organization_id, display_name, slug, description, @@ -84,6 +109,7 @@ INSERT INTO mcp_server_configs ( created_by, updated_by ) VALUES ( + @organization_id::uuid, @display_name::text, @slug::text, @description::text, diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 23256ed3b86fc..79ec48aa26d6c 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -53,8 +53,8 @@ const ( UniqueJfrogXrayScansPkey UniqueConstraint = "jfrog_xray_scans_pkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_pkey PRIMARY KEY (agent_id, workspace_id); UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt); UniqueLicensesPkey UniqueConstraint = "licenses_pkey" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_pkey PRIMARY KEY (id); + UniqueMcpServerConfigsOrganizationIDSlugKey UniqueConstraint = "mcp_server_configs_organization_id_slug_key" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_organization_id_slug_key UNIQUE (organization_id, slug); UniqueMcpServerConfigsPkey UniqueConstraint = "mcp_server_configs_pkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_pkey PRIMARY KEY (id); - UniqueMcpServerConfigsSlugKey UniqueConstraint = "mcp_server_configs_slug_key" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_slug_key UNIQUE (slug); UniqueMcpServerUserTokensMcpServerConfigIDUserIDKey UniqueConstraint = "mcp_server_user_tokens_mcp_server_config_id_user_id_key" // ALTER TABLE ONLY mcp_server_user_tokens ADD CONSTRAINT mcp_server_user_tokens_mcp_server_config_id_user_id_key UNIQUE (mcp_server_config_id, user_id); UniqueMcpServerUserTokensPkey UniqueConstraint = "mcp_server_user_tokens_pkey" // ALTER TABLE ONLY mcp_server_user_tokens ADD CONSTRAINT mcp_server_user_tokens_pkey PRIMARY KEY (id); UniqueNotificationMessagesPkey UniqueConstraint = "notification_messages_pkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_pkey PRIMARY KEY (id); diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 8b3f69ee5c0c4..8c3d090a4d84f 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -1257,6 +1257,48 @@ func (api *API) validateExplicitChatModelConfigAvailable( // @Failure 413 {object} codersdk.Response "Request body exceeds 256 KiB" // @Router /api/experimental/chats [post] // @Description Experimental: this endpoint is subject to change. +// chatMCPServerConfigs returns the requested MCP server configs that exist +// within the chat's organization OR the default organization, in +// display_name order with one row per unique ID (both properties of the +// query). Chat create/update request validation compares the result against +// the raw request, so duplicates and out-of-org IDs are both rejected +// exactly as they were before configs were org-scoped. Disabled configs +// count as existing: the generation path skips them, as it did before +// org-scoping. +// +// TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). +func chatMCPServerConfigs( + ctx context.Context, + db database.Store, + organizationID uuid.UUID, + ids []uuid.UUID, +) ([]database.MCPServerConfig, error) { + if len(ids) == 0 { + return []database.MCPServerConfig{}, nil + } + + // The default organization is resolved as chatd: callers may hold a + // custom role without organization:read on it. + //nolint:gocritic // Organization resolution is an internal detail, not a permission the caller must hold. + defaultOrg, err := db.GetDefaultOrganization(dbauthz.AsChatd(ctx)) + if err != nil { + return nil, xerrors.Errorf("get default organization: %w", err) + } + organizationIDs := []uuid.UUID{organizationID} + if !slices.Contains(organizationIDs, defaultOrg.ID) { + organizationIDs = append(organizationIDs, defaultOrg.ID) + } + + configs, err := db.GetMCPServerConfigsByIDsAndOrganizations(ctx, database.GetMCPServerConfigsByIDsAndOrganizationsParams{ + IDs: ids, + OrganizationIds: organizationIDs, + }) + if err != nil { + return nil, xerrors.Errorf("get MCP server configs for organizations: %w", err) + } + return configs, nil +} + func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) @@ -1337,10 +1379,14 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { return } - // Validate MCP server IDs exist. + // Validate MCP server IDs exist and belong to the chat's + // organization (falling back to the default organization until + // the org-scoping cutover). Disabled configs are accepted: the + // generation path skips them, as it did before org-scoping. + // TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). if len(req.MCPServerIDs) > 0 { - //nolint:gocritic // Need to validate MCP server IDs exist. - existingConfigs, err := api.Database.GetMCPServerConfigsByIDs(dbauthz.AsSystemRestricted(ctx), req.MCPServerIDs) + //nolint:gocritic // Need to validate MCP server IDs exist and are usable by the chat's organization. + existingConfigs, err := chatMCPServerConfigs(dbauthz.AsSystemRestricted(ctx), api.Database, req.OrganizationID, req.MCPServerIDs) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to validate MCP server IDs.", @@ -2735,10 +2781,14 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { return } - // Validate MCP server IDs exist. + // Validate MCP server IDs exist and belong to the chat's + // organization (falling back to the default organization until + // the org-scoping cutover). Disabled configs are accepted: the + // generation path skips them, as it did before org-scoping. + // TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). if req.MCPServerIDs != nil && len(*req.MCPServerIDs) > 0 { - //nolint:gocritic // Need to validate MCP server IDs exist. - existingConfigs, err := api.Database.GetMCPServerConfigsByIDs(dbauthz.AsSystemRestricted(ctx), *req.MCPServerIDs) + //nolint:gocritic // Need to validate MCP server IDs exist and are usable by the chat's organization. + existingConfigs, err := chatMCPServerConfigs(dbauthz.AsSystemRestricted(ctx), api.Database, chat.OrganizationID, *req.MCPServerIDs) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to validate MCP server IDs.", diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index da0edd73215cd..f9e1d800ac640 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -570,6 +570,200 @@ func TestPostChats(t *testing.T) { })) }) + t.Run("MCPServerIDsDefaultOrgFallback", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + // The chat lives in a second organization, but the only enabled + // MCP server config lives in the default organization. During the + // fallback window the create must accept it. + defaultOrgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: firstUser.OrganizationID, + Enabled: true, + }) + + secondOrg := dbgen.Organization(t, db, database.Organization{}) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, secondOrg.ID, rbac.ScopedRoleAgentsAccess(secondOrg.ID)) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: secondOrg.ID, + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "chat with a default-org MCP server", + }, + }, + MCPServerIDs: []uuid.UUID{defaultOrgConfig.ID}, + }) + require.NoError(t, err) + require.Equal(t, []uuid.UUID{defaultOrgConfig.ID}, chat.MCPServerIDs) + }) + + t.Run("MCPServerIDsDuplicatesRejected", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + // Duplicate valid IDs are rejected with the pre-org-scoping + // response: 400, fixed message, and an EMPTY Invalid IDs detail + // (the SQL dedupes, the raw-length comparison flags the + // mismatch, and no ID is missing). Whether duplicates should be + // rejected, and what the detail should say, is CODAGT-870's + // decision; this stage preserves the old behavior. + defaultOrgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: firstUser.OrganizationID, + Enabled: true, + }) + + secondOrg := dbgen.Organization(t, db, database.Organization{}) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, secondOrg.ID, rbac.ScopedRoleAgentsAccess(secondOrg.ID)) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: secondOrg.ID, + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "chat with a duplicated MCP server ID", + }, + }, + MCPServerIDs: []uuid.UUID{defaultOrgConfig.ID, defaultOrgConfig.ID}, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "One or more MCP server IDs are invalid.", sdkErr.Message) + require.Equal(t, "Invalid IDs: ", sdkErr.Detail) + require.Equal(t, uuid.Nil, chat.ID) + + // Seed a valid chat, then reject the duplicate on message + // update with the identical response. + validChat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: secondOrg.ID, + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "chat with a single MCP server ID", + }, + }, + MCPServerIDs: []uuid.UUID{defaultOrgConfig.ID}, + }) + require.NoError(t, err) + + _, err = memberClient.CreateChatMessage(ctx, validChat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "update to a duplicated MCP server ID", + }, + }, + MCPServerIDs: &[]uuid.UUID{defaultOrgConfig.ID, defaultOrgConfig.ID}, + }) + sdkErr = requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "One or more MCP server IDs are invalid.", sdkErr.Message) + require.Equal(t, "Invalid IDs: ", sdkErr.Detail) + }) + + t.Run("MCPServerIDsDisabledConfigAccepted", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + // A disabled config in the default organization: attaching it to + // a chat (and updating the chat to keep it) must stay accepted, + // as before org-scoping. The generation path skips disabled + // configs, so this changes nothing about tool exposure. + user := dbgen.User(t, db, database.User{}) + disabledCfg, err := db.InsertMCPServerConfig(dbauthz.AsSystemRestricted(ctx), database.InsertMCPServerConfigParams{ + OrganizationID: firstUser.OrganizationID, + DisplayName: "Disabled MCP Server", + Slug: testutil.GetRandomName(t), + Url: "https://mcp.example.com", + Transport: "streamable_http", + AuthType: "none", + ToolAllowList: []string{}, + ToolDenyList: []string{}, + Availability: "default_off", + Enabled: false, + CreatedBy: user.ID, + UpdatedBy: user.ID, + }) + require.NoError(t, err) + + secondOrg := dbgen.Organization(t, db, database.Organization{}) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, secondOrg.ID, rbac.ScopedRoleAgentsAccess(secondOrg.ID)) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: secondOrg.ID, + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "chat with a disabled default-org MCP server", + }, + }, + MCPServerIDs: []uuid.UUID{disabledCfg.ID}, + }) + require.NoError(t, err) + require.Equal(t, []uuid.UUID{disabledCfg.ID}, chat.MCPServerIDs) + + // The message-update validation path accepts it too. + _, err = memberClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "still keeping the disabled config", + }, + }, + MCPServerIDs: &[]uuid.UUID{disabledCfg.ID}, + }) + require.NoError(t, err) + }) + + t.Run("MCPServerIDsThirdOrgRejected", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + // The enabled config belongs to a third organization: neither the + // chat's organization nor the default organization, so the create + // must reject it even during the fallback window. + thirdOrg := dbgen.Organization(t, db, database.Organization{}) + thirdOrgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: thirdOrg.ID, + Enabled: true, + }) + + secondOrg := dbgen.Organization(t, db, database.Organization{}) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, secondOrg.ID, rbac.ScopedRoleAgentsAccess(secondOrg.ID)) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: secondOrg.ID, + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "chat with a third-org MCP server", + }, + }, + MCPServerIDs: []uuid.UUID{thirdOrgConfig.ID}, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "One or more MCP server IDs are invalid.", sdkErr.Message) + }) + t.Run("MemberWithoutAgentsAccess", func(t *testing.T) { t.Parallel() diff --git a/coderd/mcp.go b/coderd/mcp.go index f9c90b01b6bbf..da039a2e4fd7c 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -269,7 +269,23 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { return } + // New configs are created in the default organization. + // Callers may hold a custom role with + // deployment_config:update and no organization:read, so + // resolve the organization as chatd while the insert + // itself stays under the caller's context. + //nolint:gocritic // Organization resolution is an internal detail, not a permission the caller must hold. + defaultOrg, orgErr := api.Database.GetDefaultOrganization(dbauthz.AsChatd(ctx)) + if orgErr != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to resolve default organization.", + Detail: orgErr.Error(), + }) + return + } + inserted, err := api.Database.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{ + OrganizationID: defaultOrg.ID, DisplayName: strings.TrimSpace(req.DisplayName), Slug: strings.TrimSpace(req.Slug), Description: strings.TrimSpace(req.Description), @@ -448,7 +464,20 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { return } + // New configs are created in the default organization. See the + // auto-discovery branch above for why this resolves as chatd. + //nolint:gocritic // Organization resolution is an internal detail, not a permission the caller must hold. + defaultOrg, orgErr := api.Database.GetDefaultOrganization(dbauthz.AsChatd(ctx)) + if orgErr != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to resolve default organization.", + Detail: orgErr.Error(), + }) + return + } + inserted, err := api.Database.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{ + OrganizationID: defaultOrg.ID, DisplayName: strings.TrimSpace(req.DisplayName), Slug: strings.TrimSpace(req.Slug), Description: strings.TrimSpace(req.Description), diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 064dcdba83189..460e1ec89ab46 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -23,6 +23,10 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -69,6 +73,66 @@ func createMCPServerConfig(t testing.TB, client *codersdk.Client, slug string, e return config } +// TestCreateMCPServerConfigCustomRole verifies that a caller holding a +// persisted site custom role with deployment_config read+update but no +// organization:read can still create configs: the default-organization +// resolution behind the gate must not require permissions the gate itself +// does not imply. +func TestCreateMCPServerConfigCustomRole(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + rawStore, _, rawSQLDB := dbtestutil.NewDBWithSQLDB(t) + client := coderdtest.New(t, &coderdtest.Options{Database: rawStore}) + firstUser := coderdtest.CreateFirstUser(t, client) + db := rawStore + + // The user belongs to a non-default org, so they hold no implicit + // organization read on the default org. Their only permissions come + // from a persisted site custom role granting deployment_config + // read+update and nothing else. Custom roles persisted through + // dbauthz cannot carry site permissions, so write directly to the + // raw database. + secondOrg := dbgen.Organization(t, db, database.Organization{}) + customRole, err := database.New(rawSQLDB).InsertCustomRole(ctx, database.InsertCustomRoleParams{ + Name: "mcp-config-manager", + SitePermissions: []database.CustomRolePermission{ + {ResourceType: rbac.ResourceDeploymentConfig.Type, Action: policy.ActionRead}, + {ResourceType: rbac.ResourceDeploymentConfig.Type, Action: policy.ActionUpdate}, + }, + OrgPermissions: []database.CustomRolePermission{}, + UserPermissions: []database.CustomRolePermission{}, + }) + require.NoError(t, err) + user := dbgen.User(t, db, database.User{RBACRoles: []string{customRole.Name}}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: secondOrg.ID, + }) + _, token := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID}) + userClient := codersdk.New(client.URL) + userClient.SetSessionToken(token) + + created, err := userClient.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "Custom Role Server", + Slug: "custom-role-server", + Transport: "streamable_http", + URL: "https://mcp.example.com/custom-role", + AuthType: "none", + Availability: "default_on", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, + }) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, created.ID) + + // The config lands in the default organization. + stored, err := db.GetMCPServerConfigByID(dbauthz.AsSystemRestricted(ctx), created.ID) + require.NoError(t, err) + require.Equal(t, firstUser.OrganizationID, stored.OrganizationID) +} + func TestMCPServerConfigsCRUD(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index f9085157de85b..27db8aa856171 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -42,7 +42,7 @@ func (server *Server) effectiveMCPServerConfigs( var configs []database.MCPServerConfig if len(chat.MCPServerIDs) > 0 { var err error - configs, err = server.db.GetMCPServerConfigsByIDs(ctx, chat.MCPServerIDs) + configs, err = enabledMCPServerConfigsForChatOrg(ctx, server.db, chat.OrganizationID, chat.MCPServerIDs) if err != nil { // Best-effort for the user-selected set, matching prior // behavior: a load failure degrades the turn rather than @@ -910,3 +910,76 @@ func latestAssistantText(messages []database.ChatMessage) string { } return "" } + +// enabledMCPServerConfigsForChatOrg returns the requested MCP server +// configs that a chat in the given organization may use at generation time: +// those that belong to the chat's organization OR to the default +// organization and are enabled. The enabled filter is applied here in Go, +// mirroring the MCP client's connect-time skip of disabled configs +// (mcpclient.go), so the fetch shape matches pre-org-scoping behavior. +// +// TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). Until +// the cutover, configs fetched for a chat are valid when they belong to the +// chat's organization OR to the default organization. All existing configs +// were backfilled to the default organization in migration 000561 and every +// create path still assigns it, so a strict chat-org check would detach MCP +// servers from chats in every other organization. The cutover fans configs +// out to every organization and switches this to a strict +// chat-organization-only lookup. +func enabledMCPServerConfigsForChatOrg( + ctx context.Context, + db database.Store, + organizationID uuid.UUID, + ids []uuid.UUID, +) ([]database.MCPServerConfig, error) { + if len(ids) == 0 { + return []database.MCPServerConfig{}, nil + } + + organizationIDs, err := eligibleMCPServerConfigOrganizations(ctx, db, organizationID) + if err != nil { + return nil, err + } + configs, err := db.GetMCPServerConfigsByIDsAndOrganizations(ctx, database.GetMCPServerConfigsByIDsAndOrganizationsParams{ + IDs: ids, + OrganizationIds: organizationIDs, + }) + if err != nil { + return nil, xerrors.Errorf("get MCP server configs for organizations: %w", err) + } + + enabled := make([]database.MCPServerConfig, 0, len(configs)) + for _, cfg := range configs { + if !cfg.Enabled { + continue + } + enabled = append(enabled, cfg) + } + return enabled, nil +} + +// eligibleMCPServerConfigOrganizations returns the chat's organization plus +// the default organization (deduplicated), the pair every chat MCP config +// lookup accepts during the fallback window. +// +// The default organization lookup is a second failure surface that did not +// exist before org-scoping: when it fails transiently, callers that +// log-and-continue (generation preparation) strip all MCP tools for the +// turn rather than just the config that would have failed. Window-limited; +// the B3 strict scoping removes it. +func eligibleMCPServerConfigOrganizations( + ctx context.Context, + db database.Store, + organizationID uuid.UUID, +) ([]uuid.UUID, error) { + defaultOrg, err := db.GetDefaultOrganization(ctx) + if err != nil { + return nil, xerrors.Errorf("get default organization: %w", err) + } + + organizationIDs := []uuid.UUID{organizationID} + if !slices.Contains(organizationIDs, defaultOrg.ID) { + organizationIDs = append(organizationIDs, defaultOrg.ID) + } + return organizationIDs, nil +} diff --git a/coderd/x/chatd/generation_preparer_internal_test.go b/coderd/x/chatd/generation_preparer_internal_test.go index 83fd496739bc8..815da25d42603 100644 --- a/coderd/x/chatd/generation_preparer_internal_test.go +++ b/coderd/x/chatd/generation_preparer_internal_test.go @@ -21,6 +21,7 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) func mustMarshalText(t *testing.T, parts ...string) pqtype.NullRawMessage { @@ -621,3 +622,181 @@ func TestShouldCompactPromptUsage(t *testing.T) { contextLimit, 80)) }) } + +func TestEnabledMCPServerConfigsForChatOrg(t *testing.T) { + t.Parallel() + + newOrgWithConfig := func(t *testing.T, db database.Store, enabled bool) (database.Organization, database.MCPServerConfig) { + t.Helper() + org := dbgen.Organization(t, db, database.Organization{}) + cfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: org.ID, + Enabled: enabled, + }) + return org, cfg + } + + t.Run("ChatOrgAndDefaultOrgFallback", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + defaultOrg, err := db.GetDefaultOrganization(ctx) + require.NoError(t, err) + + // The chat lives in a non-default organization. Both of its MCP + // server configs live elsewhere: one in its own organization and + // one in the default organization. During the fallback window both + // must resolve. + chatOrg, chatOrgCfg := newOrgWithConfig(t, db, true) + defaultOrgCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: defaultOrg.ID, + Enabled: true, + }) + + configs, err := enabledMCPServerConfigsForChatOrg(ctx, db, chatOrg.ID, []uuid.UUID{chatOrgCfg.ID, defaultOrgCfg.ID}) + require.NoError(t, err) + require.Len(t, configs, 2) + gotIDs := map[uuid.UUID]struct{}{} + for _, cfg := range configs { + gotIDs[cfg.ID] = struct{}{} + } + require.Contains(t, gotIDs, chatOrgCfg.ID) + require.Contains(t, gotIDs, defaultOrgCfg.ID) + }) + + t.Run("ThirdOrgConfigExcluded", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + chatOrg, chatOrgCfg := newOrgWithConfig(t, db, true) + _, foreignCfg := newOrgWithConfig(t, db, true) + + // A config belonging to a third organization is not usable by the + // chat, while its own organization's config still resolves. + configs, err := enabledMCPServerConfigsForChatOrg(ctx, db, chatOrg.ID, []uuid.UUID{chatOrgCfg.ID, foreignCfg.ID}) + require.NoError(t, err) + require.Len(t, configs, 1) + require.Equal(t, chatOrgCfg.ID, configs[0].ID) + }) + + t.Run("DisabledConfigExcluded", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + // dbgen.MCPServerConfig defaults Enabled to true, so insert the + // disabled config directly. + chatOrg := dbgen.Organization(t, db, database.Organization{}) + user := dbgen.User(t, db, database.User{}) + disabledCfg, err := db.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{ + OrganizationID: chatOrg.ID, + DisplayName: "Disabled MCP Server", + Slug: testutil.GetRandomName(t), + Url: "https://mcp.example.com", + Transport: "streamable_http", + AuthType: "none", + ToolAllowList: []string{}, + ToolDenyList: []string{}, + Availability: "default_off", + Enabled: false, + CreatedBy: user.ID, + UpdatedBy: user.ID, + }) + require.NoError(t, err) + + configs, err := enabledMCPServerConfigsForChatOrg(ctx, db, chatOrg.ID, []uuid.UUID{disabledCfg.ID}) + require.NoError(t, err) + require.Empty(t, configs) + }) + + t.Run("DuplicateIDsYieldOneConfigPerUniqueID", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + defaultOrg, err := db.GetDefaultOrganization(ctx) + require.NoError(t, err) + + // A legacy/hostile chats.mcp_server_ids array can contain the + // same ID twice (the column has no uniqueness constraint). + // Pre-org-scoping the SQL returned one row per unique ID, in + // display_name order; the generation helper must preserve that shape. + chatOrg, chatOrgCfg := newOrgWithConfig(t, db, true) + defaultOrgCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: defaultOrg.ID, + Enabled: true, + }) + + // The requested order is the reverse of display_name order to + // prove the output ordering comes from the SQL, not the request. + requested := []uuid.UUID{defaultOrgCfg.ID, chatOrgCfg.ID, defaultOrgCfg.ID, chatOrgCfg.ID} + + configs, err := enabledMCPServerConfigsForChatOrg(ctx, db, chatOrg.ID, requested) + require.NoError(t, err) + require.Len(t, configs, 2) + gotIDs := []uuid.UUID{configs[0].ID, configs[1].ID} + require.ElementsMatch(t, []uuid.UUID{chatOrgCfg.ID, defaultOrgCfg.ID}, gotIDs) + wantOrder := []uuid.UUID{chatOrgCfg.ID, defaultOrgCfg.ID} + if chatOrgCfg.DisplayName > defaultOrgCfg.DisplayName { + wantOrder = []uuid.UUID{defaultOrgCfg.ID, chatOrgCfg.ID} + } + require.Equal(t, wantOrder, gotIDs, "output must follow display_name order, not request order") + }) + + t.Run("ChatOrgWithNoConfigsFallsBackToDefaultOrg", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + defaultOrg, err := db.GetDefaultOrganization(ctx) + require.NoError(t, err) + + // The chat's organization has no MCP configs at all, so the + // default organization's enabled configs serve it directly. + chatOrg := dbgen.Organization(t, db, database.Organization{}) + defaultOrgCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: defaultOrg.ID, + Enabled: true, + }) + + configs, err := enabledMCPServerConfigsForChatOrg(ctx, db, chatOrg.ID, []uuid.UUID{defaultOrgCfg.ID}) + require.NoError(t, err) + require.Len(t, configs, 1) + require.Equal(t, defaultOrgCfg.ID, configs[0].ID) + }) + + t.Run("ChatOrgConfigsDoNotHideDefaultOrgConfigs", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + defaultOrg, err := db.GetDefaultOrganization(ctx) + require.NoError(t, err) + + // Even when the chat's organization has its own configs, a config + // living in the default organization still resolves (the fallback + // is an OR, not a preference). + chatOrg, _ := newOrgWithConfig(t, db, true) + defaultOrgCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: defaultOrg.ID, + Enabled: true, + }) + + configs, err := enabledMCPServerConfigsForChatOrg(ctx, db, chatOrg.ID, []uuid.UUID{defaultOrgCfg.ID}) + require.NoError(t, err) + require.Len(t, configs, 1) + require.Equal(t, defaultOrgCfg.ID, configs[0].ID) + }) + + t.Run("EmptyIDs", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + configs, err := enabledMCPServerConfigsForChatOrg(ctx, db, uuid.New(), nil) + require.NoError(t, err) + require.Empty(t, configs) + }) +} diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index f51099bd6669d..88f38dd4a06d4 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -1205,7 +1205,7 @@ func (p *Server) resolveExploreToolSnapshot( ) ([]uuid.UUID, error) { inheritedMCPServerIDs := []uuid.UUID{} if len(parent.MCPServerIDs) > 0 { - configs, err := p.db.GetMCPServerConfigsByIDs(ctx, parent.MCPServerIDs) + configs, err := enabledMCPServerConfigsForChatOrg(ctx, p.db, parent.OrganizationID, parent.MCPServerIDs) if err != nil { return nil, xerrors.Errorf("get parent MCP server configs for chat %s: %w", parent.ID, err) } diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index 6c9150f17a33f..852072db8ff5c 100644 --- a/enterprise/dbcrypt/dbcrypt.go +++ b/enterprise/dbcrypt/dbcrypt.go @@ -713,8 +713,8 @@ func (db *dbCrypt) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (da return cfg, nil } -func (db *dbCrypt) GetMCPServerConfigBySlug(ctx context.Context, slug string) (database.MCPServerConfig, error) { - cfg, err := db.Store.GetMCPServerConfigBySlug(ctx, slug) +func (db *dbCrypt) GetMCPServerConfigByOrganizationAndSlug(ctx context.Context, arg database.GetMCPServerConfigByOrganizationAndSlugParams) (database.MCPServerConfig, error) { + cfg, err := db.Store.GetMCPServerConfigByOrganizationAndSlug(ctx, arg) if err != nil { return database.MCPServerConfig{}, err } @@ -750,6 +750,19 @@ func (db *dbCrypt) GetMCPServerConfigsByIDs(ctx context.Context, ids []uuid.UUID return cfgs, nil } +func (db *dbCrypt) GetMCPServerConfigsByIDsAndOrganizations(ctx context.Context, arg database.GetMCPServerConfigsByIDsAndOrganizationsParams) ([]database.MCPServerConfig, error) { + cfgs, err := db.Store.GetMCPServerConfigsByIDsAndOrganizations(ctx, arg) + if err != nil { + return nil, err + } + for i := range cfgs { + if err := db.decryptMCPServerConfig(&cfgs[i]); err != nil { + return nil, err + } + } + return cfgs, nil +} + func (db *dbCrypt) GetEnabledMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { cfgs, err := db.Store.GetEnabledMCPServerConfigs(ctx) if err != nil { @@ -776,6 +789,19 @@ func (db *dbCrypt) GetForcedMCPServerConfigs(ctx context.Context) ([]database.MC return cfgs, nil } +func (db *dbCrypt) GetForcedMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { + cfgs, err := db.Store.GetForcedMCPServerConfigsByOrganization(ctx, organizationID) + if err != nil { + return nil, err + } + for i := range cfgs { + if err := db.decryptMCPServerConfig(&cfgs[i]); err != nil { + return nil, err + } + } + return cfgs, nil +} + func (db *dbCrypt) GetMCPServerUserToken(ctx context.Context, arg database.GetMCPServerUserTokenParams) (database.MCPServerUserToken, error) { tok, err := db.Store.GetMCPServerUserToken(ctx, arg) if err != nil { diff --git a/enterprise/dbcrypt/dbcrypt_internal_test.go b/enterprise/dbcrypt/dbcrypt_internal_test.go index d37fbbacf88b8..9a249d5beee3f 100644 --- a/enterprise/dbcrypt/dbcrypt_internal_test.go +++ b/enterprise/dbcrypt/dbcrypt_internal_test.go @@ -961,15 +961,26 @@ func TestMCPServerConfigs(t *testing.T) { requireMCPServerConfigRawEncrypted(ctx, t, db, cfg.ID, ciphers, oauthSecret, apiKeyValue, customHeaders) }) - t.Run("GetMCPServerConfigBySlug", func(t *testing.T) { + t.Run("GetMCPServerConfigByOrganizationAndSlug", func(t *testing.T) { t.Parallel() db, crypt, ciphers := setup(t) cfg := insertConfig(t, crypt, ciphers) - got, err := crypt.GetMCPServerConfigBySlug(ctx, cfg.Slug) + got, err := crypt.GetMCPServerConfigByOrganizationAndSlug(ctx, database.GetMCPServerConfigByOrganizationAndSlugParams{ + OrganizationID: cfg.OrganizationID, + Slug: cfg.Slug, + }) require.NoError(t, err) requireMCPServerConfigDecrypted(t, got, ciphers, oauthSecret, apiKeyValue, customHeaders) requireMCPServerConfigRawEncrypted(ctx, t, db, cfg.ID, ciphers, oauthSecret, apiKeyValue, customHeaders) + + // The slug is only unique per organization: the same slug in + // another organization must not resolve. + _, err = crypt.GetMCPServerConfigByOrganizationAndSlug(ctx, database.GetMCPServerConfigByOrganizationAndSlugParams{ + OrganizationID: uuid.New(), + Slug: cfg.Slug, + }) + require.ErrorIs(t, err, sql.ErrNoRows) }) t.Run("GetMCPServerConfigs", func(t *testing.T) { From 42bc2477a7d0ff4c300f460f25581863b797cb63 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 29 Jul 2026 17:13:48 +0000 Subject: [PATCH 02/59] feat: add mcp server config RBAC resource Introduce a new mcp_server_config RBAC resource (create, read, update, delete; share lands in B4) as the second stage of CODAGT-711, giving the deployment-wide MCP server configs an org-scoped authorization object ahead of the org-scoped routes. - Resource declared in policy.go with generated object, scope constants, codersdk, and TypeScript artifacts; api_key_scope enum migration 000565 (internal-only scopes, no-op down per precedent). - Roles: owner and orgAdmin implicit; site auditor explicit read (parity with deployment config); orgAuditor explicit org-scoped read (a new deliberate capability). Temporary orgMember and orgServiceAccount read grants keep chat attachment working until B4 swaps in the everyone-ACL. - modelmethods RBACObject (org-scoped, no ACLs yet), a NoACL regosql converter, an authorized-list query via -- @authorize_filter, and GetAuthorizedMCPServerConfigs. The authorized list is B2's deliverable for B3: it is wired at the dbauthz/query layer now and consumed by the HTTP management routes only at the org-scoping cutover. - dbauthz read side swaps to the new object where the change is authorization-inert during the window: the by-slug fetch, the id-list queries (per-row post-filter), and the enabled/forced queries check the resource; GetMCPServerUserToken authorizes against its config's RBAC object via an internal fetch (through dbcrypt in Enterprise). - The interim HTTP management list keeps the parent's contract: its gate is deployment_config read, so it reads through a deployment-gated unfiltered GetMCPServerConfigManagementList until B3 swaps gate and query to the authorized list together (one method, one contract). - Write side stays on deployment_config (insert, update, delete, and the concealed-404 config fetch in the update/delete flows) with cutover TODOs, so deployment-config-only custom roles keep their M1 write set during the fallback window; the object checks swap in at B3. - subjectChatd gains a site-level read so chatd's internal token reads keep working; the org-scoped read grants are HTTP-ineffective until the B3 org-scoped routes exist. --- coderd/apidoc/docs.go | 12 + coderd/apidoc/swagger.json | 12 + coderd/database/dbauthz/dbauthz.go | 75 +++-- coderd/database/dbauthz/dbauthz_test.go | 34 +- coderd/database/dbmetrics/querymetrics.go | 16 + coderd/database/dbmock/dbmock.go | 30 ++ coderd/database/dump.sql | 7 +- .../000565_mcp_server_config_scopes.down.sql | 2 + .../000565_mcp_server_config_scopes.up.sql | 5 + coderd/database/modelmethods.go | 6 + coderd/database/modelqueries.go | 83 +++++ coderd/database/models.go | 17 +- coderd/database/queries.sql.go | 4 + coderd/database/queries/mcpserverconfigs.sql | 4 + coderd/mcp.go | 2 +- coderd/mcp_b2_test.go | 296 ++++++++++++++++++ coderd/rbac/object_gen.go | 11 + coderd/rbac/policy/policy.go | 11 + coderd/rbac/regosql/configs.go | 18 ++ coderd/rbac/roles.go | 15 + coderd/rbac/roles_test.go | 21 ++ coderd/rbac/scopes_constants_gen.go | 12 + codersdk/apikey_scopes_gen.go | 5 + codersdk/rbacresources_gen.go | 2 + docs/reference/api/members.md | 40 +-- docs/reference/api/schemas.md | 12 +- docs/reference/api/users.md | 10 +- site/src/api/rbacresourcesGenerated.ts | 6 + site/src/api/typesGenerated.ts | 12 + 29 files changed, 718 insertions(+), 62 deletions(-) create mode 100644 coderd/database/migrations/000565_mcp_server_config_scopes.down.sql create mode 100644 coderd/database/migrations/000565_mcp_server_config_scopes.up.sql create mode 100644 coderd/mcp_b2_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ad0411fc667a7..e9a3a9b02c5e7 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16509,6 +16509,11 @@ const docTemplate = `{ "license:create", "license:delete", "license:read", + "mcp_server_config:*", + "mcp_server_config:create", + "mcp_server_config:delete", + "mcp_server_config:read", + "mcp_server_config:update", "notification_message:*", "notification_message:create", "notification_message:delete", @@ -16749,6 +16754,11 @@ const docTemplate = `{ "APIKeyScopeLicenseCreate", "APIKeyScopeLicenseDelete", "APIKeyScopeLicenseRead", + "APIKeyScopeMcpServerConfigAll", + "APIKeyScopeMcpServerConfigCreate", + "APIKeyScopeMcpServerConfigDelete", + "APIKeyScopeMcpServerConfigRead", + "APIKeyScopeMcpServerConfigUpdate", "APIKeyScopeNotificationMessageAll", "APIKeyScopeNotificationMessageCreate", "APIKeyScopeNotificationMessageDelete", @@ -24036,6 +24046,7 @@ const docTemplate = `{ "idpsync_settings", "inbox_notification", "license", + "mcp_server_config", "notification_message", "notification_preference", "notification_template", @@ -24089,6 +24100,7 @@ const docTemplate = `{ "ResourceIdpsyncSettings", "ResourceInboxNotification", "ResourceLicense", + "ResourceMCPServerConfig", "ResourceNotificationMessage", "ResourceNotificationPreference", "ResourceNotificationTemplate", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e7b75e54fa3ba..bef2266b25d0b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14774,6 +14774,11 @@ "license:create", "license:delete", "license:read", + "mcp_server_config:*", + "mcp_server_config:create", + "mcp_server_config:delete", + "mcp_server_config:read", + "mcp_server_config:update", "notification_message:*", "notification_message:create", "notification_message:delete", @@ -15014,6 +15019,11 @@ "APIKeyScopeLicenseCreate", "APIKeyScopeLicenseDelete", "APIKeyScopeLicenseRead", + "APIKeyScopeMcpServerConfigAll", + "APIKeyScopeMcpServerConfigCreate", + "APIKeyScopeMcpServerConfigDelete", + "APIKeyScopeMcpServerConfigRead", + "APIKeyScopeMcpServerConfigUpdate", "APIKeyScopeNotificationMessageAll", "APIKeyScopeNotificationMessageCreate", "APIKeyScopeNotificationMessageDelete", @@ -22028,6 +22038,7 @@ "idpsync_settings", "inbox_notification", "license", + "mcp_server_config", "notification_message", "notification_preference", "notification_template", @@ -22081,6 +22092,7 @@ "ResourceIdpsyncSettings", "ResourceInboxNotification", "ResourceLicense", + "ResourceMCPServerConfig", "ResourceNotificationMessage", "ResourceNotificationPreference", "ResourceNotificationTemplate", diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index afba8e08a3ae5..7053f611ca1d5 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -794,6 +794,7 @@ var ( rbac.ResourceChat.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, rbac.ResourceWorkspace.Type: {policy.ActionRead, policy.ActionUpdate}, rbac.ResourceDeploymentConfig.Type: {policy.ActionRead}, + rbac.ResourceMCPServerConfig.Type: {policy.ActionRead}, rbac.ResourceUser.Type: {policy.ActionReadPersonal}, // TODO(mafredri): remove after CODAGT-711 B3 // (org-scoping cutover). The chat-org-then-default-org @@ -2288,6 +2289,10 @@ func (q *querier) DeleteLicense(ctx context.Context, id int32) (int32, error) { } func (q *querier) DeleteMCPServerConfigByID(ctx context.Context, id uuid.UUID) error { + // TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). + // The old delete handler gates on deployment_config update while the + // B1 fallback window lets default-org configs serve every org; the + // object-scoped delete check swaps in at the cutover. if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return err } @@ -3769,7 +3774,7 @@ func (q *querier) GetEnabledChatModelConfigs(ctx context.Context) ([]database.Ge } func (q *querier) GetEnabledMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceMCPServerConfig); err != nil { return nil, err } return q.db.GetEnabledMCPServerConfigs(ctx) @@ -3848,14 +3853,14 @@ func (q *querier) GetFilteredInboxNotificationsByUserID(ctx context.Context, arg } func (q *querier) GetForcedMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceMCPServerConfig); err != nil { return nil, err } return q.db.GetForcedMCPServerConfigs(ctx) } func (q *querier) GetForcedMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceMCPServerConfig); err != nil { return nil, err } return q.db.GetForcedMCPServerConfigsByOrganization(ctx, organizationID) @@ -4060,6 +4065,12 @@ func (q *querier) GetLogoURL(ctx context.Context) (string, error) { } func (q *querier) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (database.MCPServerConfig, error) { + // TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). + // The update/delete handlers fetch the config under the caller's + // subject behind a deployment_config update gate; an object-scoped + // read here would contract that write set during the B1 fallback + // window (a concealed 404). The object-scoped read swaps in at the + // cutover. if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { return database.MCPServerConfig{}, err } @@ -4067,35 +4078,36 @@ func (q *querier) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (dat } func (q *querier) GetMCPServerConfigByOrganizationAndSlug(ctx context.Context, arg database.GetMCPServerConfigByOrganizationAndSlugParams) (database.MCPServerConfig, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return database.MCPServerConfig{}, err - } - return q.db.GetMCPServerConfigByOrganizationAndSlug(ctx, arg) + return fetch(q.log, q.auth, q.db.GetMCPServerConfigByOrganizationAndSlug)(ctx, arg) } func (q *querier) GetMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return nil, err + prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceMCPServerConfig.Type) + if err != nil { + return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err) } - return q.db.GetMCPServerConfigs(ctx) + return q.db.GetAuthorizedMCPServerConfigs(ctx, prep) } func (q *querier) GetMCPServerConfigsByIDs(ctx context.Context, ids []uuid.UUID) ([]database.MCPServerConfig, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return nil, err - } - return q.db.GetMCPServerConfigsByIDs(ctx, ids) + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetMCPServerConfigsByIDs)(ctx, ids) } func (q *querier) GetMCPServerConfigsByIDsAndOrganizations(ctx context.Context, arg database.GetMCPServerConfigsByIDsAndOrganizationsParams) ([]database.MCPServerConfig, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return nil, err - } - return q.db.GetMCPServerConfigsByIDsAndOrganizations(ctx, arg) + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetMCPServerConfigsByIDsAndOrganizations)(ctx, arg) } func (q *querier) GetMCPServerUserToken(ctx context.Context, arg database.GetMCPServerUserTokenParams) (database.MCPServerUserToken, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + // Authorize against the token's config: a user token is usable only + // where its MCP server config is usable. The config is fetched via + // q.db (never another dbauthz method, which would recurse), so in + // Enterprise builds this read goes through dbcrypt and a config + // decryption failure now surfaces in token reads. + cfg, err := q.db.GetMCPServerConfigByID(ctx, arg.MCPServerConfigID) + if err != nil { + return database.MCPServerUserToken{}, err + } + if err := q.authorizeContext(ctx, policy.ActionRead, cfg.RBACObject()); err != nil { return database.MCPServerUserToken{}, err } return q.db.GetMCPServerUserToken(ctx, arg) @@ -6213,6 +6225,10 @@ func (q *querier) InsertLicense(ctx context.Context, arg database.InsertLicenseP } func (q *querier) InsertMCPServerConfig(ctx context.Context, arg database.InsertMCPServerConfigParams) (database.MCPServerConfig, error) { + // TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). + // The old create handler gates on deployment_config update while the + // B1 fallback window lets default-org configs serve every org; the + // object-scoped create check swaps in at the cutover. if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return database.MCPServerConfig{}, err } @@ -7680,6 +7696,10 @@ func (q *querier) UpdateInboxNotificationReadStatus(ctx context.Context, args da } func (q *querier) UpdateMCPServerConfig(ctx context.Context, arg database.UpdateMCPServerConfigParams) (database.MCPServerConfig, error) { + // TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). + // The old update handler gates on deployment_config update while the + // B1 fallback window lets default-org configs serve every org; the + // object-scoped update check swaps in at the cutover. if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return database.MCPServerConfig{}, err } @@ -9414,3 +9434,20 @@ func (q *querier) GetAuthorizedChats(ctx context.Context, arg database.GetChatsP func (q *querier) GetAuthorizedChatsByChatFileID(ctx context.Context, fileID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.Chat, error) { return q.db.GetAuthorizedChatsByChatFileID(ctx, fileID, prepared) } + +func (q *querier) GetAuthorizedMCPServerConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) { + // GetMCPServerConfigs prepares the filter; a caller that already holds + // the prepared value must not discard it (row filtering depends on it). + return q.db.GetAuthorizedMCPServerConfigs(ctx, prepared) +} + +func (q *querier) GetMCPServerConfigManagementList(ctx context.Context) ([]database.MCPServerConfig, error) { + // TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). + // The interim management list gates on deployment_config read; the + // query behind that gate must carry the gate's contract until the + // cutover swaps both to the authorized list. + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return nil, err + } + return q.db.GetMCPServerConfigManagementList(ctx) +} diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 04139647c80f2..57a14d065261f 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1649,20 +1649,20 @@ func (s *MethodTestSuite) TestChats() { configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) dbm.EXPECT().GetEnabledMCPServerConfigs(gomock.Any()).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() - check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.MCPServerConfig{configA, configB}) + check.Args().Asserts(rbac.ResourceMCPServerConfig, policy.ActionRead).Returns([]database.MCPServerConfig{configA, configB}) })) s.Run("GetForcedMCPServerConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) dbm.EXPECT().GetForcedMCPServerConfigs(gomock.Any()).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() - check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.MCPServerConfig{configA, configB}) + check.Args().Asserts(rbac.ResourceMCPServerConfig, policy.ActionRead).Returns([]database.MCPServerConfig{configA, configB}) })) s.Run("GetForcedMCPServerConfigsByOrganization", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { orgID := uuid.New() configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: orgID, Availability: "force_on"}) configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: orgID, Availability: "force_on"}) dbm.EXPECT().GetForcedMCPServerConfigsByOrganization(gomock.Any(), orgID).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() - check.Args(orgID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.MCPServerConfig{configA, configB}) + check.Args(orgID).Asserts(rbac.ResourceMCPServerConfig, policy.ActionRead).Returns([]database.MCPServerConfig{configA, configB}) })) s.Run("GetMCPServerConfigByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { config := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) @@ -1676,14 +1676,28 @@ func (s *MethodTestSuite) TestChats() { } config := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: arg.OrganizationID, Slug: arg.Slug}) dbm.EXPECT().GetMCPServerConfigByOrganizationAndSlug(gomock.Any(), arg).Return(config, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(config) + check.Args(arg).Asserts(config, policy.ActionRead).Returns(config) })) s.Run("GetMCPServerConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) - dbm.EXPECT().GetMCPServerConfigs(gomock.Any()).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() + dbm.EXPECT().GetAuthorizedMCPServerConfigs(gomock.Any(), gomock.Any()).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() + // No asserts here because SQLFilter. + check.Args().Asserts().Returns([]database.MCPServerConfig{configA, configB}) + })) + s.Run("GetMCPServerConfigManagementList", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) + configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) + dbm.EXPECT().GetMCPServerConfigManagementList(gomock.Any()).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.MCPServerConfig{configA, configB}) })) + s.Run("GetAuthorizedMCPServerConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) + configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) + dbm.EXPECT().GetAuthorizedMCPServerConfigs(gomock.Any(), gomock.Any()).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() + // No asserts here because SQLFilter. + check.Args(emptyPreparedAuthorized{}).Asserts().Returns([]database.MCPServerConfig{configA, configB}) + })) s.Run("GetMCPServerConfigsByIDsAndOrganizations", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { arg := database.GetMCPServerConfigsByIDsAndOrganizationsParams{ IDs: []uuid.UUID{uuid.New(), uuid.New()}, @@ -1692,23 +1706,25 @@ func (s *MethodTestSuite) TestChats() { configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{ID: arg.IDs[0], OrganizationID: arg.OrganizationIds[0]}) configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{ID: arg.IDs[1], OrganizationID: arg.OrganizationIds[1]}) dbm.EXPECT().GetMCPServerConfigsByIDsAndOrganizations(gomock.Any(), arg).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.MCPServerConfig{configA, configB}) + check.Args(arg).Asserts(configA, policy.ActionRead, configB, policy.ActionRead).OutOfOrder().Returns([]database.MCPServerConfig{configA, configB}) })) s.Run("GetMCPServerConfigsByIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) ids := []uuid.UUID{configA.ID, configB.ID} dbm.EXPECT().GetMCPServerConfigsByIDs(gomock.Any(), ids).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() - check.Args(ids).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.MCPServerConfig{configA, configB}) + check.Args(ids).Asserts(configA, policy.ActionRead, configB, policy.ActionRead).OutOfOrder().Returns([]database.MCPServerConfig{configA, configB}) })) s.Run("GetMCPServerUserToken", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + config := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) arg := database.GetMCPServerUserTokenParams{ - MCPServerConfigID: uuid.New(), + MCPServerConfigID: config.ID, UserID: uuid.New(), } token := testutil.Fake(s.T(), faker, database.MCPServerUserToken{MCPServerConfigID: arg.MCPServerConfigID, UserID: arg.UserID}) + dbm.EXPECT().GetMCPServerConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes() dbm.EXPECT().GetMCPServerUserToken(gomock.Any(), arg).Return(token, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(token) + check.Args(arg).Asserts(config, policy.ActionRead).Returns(token) })) s.Run("GetMCPServerUserTokensByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { userID := uuid.New() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index cd76b51c81e06..211997b4df45e 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -6808,3 +6808,19 @@ func (m queryMetricsStore) GetAuthorizedChatsByChatFileID(ctx context.Context, f m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAuthorizedChatsByChatFileID").Inc() return r0, r1 } + +func (m queryMetricsStore) GetAuthorizedMCPServerConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) { + start := time.Now() + r0, r1 := m.s.GetAuthorizedMCPServerConfigs(ctx, prepared) + m.queryLatencies.WithLabelValues("GetAuthorizedMCPServerConfigs").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAuthorizedMCPServerConfigs").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetMCPServerConfigManagementList(ctx context.Context) ([]database.MCPServerConfig, error) { + start := time.Now() + r0, r1 := m.s.GetMCPServerConfigManagementList(ctx) + m.queryLatencies.WithLabelValues("GetMCPServerConfigManagementList").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetMCPServerConfigManagementList").Inc() + return r0, r1 +} diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 6e137297252f5..9f5c7dd1e709a 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2505,6 +2505,21 @@ func (mr *MockStoreMockRecorder) GetAuthorizedConnectionLogsOffset(ctx, arg, pre return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedConnectionLogsOffset", reflect.TypeOf((*MockStore)(nil).GetAuthorizedConnectionLogsOffset), ctx, arg, prepared) } +// GetAuthorizedMCPServerConfigs mocks base method. +func (m *MockStore) GetAuthorizedMCPServerConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAuthorizedMCPServerConfigs", ctx, prepared) + ret0, _ := ret[0].([]database.MCPServerConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAuthorizedMCPServerConfigs indicates an expected call of GetAuthorizedMCPServerConfigs. +func (mr *MockStoreMockRecorder) GetAuthorizedMCPServerConfigs(ctx, prepared any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedMCPServerConfigs", reflect.TypeOf((*MockStore)(nil).GetAuthorizedMCPServerConfigs), ctx, prepared) +} + // GetAuthorizedTemplates mocks base method. func (m *MockStore) GetAuthorizedTemplates(ctx context.Context, arg database.GetTemplatesWithFilterParams, prepared rbac.PreparedAuthorized) ([]database.Template, error) { m.ctrl.T.Helper() @@ -4350,6 +4365,21 @@ func (mr *MockStoreMockRecorder) GetMCPServerConfigByOrganizationAndSlug(ctx, ar return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigByOrganizationAndSlug", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigByOrganizationAndSlug), ctx, arg) } +// GetMCPServerConfigManagementList mocks base method. +func (m *MockStore) GetMCPServerConfigManagementList(ctx context.Context) ([]database.MCPServerConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMCPServerConfigManagementList", ctx) + ret0, _ := ret[0].([]database.MCPServerConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetMCPServerConfigManagementList indicates an expected call of GetMCPServerConfigManagementList. +func (mr *MockStoreMockRecorder) GetMCPServerConfigManagementList(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigManagementList", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigManagementList), ctx) +} + // GetMCPServerConfigs mocks base method. func (m *MockStore) GetMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 7ed8084694a0c..63487a1a9a083 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -273,7 +273,12 @@ CREATE TYPE api_key_scope AS ENUM ( 'workspace_build_orchestration:create', 'workspace_build_orchestration:delete', 'workspace_build_orchestration:read', - 'workspace_build_orchestration:update' + 'workspace_build_orchestration:update', + 'mcp_server_config:*', + 'mcp_server_config:create', + 'mcp_server_config:read', + 'mcp_server_config:update', + 'mcp_server_config:delete' ); CREATE TYPE app_sharing_level AS ENUM ( diff --git a/coderd/database/migrations/000565_mcp_server_config_scopes.down.sql b/coderd/database/migrations/000565_mcp_server_config_scopes.down.sql new file mode 100644 index 0000000000000..04f101ceb4e84 --- /dev/null +++ b/coderd/database/migrations/000565_mcp_server_config_scopes.down.sql @@ -0,0 +1,2 @@ +-- Enum additions to api_key_scope are intentionally not reverted because +-- Postgres cannot drop enum values safely. diff --git a/coderd/database/migrations/000565_mcp_server_config_scopes.up.sql b/coderd/database/migrations/000565_mcp_server_config_scopes.up.sql new file mode 100644 index 0000000000000..8e08c8d78d168 --- /dev/null +++ b/coderd/database/migrations/000565_mcp_server_config_scopes.up.sql @@ -0,0 +1,5 @@ +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'mcp_server_config:*'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'mcp_server_config:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'mcp_server_config:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'mcp_server_config:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'mcp_server_config:delete'; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 927c352ebf4b2..dfd13f5c3ec17 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -227,6 +227,12 @@ func (c Chat) RBACObject() rbac.Object { WithGroupACL(c.GroupACL.RBACACL()) } +func (m MCPServerConfig) RBACObject() rbac.Object { + return rbac.ResourceMCPServerConfig. + WithID(m.ID). + InOrg(m.OrganizationID) +} + func (c Chat) IsSubChat() bool { return c.RootChatID.Valid || c.ParentChatID.Valid } diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 70940611ccee4..92bc43907af41 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -53,6 +53,7 @@ type customQuerier interface { connectionLogQuerier aibridgeQuerier chatQuerier + mcpServerConfigQuerier } type templateQuerier interface { @@ -1199,3 +1200,85 @@ func (q *sqlQuerier) UpdateUserLinkRawJSON(ctx context.Context, userID uuid.UUID _, err := q.sdb.ExecContext(ctx, "UPDATE user_links SET claims = $2 WHERE user_id = $1", userID, data) return err } + +type mcpServerConfigQuerier interface { + GetAuthorizedMCPServerConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]MCPServerConfig, error) + GetMCPServerConfigManagementList(ctx context.Context) ([]MCPServerConfig, error) +} + +func (q *sqlQuerier) GetAuthorizedMCPServerConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]MCPServerConfig, error) { + authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{ + VariableConverter: regosql.MCPServerConfigNoACLConverter(), + }) + if err != nil { + return nil, xerrors.Errorf("compile authorized filter: %w", err) + } + + filtered, err := insertAuthorizedFilter(getMCPServerConfigs, fmt.Sprintf(" AND %s", authorizedFilter)) + if err != nil { + return nil, xerrors.Errorf("insert authorized filter: %w", err) + } + + // The name comment is for metric tracking + query := fmt.Sprintf("-- name: GetAuthorizedMCPServerConfigs :many\n%s", filtered) + rows, err := q.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + var items []MCPServerConfig + for rows.Next() { + var i MCPServerConfig + if err := rows.Scan( + &i.ID, + &i.DisplayName, + &i.Slug, + &i.Description, + &i.IconURL, + &i.Transport, + &i.Url, + &i.AuthType, + &i.OAuth2ClientID, + &i.OAuth2ClientSecret, + &i.OAuth2ClientSecretKeyID, + &i.OAuth2AuthURL, + &i.OAuth2TokenURL, + &i.OAuth2Scopes, + &i.APIKeyHeader, + &i.APIKeyValue, + &i.APIKeyValueKeyID, + &i.CustomHeaders, + &i.CustomHeadersKeyID, + pq.Array(&i.ToolAllowList), + pq.Array(&i.ToolDenyList), + &i.Availability, + &i.Enabled, + &i.CreatedBy, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.ModelIntent, + &i.AllowInPlanMode, + &i.ForwardCoderHeaders, + &i.OAuth2RevocationURL, + &i.OrganizationID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +// GetMCPServerConfigManagementList returns every MCP server config +// unfiltered. It backs the interim HTTP management list, whose gate is +// deployment_config read; the query behind that gate must carry the gate's +// contract until the B3 cutover swaps both to the authorized list. +// +// TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). +func (q *sqlQuerier) GetMCPServerConfigManagementList(ctx context.Context) ([]MCPServerConfig, error) { + return q.GetMCPServerConfigs(ctx) +} diff --git a/coderd/database/models.go b/coderd/database/models.go index c77c730781ede..e49b0ad042fa5 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -465,6 +465,11 @@ const ( ApiKeyScopeWorkspaceBuildOrchestrationDelete APIKeyScope = "workspace_build_orchestration:delete" ApiKeyScopeWorkspaceBuildOrchestrationRead APIKeyScope = "workspace_build_orchestration:read" ApiKeyScopeWorkspaceBuildOrchestrationUpdate APIKeyScope = "workspace_build_orchestration:update" + ApiKeyScopeMcpServerConfig APIKeyScope = "mcp_server_config:*" + ApiKeyScopeMcpServerConfigCreate APIKeyScope = "mcp_server_config:create" + ApiKeyScopeMcpServerConfigRead APIKeyScope = "mcp_server_config:read" + ApiKeyScopeMcpServerConfigUpdate APIKeyScope = "mcp_server_config:update" + ApiKeyScopeMcpServerConfigDelete APIKeyScope = "mcp_server_config:delete" ) func (e *APIKeyScope) Scan(src interface{}) error { @@ -739,7 +744,12 @@ func (e APIKeyScope) Valid() bool { ApiKeyScopeWorkspaceBuildOrchestrationCreate, ApiKeyScopeWorkspaceBuildOrchestrationDelete, ApiKeyScopeWorkspaceBuildOrchestrationRead, - ApiKeyScopeWorkspaceBuildOrchestrationUpdate: + ApiKeyScopeWorkspaceBuildOrchestrationUpdate, + ApiKeyScopeMcpServerConfig, + ApiKeyScopeMcpServerConfigCreate, + ApiKeyScopeMcpServerConfigRead, + ApiKeyScopeMcpServerConfigUpdate, + ApiKeyScopeMcpServerConfigDelete: return true } return false @@ -983,6 +993,11 @@ func AllAPIKeyScopeValues() []APIKeyScope { ApiKeyScopeWorkspaceBuildOrchestrationDelete, ApiKeyScopeWorkspaceBuildOrchestrationRead, ApiKeyScopeWorkspaceBuildOrchestrationUpdate, + ApiKeyScopeMcpServerConfig, + ApiKeyScopeMcpServerConfigCreate, + ApiKeyScopeMcpServerConfigRead, + ApiKeyScopeMcpServerConfigUpdate, + ApiKeyScopeMcpServerConfigDelete, } } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 43be80abaf58b..421175f9aaa83 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -17446,6 +17446,10 @@ SELECT id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id FROM mcp_server_configs +WHERE + true + -- Authorize Filter clause will be injected below in GetAuthorizedMCPServerConfigs + -- @authorize_filter ORDER BY display_name ASC ` diff --git a/coderd/database/queries/mcpserverconfigs.sql b/coderd/database/queries/mcpserverconfigs.sql index 9a48df0a51769..48173f9a9e550 100644 --- a/coderd/database/queries/mcpserverconfigs.sql +++ b/coderd/database/queries/mcpserverconfigs.sql @@ -20,6 +20,10 @@ SELECT * FROM mcp_server_configs +WHERE + true + -- Authorize Filter clause will be injected below in GetAuthorizedMCPServerConfigs + -- @authorize_filter ORDER BY display_name ASC; diff --git a/coderd/mcp.go b/coderd/mcp.go index da039a2e4fd7c..d4c0f362245ae 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -160,7 +160,7 @@ func (api *API) listMCPServerConfigs(rw http.ResponseWriter, r *http.Request) { var configs []database.MCPServerConfig var err error if isAdmin { - configs, err = api.Database.GetMCPServerConfigs(ctx) + configs, err = api.Database.GetMCPServerConfigManagementList(ctx) } else { //nolint:gocritic // All authenticated users need to read enabled MCP server configs to use the chat feature. configs, err = api.Database.GetEnabledMCPServerConfigs(dbauthz.AsSystemRestricted(ctx)) diff --git a/coderd/mcp_b2_test.go b/coderd/mcp_b2_test.go new file mode 100644 index 0000000000000..08314822fd4d2 --- /dev/null +++ b/coderd/mcp_b2_test.go @@ -0,0 +1,296 @@ +package coderd_test + +import ( + "context" + "database/sql" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/rbac/rolestore" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +// TestMCPServerConfigListReadContracts pins the read contracts of the +// authorized MCP server config list while the B1 fallback window is open. +// Exercised through the dbauthz boundary: the org-scoped read grants are +// HTTP-ineffective until the B3 org-scoped routes exist, so the contracts +// live at the database authorization layer. +// +// Org admin and org auditor read only their own org's configs (including +// disabled ones) through the authorized list. A custom site role holding +// only mcp_server_config:read reads across orgs (site scope), and a plain +// org member reads only their own org via the temporary member grant. +// +//nolint:tparallel,paralleltest // Subtests share one seeded database. +func TestMCPServerConfigListReadContracts(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + rawStore, _, rawSQLDB := dbtestutil.NewDBWithSQLDB(t) + adminClient := coderdtest.New(t, &coderdtest.Options{Database: rawStore}) + db := rawStore + firstUser := coderdtest.CreateFirstUser(t, adminClient) + defaultOrgID := firstUser.OrganizationID + + // A second organization with a config that org-scoped readers of the + // default org must not see. + secondOrg := dbgen.Organization(t, db, database.Organization{}) + + // Seed one enabled and one disabled config in the default org (the + // create handler seeds the default org during the window), and one + // enabled config in the second org directly in the DB. + enabledDefault := createMCPServerConfig(t, adminClient, "enabled-default", true) + disabledDefault := createMCPServerConfig(t, adminClient, "disabled-default", false) + otherOrgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: secondOrg.ID, + Enabled: true, + }) + + authzDB := func() database.Store { + return dbauthz.New(db, rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()), slogtest.Make(t, nil), coderdtest.AccessControlStorePointer()) + } + + // authorizedSlugs returns the slugs the given user sees through the + // authorized list under their full (db-backed) subject. + authorizedSlugs := func(user codersdk.User) []string { + subject := coderdtest.AuthzUserSubjectWithDB(ctx, t, db, user) + cfgs, err := authzDB().GetMCPServerConfigs(dbauthz.As(ctx, subject)) + require.NoError(t, err) + out := make([]string, 0, len(cfgs)) + for _, c := range cfgs { + out = append(out, c.Slug) + } + return out + } + + t.Run("OrgAdminSeesOwnOrgOnly", func(t *testing.T) { + _, orgAdmin := coderdtest.CreateAnotherUser(t, adminClient, defaultOrgID, rbac.ScopedRoleOrgAdmin(defaultOrgID)) + got := authorizedSlugs(orgAdmin) + require.Contains(t, got, enabledDefault.Slug) + require.Contains(t, got, disabledDefault.Slug) + require.NotContains(t, got, otherOrgConfig.Slug) + }) + + t.Run("OrgAuditorSeesOwnOrgOnly", func(t *testing.T) { + _, orgAuditor := coderdtest.CreateAnotherUser(t, adminClient, defaultOrgID, rbac.ScopedRoleOrgAuditor(defaultOrgID)) + got := authorizedSlugs(orgAuditor) + require.Contains(t, got, enabledDefault.Slug) + require.Contains(t, got, disabledDefault.Slug) + require.NotContains(t, got, otherOrgConfig.Slug) + }) + + t.Run("OrgMemberSeesOwnOrgOnly", func(t *testing.T) { + // A plain member of the default org, relying on the temporary + // orgMember read grant. + memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, defaultOrgID) + _ = memberClient + got := authorizedSlugs(member) + require.Contains(t, got, enabledDefault.Slug) + require.Contains(t, got, disabledDefault.Slug) + require.NotContains(t, got, otherOrgConfig.Slug) + }) + + t.Run("CustomSiteReadRoleSeesAcrossOrgs", func(t *testing.T) { + // A site custom role holding only mcp_server_config:read. Custom + // roles persisted through dbauthz cannot carry site permissions, + // so write it directly to the raw database. The user lives in the + // second org so no implicit organization read on the default org + // masks a broken authorization path. + customRole, err := database.New(rawSQLDB).InsertCustomRole(ctx, database.InsertCustomRoleParams{ + Name: "mcp-config-reader", + SitePermissions: []database.CustomRolePermission{ + {ResourceType: rbac.ResourceMCPServerConfig.Type, Action: policy.ActionRead}, + }, + OrgPermissions: []database.CustomRolePermission{}, + UserPermissions: []database.CustomRolePermission{}, + }) + require.NoError(t, err) + user := dbgen.User(t, db, database.User{RBACRoles: []string{customRole.Name}}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: secondOrg.ID, + }) + + subject := mcpCustomRoleSubject(ctx, t, db, user) + // The custom site role grants read across orgs, so this user sees + // both orgs' configs. + cfgs, err := authzDB().GetMCPServerConfigs(dbauthz.As(ctx, subject)) + require.NoError(t, err) + got := make([]string, 0, len(cfgs)) + for _, c := range cfgs { + got = append(got, c.Slug) + } + require.Contains(t, got, enabledDefault.Slug) + require.Contains(t, got, otherOrgConfig.Slug) + }) +} + +// mcpCustomRoleSubject builds the authorization subject for a dbgen user +// with custom RBAC roles, expanding roles and org membership from the DB. +func mcpCustomRoleSubject(ctx context.Context, t *testing.T, db database.Store, user database.User) rbac.Subject { + t.Helper() + + roles := rbac.RoleIdentifiers{rbac.RoleMember()} + for _, name := range user.RBACRoles { + roles = append(roles, rbac.RoleIdentifier{Name: name}) + } + orgs, err := db.GetOrganizationsByUserID(dbauthz.AsSystemRestricted(ctx), database.GetOrganizationsByUserIDParams{ + UserID: user.ID, + Deleted: sql.NullBool{Valid: true, Bool: false}, + }) + require.NoError(t, err) + for _, org := range orgs { + roles = append(roles, rbac.ScopedRoleOrgMember(org.ID)) + } + rbacRoles, err := rolestore.Expand(dbauthz.AsSystemRestricted(ctx), db, roles) + require.NoError(t, err) + + return rbac.Subject{ + ID: user.ID.String(), + Roles: rbacRoles, + Groups: []string{}, + Scope: rbac.ScopeAll, + }.WithCachedASTValue() +} + +// TestMCPServerConfigDeploymentConfigOnlyRoleWritesThroughWindow proves the +// interim write gate stays on deployment_config while the B1 fallback window +// is open: a site custom role holding only deployment_config read+update can +// create, update, and delete an MCP server config. Swapping the write-side +// dbauthz checks (or the concealed-404 fetch in the update/delete flows) to +// the new resource early would contract this write set, a behavior +// regression (invariant 1). +func TestMCPServerConfigDeploymentConfigOnlyRoleWritesThroughWindow(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + rawStore, _, rawSQLDB := dbtestutil.NewDBWithSQLDB(t) + client := coderdtest.New(t, &coderdtest.Options{Database: rawStore}) + _ = coderdtest.CreateFirstUser(t, client) + db := rawStore + + // The user belongs to a non-default org and holds only deployment_config + // read+update via a site custom role written directly to the raw + // database (dbauthz cannot persist site permissions on custom roles). + secondOrg := dbgen.Organization(t, db, database.Organization{}) + customRole, err := database.New(rawSQLDB).InsertCustomRole(ctx, database.InsertCustomRoleParams{ + Name: "deployment-config-manager", + SitePermissions: []database.CustomRolePermission{ + {ResourceType: rbac.ResourceDeploymentConfig.Type, Action: policy.ActionRead}, + {ResourceType: rbac.ResourceDeploymentConfig.Type, Action: policy.ActionUpdate}, + }, + OrgPermissions: []database.CustomRolePermission{}, + UserPermissions: []database.CustomRolePermission{}, + }) + require.NoError(t, err) + user := dbgen.User(t, db, database.User{RBACRoles: []string{customRole.Name}}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: secondOrg.ID, + }) + _, token := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID}) + userClient := codersdk.New(client.URL) + userClient.SetSessionToken(token) + + created, err := userClient.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "Window Server", + Slug: "window-server", + Transport: "streamable_http", + URL: "https://mcp.example.com/window", + AuthType: "none", + Availability: "default_on", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, + }) + require.NoError(t, err) + + newName := "Window Server Renamed" + _, err = userClient.UpdateMCPServerConfig(ctx, created.ID, codersdk.UpdateMCPServerConfigRequest{ + DisplayName: &newName, + }) + require.NoError(t, err) + + require.NoError(t, userClient.DeleteMCPServerConfig(ctx, created.ID)) +} + +// TestMCPServerConfigManagementListParentEquivalence proves the interim +// privileged management list keeps the parent's read contract: any principal +// the deployment_config read gate admits sees the full unfiltered row set +// (default-org enabled AND disabled, and any other org's rows), exactly as on +// the parent. The authorized-list swap must not narrow this path during the +// window. The subject holds ONLY deployment_config read via a site custom +// role and is a member of a non-default org, so the implicit default-org +// member read cannot mask a broken authorization path. +func TestMCPServerConfigManagementListParentEquivalence(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + rawStore, _, rawSQLDB := dbtestutil.NewDBWithSQLDB(t) + client := coderdtest.New(t, &coderdtest.Options{Database: rawStore}) + _ = coderdtest.CreateFirstUser(t, client) + db := rawStore + + // Seed default-org enabled + disabled configs (via the admin HTTP path) + // and a config in the subject's own (second) org. + enabledDefault := createMCPServerConfig(t, client, "enabled-default", true) + disabledDefault := createMCPServerConfig(t, client, "disabled-default", false) + secondOrg := dbgen.Organization(t, db, database.Organization{}) + ownOrgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: secondOrg.ID, + Enabled: true, + }) + + // A site custom role holding ONLY deployment_config read, member of the + // non-default second org. + customRole, err := database.New(rawSQLDB).InsertCustomRole(ctx, database.InsertCustomRoleParams{ + Name: "deployment-config-reader", + SitePermissions: []database.CustomRolePermission{ + {ResourceType: rbac.ResourceDeploymentConfig.Type, Action: policy.ActionRead}, + }, + OrgPermissions: []database.CustomRolePermission{}, + UserPermissions: []database.CustomRolePermission{}, + }) + require.NoError(t, err) + user := dbgen.User(t, db, database.User{RBACRoles: []string{customRole.Name}}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: secondOrg.ID, + }) + _, token := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID}) + userClient := codersdk.New(client.URL) + userClient.SetSessionToken(token) + + cfgs, err := userClient.MCPServerConfigs(ctx) + require.NoError(t, err) + got := make(map[string]bool, len(cfgs)) + for _, c := range cfgs { + got[c.Slug] = true + } + require.True(t, got[enabledDefault.Slug], "privileged list missing default-org enabled config") + require.True(t, got[disabledDefault.Slug], "privileged list missing default-org disabled config") + require.True(t, got[ownOrgConfig.Slug], "privileged list missing subject-org config") + + // The privileged view carries connection metadata, not just redaction. + var enabled codersdk.MCPServerConfig + found := false + for _, c := range cfgs { + if c.Slug == enabledDefault.Slug { + enabled = c + found = true + } + } + require.True(t, found) + require.Equal(t, "https://mcp.example.com/"+enabledDefault.Slug, enabled.URL) +} diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index d22fb8c60a7c1..c956750dab219 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -219,6 +219,16 @@ var ( Type: "license", } + // ResourceMCPServerConfig + // Valid Actions + // - "ActionCreate" :: create a new MCP server config + // - "ActionDelete" :: delete MCP server config + // - "ActionRead" :: read MCP server config + // - "ActionUpdate" :: update MCP server config + ResourceMCPServerConfig = Object{ + Type: "mcp_server_config", + } + // ResourceNotificationMessage // Valid Actions // - "ActionCreate" :: create notification messages @@ -522,6 +532,7 @@ func AllResources() []Objecter { ResourceIdpsyncSettings, ResourceInboxNotification, ResourceLicense, + ResourceMCPServerConfig, ResourceNotificationMessage, ResourceNotificationPreference, ResourceNotificationTemplate, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index fa66254dea9bf..590963105410c 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -85,6 +85,13 @@ var chatActions = map[Action]ActionDefinition{ ActionShare: "share a chat with other users or groups", } +var mcpServerConfigActions = map[Action]ActionDefinition{ + ActionCreate: "create a new MCP server config", + ActionRead: "read MCP server config", + ActionUpdate: "update MCP server config", + ActionDelete: "delete MCP server config", +} + // RBACPermissions is indexed by the type var RBACPermissions = map[string]PermissionDefinition{ // Wildcard is every object, and the action "*" provides all actions. @@ -453,4 +460,8 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionDelete: "delete boundary usage statistics", }, }, + "mcp_server_config": { + Name: "MCPServerConfig", + Actions: mcpServerConfigActions, + }, } diff --git a/coderd/rbac/regosql/configs.go b/coderd/rbac/regosql/configs.go index 36a056eff26ed..6aa0da0ef2c1a 100644 --- a/coderd/rbac/regosql/configs.go +++ b/coderd/rbac/regosql/configs.go @@ -74,6 +74,24 @@ func ChatNoACLConverter() *sqltypes.VariableConverter { return matcher } +// MCPServerConfigNoACLConverter converts MCP server config permissions to +// SQL. The table carries no ACL columns yet (sharing lands with them), so +// ACL matchers are always false and only org scoping filters rows. +func MCPServerConfigNoACLConverter() *sqltypes.VariableConverter { + matcher := sqltypes.NewVariableConverter().RegisterMatcher( + resourceIDMatcher(), + organizationOwnerMatcher(), + // MCP server configs have no user owner, only owner by an + // organization. + sqltypes.AlwaysFalse(userOwnerMatcher()), + ) + matcher.RegisterMatcher( + sqltypes.AlwaysFalse(groupACLMatcher(matcher)), + sqltypes.AlwaysFalse(userACLMatcher(matcher)), + ) + return matcher +} + func chatBaseConverter() *sqltypes.VariableConverter { return sqltypes.NewVariableConverter().RegisterMatcher( chatResourceIDMatcher(), diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index fdb462b7eb615..a0f6b347b71d0 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -476,6 +476,9 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // Allow auditors to query deployment stats and insights. ResourceDeploymentStats.Type: {policy.ActionRead}, ResourceDeploymentConfig.Type: {policy.ActionRead}, + // Allow auditors to read MCP server configs (redacted through + // the HTTP convert layer), matching their deployment config read. + ResourceMCPServerConfig.Type: {policy.ActionRead}, // Allow auditors to query AI Bridge interceptions. ResourceAibridgeInterception.Type: {policy.ActionRead}, // Allow auditors to read boundary logs. @@ -611,6 +614,9 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ResourceGroupMember.Type: {policy.ActionRead}, ResourceOrganization.Type: {policy.ActionRead}, ResourceOrganizationMember.Type: {policy.ActionRead}, + // Organization auditors can read their organization's MCP + // server configs (redacted through the HTTP convert layer). + ResourceMCPServerConfig.Type: {policy.ActionRead}, }), Member: []Permission{}, }, @@ -1157,6 +1163,10 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions { ResourceOrganization.Type: {policy.ActionRead}, // Can read available roles. ResourceAssignOrgRole.Type: {policy.ActionRead}, + // TODO(mafredri): remove after CODAGT-711 B4 (org-scoping cutover). + // Members read MCP server configs so chats can attach them; B4 + // replaces this grant with the everyone-ACL on each config. + ResourceMCPServerConfig.Type: {policy.ActionRead}, } // In all modes of workspace sharing but `none`, members need to @@ -1234,6 +1244,11 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions { ResourceOrganization.Type: {policy.ActionRead}, // Can read available roles. ResourceAssignOrgRole.Type: {policy.ActionRead}, + // TODO(mafredri): remove after CODAGT-711 B4 (org-scoping cutover). + // Service accounts read MCP server configs so chats can attach + // them; B4 replaces this grant with the everyone-ACL on each + // config. + ResourceMCPServerConfig.Type: {policy.ActionRead}, } // When workspace sharing is enabled, service accounts need to see diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index c5c484b01bcbf..4714c1fe10735 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -824,6 +824,27 @@ func TestRolePermissions(t *testing.T) { false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, + { + // MCP server config read: owner, both auditors, and (during + // the staged window until B4 swaps in the everyone-ACL) org + // admins, org members, and service accounts. + Name: "MCPServerConfigRead", + Actions: []policy.Action{policy.ActionRead}, + Resource: rbac.ResourceMCPServerConfig.WithID(uuid.New()).InOrg(orgID), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, orgAdmin, orgAuditor, auditor, orgMemberMe}, + false: {setOtherOrg, memberMe, agentsAccessUser, orgWorkspaceAccessUser, orgUserAdmin, orgTemplateAdmin, templateAdmin, userAdmin}, + }, + }, + { + Name: "MCPServerConfigWrite", + Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + Resource: rbac.ResourceMCPServerConfig.WithID(uuid.New()).InOrg(orgID), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, orgAdmin}, + false: {setOtherOrg, orgAuditor, auditor, orgMemberMe, memberMe, agentsAccessUser, orgWorkspaceAccessUser, orgUserAdmin, orgTemplateAdmin, templateAdmin, userAdmin}, + }, + }, { Name: "DebugInfo", Actions: []policy.Action{policy.ActionRead}, diff --git a/coderd/rbac/scopes_constants_gen.go b/coderd/rbac/scopes_constants_gen.go index 410e793367924..674aa56e7d488 100644 --- a/coderd/rbac/scopes_constants_gen.go +++ b/coderd/rbac/scopes_constants_gen.go @@ -73,6 +73,10 @@ const ( ScopeLicenseCreate ScopeName = "license:create" ScopeLicenseDelete ScopeName = "license:delete" ScopeLicenseRead ScopeName = "license:read" + ScopeMcpServerConfigCreate ScopeName = "mcp_server_config:create" + ScopeMcpServerConfigDelete ScopeName = "mcp_server_config:delete" + ScopeMcpServerConfigRead ScopeName = "mcp_server_config:read" + ScopeMcpServerConfigUpdate ScopeName = "mcp_server_config:update" ScopeNotificationMessageCreate ScopeName = "notification_message:create" ScopeNotificationMessageDelete ScopeName = "notification_message:delete" ScopeNotificationMessageRead ScopeName = "notification_message:read" @@ -261,6 +265,10 @@ func (e ScopeName) Valid() bool { ScopeLicenseCreate, ScopeLicenseDelete, ScopeLicenseRead, + ScopeMcpServerConfigCreate, + ScopeMcpServerConfigDelete, + ScopeMcpServerConfigRead, + ScopeMcpServerConfigUpdate, ScopeNotificationMessageCreate, ScopeNotificationMessageDelete, ScopeNotificationMessageRead, @@ -450,6 +458,10 @@ func AllScopeNameValues() []ScopeName { ScopeLicenseCreate, ScopeLicenseDelete, ScopeLicenseRead, + ScopeMcpServerConfigCreate, + ScopeMcpServerConfigDelete, + ScopeMcpServerConfigRead, + ScopeMcpServerConfigUpdate, ScopeNotificationMessageCreate, ScopeNotificationMessageDelete, ScopeNotificationMessageRead, diff --git a/codersdk/apikey_scopes_gen.go b/codersdk/apikey_scopes_gen.go index 350d29237e1f7..475b2bb76fbb1 100644 --- a/codersdk/apikey_scopes_gen.go +++ b/codersdk/apikey_scopes_gen.go @@ -104,6 +104,11 @@ const ( APIKeyScopeLicenseCreate APIKeyScope = "license:create" APIKeyScopeLicenseDelete APIKeyScope = "license:delete" APIKeyScopeLicenseRead APIKeyScope = "license:read" + APIKeyScopeMcpServerConfigAll APIKeyScope = "mcp_server_config:*" + APIKeyScopeMcpServerConfigCreate APIKeyScope = "mcp_server_config:create" + APIKeyScopeMcpServerConfigDelete APIKeyScope = "mcp_server_config:delete" + APIKeyScopeMcpServerConfigRead APIKeyScope = "mcp_server_config:read" + APIKeyScopeMcpServerConfigUpdate APIKeyScope = "mcp_server_config:update" APIKeyScopeNotificationMessageAll APIKeyScope = "notification_message:*" APIKeyScopeNotificationMessageCreate APIKeyScope = "notification_message:create" APIKeyScopeNotificationMessageDelete APIKeyScope = "notification_message:delete" diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 5b67b8d50b335..f4302fc155ad0 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -28,6 +28,7 @@ const ( ResourceIdpsyncSettings RBACResource = "idpsync_settings" ResourceInboxNotification RBACResource = "inbox_notification" ResourceLicense RBACResource = "license" + ResourceMCPServerConfig RBACResource = "mcp_server_config" ResourceNotificationMessage RBACResource = "notification_message" ResourceNotificationPreference RBACResource = "notification_preference" ResourceNotificationTemplate RBACResource = "notification_template" @@ -107,6 +108,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceIdpsyncSettings: {ActionRead, ActionUpdate}, ResourceInboxNotification: {ActionCreate, ActionRead, ActionUpdate}, ResourceLicense: {ActionCreate, ActionDelete, ActionRead}, + ResourceMCPServerConfig: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceNotificationMessage: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceNotificationPreference: {ActionRead, ActionUpdate}, ResourceNotificationTemplate: {ActionRead, ActionUpdate}, diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index 1ded61bd0ee75..ca5ebbbd51a6f 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -198,10 +198,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -331,10 +331,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -464,10 +464,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -559,10 +559,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -965,9 +965,9 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 6c5ad2348045e..a37cadd2dd13b 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1388,9 +1388,9 @@ None #### Enumerated Values -| Value(s) | -|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_gateway_key:*`, `ai_gateway_key:create`, `ai_gateway_key:delete`, `ai_gateway_key:read`, `ai_gateway_key:update`, `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_log:*`, `boundary_log:create`, `boundary_log:delete`, `boundary_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:share`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `user_skill:*`, `user_skill:create`, `user_skill:delete`, `user_skill:read`, `user_skill:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_build_orchestration:*`, `workspace_build_orchestration:create`, `workspace_build_orchestration:delete`, `workspace_build_orchestration:read`, `workspace_build_orchestration:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | +| Value(s) | +|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_gateway_key:*`, `ai_gateway_key:create`, `ai_gateway_key:delete`, `ai_gateway_key:read`, `ai_gateway_key:update`, `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_log:*`, `boundary_log:create`, `boundary_log:delete`, `boundary_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:share`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `mcp_server_config:*`, `mcp_server_config:create`, `mcp_server_config:delete`, `mcp_server_config:read`, `mcp_server_config:update`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `user_skill:*`, `user_skill:create`, `user_skill:delete`, `user_skill:read`, `user_skill:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_build_orchestration:*`, `workspace_build_orchestration:create`, `workspace_build_orchestration:delete`, `workspace_build_orchestration:read`, `workspace_build_orchestration:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | ## codersdk.AddLicenseRequest @@ -11324,9 +11324,9 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith #### Enumerated Values -| Value(s) | -|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | +| Value(s) | +|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | ## codersdk.RateLimitConfig diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index ca780e6cf0d94..bb855c7f73252 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -870,11 +870,11 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | -| `login_type` | `github`, `oidc`, `password`, `token` | -| `scope` | `all`, `application_connect` | +| Property | Value(s) | +|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | +| `login_type` | `github`, `oidc`, `password`, `token` | +| `scope` | `all`, `application_connect` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index a0a12df3857f4..3bded6faf138e 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -120,6 +120,12 @@ export const RBACResourceActions: Partial< delete: "delete license", read: "read licenses", }, + mcp_server_config: { + create: "create a new MCP server config", + delete: "delete MCP server config", + read: "read MCP server config", + update: "update MCP server config", + }, notification_message: { create: "create notification messages", delete: "delete notification messages", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 56b0f4825bda1..07cf5894028bd 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -749,6 +749,11 @@ export type APIKeyScope = | "license:create" | "license:delete" | "license:read" + | "mcp_server_config:*" + | "mcp_server_config:create" + | "mcp_server_config:delete" + | "mcp_server_config:read" + | "mcp_server_config:update" | "notification_message:*" | "notification_message:create" | "notification_message:delete" @@ -989,6 +994,11 @@ export const APIKeyScopes: APIKeyScope[] = [ "license:create", "license:delete", "license:read", + "mcp_server_config:*", + "mcp_server_config:create", + "mcp_server_config:delete", + "mcp_server_config:read", + "mcp_server_config:update", "notification_message:*", "notification_message:create", "notification_message:delete", @@ -7750,6 +7760,7 @@ export type RBACResource = | "idpsync_settings" | "inbox_notification" | "license" + | "mcp_server_config" | "notification_message" | "notification_preference" | "notification_template" @@ -7803,6 +7814,7 @@ export const RBACResources: RBACResource[] = [ "idpsync_settings", "inbox_notification", "license", + "mcp_server_config", "notification_message", "notification_preference", "notification_template", From ec3d92048a352b4bfee853bd1c5621e339fa0df6 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 21 Jul 2026 14:11:49 +0000 Subject: [PATCH 03/59] feat(coderd): expose org MCP server APIs Move MCP configuration collections under organizations and conceal unauthorized item and OAuth access. --- coderd/coderd.go | 32 +- coderd/database/dbauthz/dbauthz.go | 103 ++---- coderd/database/dbauthz/dbauthz_test.go | 98 +++--- coderd/database/dbmetrics/querymetrics.go | 52 +-- coderd/database/dbmock/dbmock.go | 89 ++---- .../000565_mcp_server_config_scopes.down.sql | 2 - .../000565_mcp_server_config_scopes.up.sql | 5 - ...cp_server_configs_organization_id.down.sql | 51 ++- ..._mcp_server_configs_organization_id.up.sql | 120 +++++-- coderd/database/modelqueries.go | 26 +- coderd/database/querier.go | 8 +- coderd/database/queries.sql.go | 168 +--------- coderd/database/queries/mcpserverconfigs.sql | 36 +-- coderd/exp_chats.go | 138 ++++---- coderd/mcp.go | 221 +++++-------- coderd/mcp_b2_test.go | 296 ------------------ coderd/mcp_test.go | 274 ++++++---------- coderd/x/chatd/chatd_test.go | 2 +- coderd/x/chatd/generation_preparer.go | 52 +-- codersdk/mcp.go | 29 +- enterprise/coderd/mcp_test.go | 129 ++++++++ enterprise/dbcrypt/dbcrypt.go | 29 +- enterprise/dbcrypt/dbcrypt_internal_test.go | 19 +- site/src/api/typesGenerated.ts | 1 + .../ChatElements/tools/Tool.stories.tsx | 1 + site/src/testHelpers/chatEntities.ts | 1 + 26 files changed, 707 insertions(+), 1275 deletions(-) delete mode 100644 coderd/database/migrations/000565_mcp_server_config_scopes.down.sql delete mode 100644 coderd/database/migrations/000565_mcp_server_config_scopes.up.sql delete mode 100644 coderd/mcp_b2_test.go create mode 100644 enterprise/coderd/mcp_test.go diff --git a/coderd/coderd.go b/coderd/coderd.go index 3f9d3babbf8ac..34a12dd71ba8f 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1378,6 +1378,22 @@ func New(options *Options) *API { r.Use(httpmw.RateLimit(options.FilesRateLimit, time.Minute)) r.Get("/chats/files/{file}/download", api.downloadChatFile) }) + r.Route("/mcp-servers/{mcpserverconfig}", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Get("/", api.getMCPServerConfig) + r.Patch("/", api.updateMCPServerConfig) + r.Delete("/", api.deleteMCPServerConfig) + r.Get("/oauth2/connect", api.mcpServerOAuth2Connect) + r.Delete("/oauth2/disconnect", api.mcpServerOAuth2Disconnect) + }) + r.Route("/organizations/{organization}/mcp-servers", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + httpmw.ExtractOrganizationParam(options.Database), + ) + r.Get("/", api.listMCPServerConfigs) + r.Post("/", api.createMCPServerConfig) + }) r.Route("/chats", func(r chi.Router) { r.Use( apiKeyMiddleware, @@ -1499,20 +1515,8 @@ func New(options *Options) *API { r.Use( apiKeyMiddleware, ) - // MCP server configuration endpoints. - r.Route("/servers", func(r chi.Router) { - r.Get("/", api.listMCPServerConfigs) - r.Post("/", api.createMCPServerConfig) - r.Route("/{mcpServer}", func(r chi.Router) { - r.Get("/", api.getMCPServerConfig) - r.Patch("/", api.updateMCPServerConfig) - r.Delete("/", api.deleteMCPServerConfig) - // OAuth2 user flow - r.Get("/oauth2/connect", api.mcpServerOAuth2Connect) - r.Get("/oauth2/callback", api.mcpServerOAuth2Callback) - r.Delete("/oauth2/disconnect", api.mcpServerOAuth2Disconnect) - }) - }) + // This callback path is frozen because it is registered with OAuth2 providers. + r.Get("/servers/{mcpServer}/oauth2/callback", api.mcpServerOAuth2Callback) // MCP HTTP transport endpoint with mandatory authentication r.Route("/http", func(r chi.Router) { r.Use(httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2, codersdk.ExperimentMCPServerHTTP)) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 7053f611ca1d5..ed65c42a0c637 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2289,11 +2289,11 @@ func (q *querier) DeleteLicense(ctx context.Context, id int32) (int32, error) { } func (q *querier) DeleteMCPServerConfigByID(ctx context.Context, id uuid.UUID) error { - // TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). - // The old delete handler gates on deployment_config update while the - // B1 fallback window lets default-org configs serve every org; the - // object-scoped delete check swaps in at the cutover. - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + config, err := q.db.GetMCPServerConfigByID(ctx, id) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionDelete, config); err != nil { return err } return q.db.DeleteMCPServerConfigByID(ctx, id) @@ -3773,11 +3773,8 @@ func (q *querier) GetEnabledChatModelConfigs(ctx context.Context) ([]database.Ge return q.db.GetEnabledChatModelConfigs(ctx) } -func (q *querier) GetEnabledMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceMCPServerConfig); err != nil { - return nil, err - } - return q.db.GetEnabledMCPServerConfigs(ctx) +func (q *querier) GetEnabledMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetEnabledMCPServerConfigsByOrganization)(ctx, organizationID) } // GetExternalAgentTokensByTemplateID is used for scaletesting purposes; the @@ -3852,18 +3849,8 @@ func (q *querier) GetFilteredInboxNotificationsByUserID(ctx context.Context, arg return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetFilteredInboxNotificationsByUserID)(ctx, arg) } -func (q *querier) GetForcedMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceMCPServerConfig); err != nil { - return nil, err - } - return q.db.GetForcedMCPServerConfigs(ctx) -} - func (q *querier) GetForcedMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceMCPServerConfig); err != nil { - return nil, err - } - return q.db.GetForcedMCPServerConfigsByOrganization(ctx, organizationID) + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetForcedMCPServerConfigsByOrganization)(ctx, organizationID) } func (q *querier) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) { @@ -4065,49 +4052,30 @@ func (q *querier) GetLogoURL(ctx context.Context) (string, error) { } func (q *querier) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (database.MCPServerConfig, error) { - // TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). - // The update/delete handlers fetch the config under the caller's - // subject behind a deployment_config update gate; an object-scoped - // read here would contract that write set during the B1 fallback - // window (a concealed 404). The object-scoped read swaps in at the - // cutover. - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return database.MCPServerConfig{}, err - } - return q.db.GetMCPServerConfigByID(ctx, id) + return fetch(q.log, q.auth, q.db.GetMCPServerConfigByID)(ctx, id) } func (q *querier) GetMCPServerConfigByOrganizationAndSlug(ctx context.Context, arg database.GetMCPServerConfigByOrganizationAndSlugParams) (database.MCPServerConfig, error) { return fetch(q.log, q.auth, q.db.GetMCPServerConfigByOrganizationAndSlug)(ctx, arg) } -func (q *querier) GetMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { - prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceMCPServerConfig.Type) +func (q *querier) GetMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { + prepared, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceMCPServerConfig.Type) if err != nil { - return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err) + return nil, xerrors.Errorf("prepare sql filter: %w", err) } - return q.db.GetAuthorizedMCPServerConfigs(ctx, prep) -} - -func (q *querier) GetMCPServerConfigsByIDs(ctx context.Context, ids []uuid.UUID) ([]database.MCPServerConfig, error) { - return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetMCPServerConfigsByIDs)(ctx, ids) + return q.db.GetAuthorizedMCPServerConfigs(ctx, database.GetAuthorizedMCPServerConfigsParams{ + OrganizationID: organizationID, + Prepared: prepared, + }) } -func (q *querier) GetMCPServerConfigsByIDsAndOrganizations(ctx context.Context, arg database.GetMCPServerConfigsByIDsAndOrganizationsParams) ([]database.MCPServerConfig, error) { - return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetMCPServerConfigsByIDsAndOrganizations)(ctx, arg) +func (q *querier) GetMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg database.GetMCPServerConfigsByOrganizationAndIDsParams) ([]database.MCPServerConfig, error) { + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetMCPServerConfigsByOrganizationAndIDs)(ctx, arg) } func (q *querier) GetMCPServerUserToken(ctx context.Context, arg database.GetMCPServerUserTokenParams) (database.MCPServerUserToken, error) { - // Authorize against the token's config: a user token is usable only - // where its MCP server config is usable. The config is fetched via - // q.db (never another dbauthz method, which would recurse), so in - // Enterprise builds this read goes through dbcrypt and a config - // decryption failure now surfaces in token reads. - cfg, err := q.db.GetMCPServerConfigByID(ctx, arg.MCPServerConfigID) - if err != nil { - return database.MCPServerUserToken{}, err - } - if err := q.authorizeContext(ctx, policy.ActionRead, cfg.RBACObject()); err != nil { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { return database.MCPServerUserToken{}, err } return q.db.GetMCPServerUserToken(ctx, arg) @@ -6225,11 +6193,7 @@ func (q *querier) InsertLicense(ctx context.Context, arg database.InsertLicenseP } func (q *querier) InsertMCPServerConfig(ctx context.Context, arg database.InsertMCPServerConfigParams) (database.MCPServerConfig, error) { - // TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). - // The old create handler gates on deployment_config update while the - // B1 fallback window lets default-org configs serve every org; the - // object-scoped create check swaps in at the cutover. - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceMCPServerConfig.InOrg(arg.OrganizationID)); err != nil { return database.MCPServerConfig{}, err } return q.db.InsertMCPServerConfig(ctx, arg) @@ -7696,11 +7660,11 @@ func (q *querier) UpdateInboxNotificationReadStatus(ctx context.Context, args da } func (q *querier) UpdateMCPServerConfig(ctx context.Context, arg database.UpdateMCPServerConfigParams) (database.MCPServerConfig, error) { - // TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). - // The old update handler gates on deployment_config update while the - // B1 fallback window lets default-org configs serve every org; the - // object-scoped update check swaps in at the cutover. - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + config, err := q.db.GetMCPServerConfigByID(ctx, arg.ID) + if err != nil { + return database.MCPServerConfig{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, config); err != nil { return database.MCPServerConfig{}, err } return q.db.UpdateMCPServerConfig(ctx, arg) @@ -9435,19 +9399,6 @@ func (q *querier) GetAuthorizedChatsByChatFileID(ctx context.Context, fileID uui return q.db.GetAuthorizedChatsByChatFileID(ctx, fileID, prepared) } -func (q *querier) GetAuthorizedMCPServerConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) { - // GetMCPServerConfigs prepares the filter; a caller that already holds - // the prepared value must not discard it (row filtering depends on it). - return q.db.GetAuthorizedMCPServerConfigs(ctx, prepared) -} - -func (q *querier) GetMCPServerConfigManagementList(ctx context.Context) ([]database.MCPServerConfig, error) { - // TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). - // The interim management list gates on deployment_config read; the - // query behind that gate must carry the gate's contract until the - // cutover swaps both to the authorized list. - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return nil, err - } - return q.db.GetMCPServerConfigManagementList(ctx) +func (q *querier) GetAuthorizedMCPServerConfigs(ctx context.Context, arg database.GetAuthorizedMCPServerConfigsParams) ([]database.MCPServerConfig, error) { + return q.db.GetAuthorizedMCPServerConfigs(ctx, arg) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 57a14d065261f..caa922dd30eec 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1632,10 +1632,11 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().CleanupDeletedMCPServerIDsFromChats(gomock.Any()).Return(nil).AnyTimes() check.Args().Asserts(rbac.ResourceChat, policy.ActionUpdate) })) - s.Run("DeleteMCPServerConfigByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - id := uuid.New() - dbm.EXPECT().DeleteMCPServerConfigByID(gomock.Any(), id).Return(nil).AnyTimes() - check.Args(id).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + s.Run("DeleteMCPServerConfigByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + config := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) + dbm.EXPECT().GetMCPServerConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes() + dbm.EXPECT().DeleteMCPServerConfigByID(gomock.Any(), config.ID).Return(nil).AnyTimes() + check.Args(config.ID).Asserts(config, policy.ActionDelete) })) s.Run("DeleteMCPServerUserToken", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { arg := database.DeleteMCPServerUserTokenParams{ @@ -1645,29 +1646,24 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().DeleteMCPServerUserToken(gomock.Any(), arg).Return(nil).AnyTimes() check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) - s.Run("GetEnabledMCPServerConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) - configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) - dbm.EXPECT().GetEnabledMCPServerConfigs(gomock.Any()).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() - check.Args().Asserts(rbac.ResourceMCPServerConfig, policy.ActionRead).Returns([]database.MCPServerConfig{configA, configB}) - })) - s.Run("GetForcedMCPServerConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) - configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) - dbm.EXPECT().GetForcedMCPServerConfigs(gomock.Any()).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() - check.Args().Asserts(rbac.ResourceMCPServerConfig, policy.ActionRead).Returns([]database.MCPServerConfig{configA, configB}) + s.Run("GetEnabledMCPServerConfigsByOrganization", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + orgID := uuid.New() + configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: orgID, Enabled: true}) + configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: orgID, Enabled: true}) + dbm.EXPECT().GetEnabledMCPServerConfigsByOrganization(gomock.Any(), orgID).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() + check.Args(orgID).Asserts(configA, policy.ActionRead, configB, policy.ActionRead).OutOfOrder().Returns([]database.MCPServerConfig{configA, configB}) })) s.Run("GetForcedMCPServerConfigsByOrganization", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { orgID := uuid.New() configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: orgID, Availability: "force_on"}) configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: orgID, Availability: "force_on"}) dbm.EXPECT().GetForcedMCPServerConfigsByOrganization(gomock.Any(), orgID).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() - check.Args(orgID).Asserts(rbac.ResourceMCPServerConfig, policy.ActionRead).Returns([]database.MCPServerConfig{configA, configB}) + check.Args(orgID).Asserts(configA, policy.ActionRead, configB, policy.ActionRead).OutOfOrder().Returns([]database.MCPServerConfig{configA, configB}) })) s.Run("GetMCPServerConfigByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { config := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) dbm.EXPECT().GetMCPServerConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes() - check.Args(config.ID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(config) + check.Args(config.ID).Asserts(config, policy.ActionRead).Returns(config) })) s.Run("GetMCPServerConfigByOrganizationAndSlug", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { arg := database.GetMCPServerConfigByOrganizationAndSlugParams{ @@ -1678,53 +1674,41 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetMCPServerConfigByOrganizationAndSlug(gomock.Any(), arg).Return(config, nil).AnyTimes() check.Args(arg).Asserts(config, policy.ActionRead).Returns(config) })) - s.Run("GetMCPServerConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) - configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) + s.Run("GetMCPServerConfigsByOrganization", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + orgID := uuid.New() + configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: orgID}) + configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: orgID}) dbm.EXPECT().GetAuthorizedMCPServerConfigs(gomock.Any(), gomock.Any()).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() - // No asserts here because SQLFilter. - check.Args().Asserts().Returns([]database.MCPServerConfig{configA, configB}) - })) - s.Run("GetMCPServerConfigManagementList", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) - configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) - dbm.EXPECT().GetMCPServerConfigManagementList(gomock.Any()).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() - check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.MCPServerConfig{configA, configB}) + check.Args(orgID).Asserts().Returns([]database.MCPServerConfig{configA, configB}) })) s.Run("GetAuthorizedMCPServerConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) - configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) - dbm.EXPECT().GetAuthorizedMCPServerConfigs(gomock.Any(), gomock.Any()).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() - // No asserts here because SQLFilter. - check.Args(emptyPreparedAuthorized{}).Asserts().Returns([]database.MCPServerConfig{configA, configB}) + arg := database.GetAuthorizedMCPServerConfigsParams{ + OrganizationID: uuid.New(), + Prepared: emptyPreparedAuthorized{}, + } + configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: arg.OrganizationID}) + configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: arg.OrganizationID}) + dbm.EXPECT().GetAuthorizedMCPServerConfigs(gomock.Any(), arg).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() + check.Args(arg).Asserts().Returns([]database.MCPServerConfig{configA, configB}) })) - s.Run("GetMCPServerConfigsByIDsAndOrganizations", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - arg := database.GetMCPServerConfigsByIDsAndOrganizationsParams{ - IDs: []uuid.UUID{uuid.New(), uuid.New()}, - OrganizationIds: []uuid.UUID{uuid.New(), uuid.New()}, + s.Run("GetMCPServerConfigsByOrganizationAndIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + arg := database.GetMCPServerConfigsByOrganizationAndIDsParams{ + OrganizationID: uuid.New(), + IDs: []uuid.UUID{uuid.New(), uuid.New()}, } - configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{ID: arg.IDs[0], OrganizationID: arg.OrganizationIds[0]}) - configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{ID: arg.IDs[1], OrganizationID: arg.OrganizationIds[1]}) - dbm.EXPECT().GetMCPServerConfigsByIDsAndOrganizations(gomock.Any(), arg).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() + configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{ID: arg.IDs[0], OrganizationID: arg.OrganizationID}) + configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{ID: arg.IDs[1], OrganizationID: arg.OrganizationID}) + dbm.EXPECT().GetMCPServerConfigsByOrganizationAndIDs(gomock.Any(), arg).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() check.Args(arg).Asserts(configA, policy.ActionRead, configB, policy.ActionRead).OutOfOrder().Returns([]database.MCPServerConfig{configA, configB}) })) - s.Run("GetMCPServerConfigsByIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) - configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) - ids := []uuid.UUID{configA.ID, configB.ID} - dbm.EXPECT().GetMCPServerConfigsByIDs(gomock.Any(), ids).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() - check.Args(ids).Asserts(configA, policy.ActionRead, configB, policy.ActionRead).OutOfOrder().Returns([]database.MCPServerConfig{configA, configB}) - })) s.Run("GetMCPServerUserToken", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - config := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) arg := database.GetMCPServerUserTokenParams{ - MCPServerConfigID: config.ID, + MCPServerConfigID: uuid.New(), UserID: uuid.New(), } token := testutil.Fake(s.T(), faker, database.MCPServerUserToken{MCPServerConfigID: arg.MCPServerConfigID, UserID: arg.UserID}) - dbm.EXPECT().GetMCPServerConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes() dbm.EXPECT().GetMCPServerUserToken(gomock.Any(), arg).Return(token, nil).AnyTimes() - check.Args(arg).Asserts(config, policy.ActionRead).Returns(token) + check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(token) })) s.Run("GetMCPServerUserTokensByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { userID := uuid.New() @@ -1734,12 +1718,13 @@ func (s *MethodTestSuite) TestChats() { })) s.Run("InsertMCPServerConfig", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { arg := database.InsertMCPServerConfigParams{ - DisplayName: "Test MCP Server", - Slug: "test-mcp-server", + OrganizationID: uuid.New(), + DisplayName: "Test MCP Server", + Slug: "test-mcp-server", } - config := testutil.Fake(s.T(), faker, database.MCPServerConfig{DisplayName: arg.DisplayName, Slug: arg.Slug}) + config := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: arg.OrganizationID, DisplayName: arg.DisplayName, Slug: arg.Slug}) dbm.EXPECT().InsertMCPServerConfig(gomock.Any(), arg).Return(config, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(config) + check.Args(arg).Asserts(rbac.ResourceMCPServerConfig.InOrg(arg.OrganizationID), policy.ActionCreate).Returns(config) })) s.Run("UpdateChatMCPServerIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) @@ -1790,8 +1775,9 @@ func (s *MethodTestSuite) TestChats() { DisplayName: "Updated MCP Server", Slug: "updated-mcp-server", } + dbm.EXPECT().GetMCPServerConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes() dbm.EXPECT().UpdateMCPServerConfig(gomock.Any(), arg).Return(config, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(config) + check.Args(arg).Asserts(config, policy.ActionUpdate).Returns(config) })) s.Run("UpdateMCPServerUserTokenFromRefresh", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { token := testutil.Fake(s.T(), faker, database.MCPServerUserToken{}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 211997b4df45e..d25e654fc1ac0 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2009,11 +2009,11 @@ func (m queryMetricsStore) GetEnabledChatModelConfigs(ctx context.Context) ([]da return r0, r1 } -func (m queryMetricsStore) GetEnabledMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { +func (m queryMetricsStore) GetEnabledMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { start := time.Now() - r0, r1 := m.s.GetEnabledMCPServerConfigs(ctx) - m.queryLatencies.WithLabelValues("GetEnabledMCPServerConfigs").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetEnabledMCPServerConfigs").Inc() + r0, r1 := m.s.GetEnabledMCPServerConfigsByOrganization(ctx, organizationID) + m.queryLatencies.WithLabelValues("GetEnabledMCPServerConfigsByOrganization").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetEnabledMCPServerConfigsByOrganization").Inc() return r0, r1 } @@ -2081,14 +2081,6 @@ func (m queryMetricsStore) GetFilteredInboxNotificationsByUserID(ctx context.Con return r0, r1 } -func (m queryMetricsStore) GetForcedMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { - start := time.Now() - r0, r1 := m.s.GetForcedMCPServerConfigs(ctx) - m.queryLatencies.WithLabelValues("GetForcedMCPServerConfigs").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetForcedMCPServerConfigs").Inc() - return r0, r1 -} - func (m queryMetricsStore) GetForcedMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { start := time.Now() r0, r1 := m.s.GetForcedMCPServerConfigsByOrganization(ctx, organizationID) @@ -2337,27 +2329,19 @@ func (m queryMetricsStore) GetMCPServerConfigByOrganizationAndSlug(ctx context.C return r0, r1 } -func (m queryMetricsStore) GetMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { - start := time.Now() - r0, r1 := m.s.GetMCPServerConfigs(ctx) - m.queryLatencies.WithLabelValues("GetMCPServerConfigs").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetMCPServerConfigs").Inc() - return r0, r1 -} - -func (m queryMetricsStore) GetMCPServerConfigsByIDs(ctx context.Context, ids []uuid.UUID) ([]database.MCPServerConfig, error) { +func (m queryMetricsStore) GetMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { start := time.Now() - r0, r1 := m.s.GetMCPServerConfigsByIDs(ctx, ids) - m.queryLatencies.WithLabelValues("GetMCPServerConfigsByIDs").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetMCPServerConfigsByIDs").Inc() + r0, r1 := m.s.GetMCPServerConfigsByOrganization(ctx, organizationID) + m.queryLatencies.WithLabelValues("GetMCPServerConfigsByOrganization").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetMCPServerConfigsByOrganization").Inc() return r0, r1 } -func (m queryMetricsStore) GetMCPServerConfigsByIDsAndOrganizations(ctx context.Context, arg database.GetMCPServerConfigsByIDsAndOrganizationsParams) ([]database.MCPServerConfig, error) { +func (m queryMetricsStore) GetMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg database.GetMCPServerConfigsByOrganizationAndIDsParams) ([]database.MCPServerConfig, error) { start := time.Now() - r0, r1 := m.s.GetMCPServerConfigsByIDsAndOrganizations(ctx, arg) - m.queryLatencies.WithLabelValues("GetMCPServerConfigsByIDsAndOrganizations").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetMCPServerConfigsByIDsAndOrganizations").Inc() + r0, r1 := m.s.GetMCPServerConfigsByOrganizationAndIDs(ctx, arg) + m.queryLatencies.WithLabelValues("GetMCPServerConfigsByOrganizationAndIDs").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetMCPServerConfigsByOrganizationAndIDs").Inc() return r0, r1 } @@ -6809,18 +6793,10 @@ func (m queryMetricsStore) GetAuthorizedChatsByChatFileID(ctx context.Context, f return r0, r1 } -func (m queryMetricsStore) GetAuthorizedMCPServerConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) { +func (m queryMetricsStore) GetAuthorizedMCPServerConfigs(ctx context.Context, arg database.GetAuthorizedMCPServerConfigsParams) ([]database.MCPServerConfig, error) { start := time.Now() - r0, r1 := m.s.GetAuthorizedMCPServerConfigs(ctx, prepared) + r0, r1 := m.s.GetAuthorizedMCPServerConfigs(ctx, arg) m.queryLatencies.WithLabelValues("GetAuthorizedMCPServerConfigs").Observe(time.Since(start).Seconds()) m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAuthorizedMCPServerConfigs").Inc() return r0, r1 } - -func (m queryMetricsStore) GetMCPServerConfigManagementList(ctx context.Context) ([]database.MCPServerConfig, error) { - start := time.Now() - r0, r1 := m.s.GetMCPServerConfigManagementList(ctx) - m.queryLatencies.WithLabelValues("GetMCPServerConfigManagementList").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetMCPServerConfigManagementList").Inc() - return r0, r1 -} diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 9f5c7dd1e709a..86bae961787af 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2506,18 +2506,18 @@ func (mr *MockStoreMockRecorder) GetAuthorizedConnectionLogsOffset(ctx, arg, pre } // GetAuthorizedMCPServerConfigs mocks base method. -func (m *MockStore) GetAuthorizedMCPServerConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) { +func (m *MockStore) GetAuthorizedMCPServerConfigs(ctx context.Context, arg database.GetAuthorizedMCPServerConfigsParams) ([]database.MCPServerConfig, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAuthorizedMCPServerConfigs", ctx, prepared) + ret := m.ctrl.Call(m, "GetAuthorizedMCPServerConfigs", ctx, arg) ret0, _ := ret[0].([]database.MCPServerConfig) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAuthorizedMCPServerConfigs indicates an expected call of GetAuthorizedMCPServerConfigs. -func (mr *MockStoreMockRecorder) GetAuthorizedMCPServerConfigs(ctx, prepared any) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAuthorizedMCPServerConfigs(ctx, arg any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedMCPServerConfigs", reflect.TypeOf((*MockStore)(nil).GetAuthorizedMCPServerConfigs), ctx, prepared) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedMCPServerConfigs", reflect.TypeOf((*MockStore)(nil).GetAuthorizedMCPServerConfigs), ctx, arg) } // GetAuthorizedTemplates mocks base method. @@ -3750,19 +3750,19 @@ func (mr *MockStoreMockRecorder) GetEnabledChatModelConfigs(ctx any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEnabledChatModelConfigs", reflect.TypeOf((*MockStore)(nil).GetEnabledChatModelConfigs), ctx) } -// GetEnabledMCPServerConfigs mocks base method. -func (m *MockStore) GetEnabledMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { +// GetEnabledMCPServerConfigsByOrganization mocks base method. +func (m *MockStore) GetEnabledMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetEnabledMCPServerConfigs", ctx) + ret := m.ctrl.Call(m, "GetEnabledMCPServerConfigsByOrganization", ctx, organizationID) ret0, _ := ret[0].([]database.MCPServerConfig) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetEnabledMCPServerConfigs indicates an expected call of GetEnabledMCPServerConfigs. -func (mr *MockStoreMockRecorder) GetEnabledMCPServerConfigs(ctx any) *gomock.Call { +// GetEnabledMCPServerConfigsByOrganization indicates an expected call of GetEnabledMCPServerConfigsByOrganization. +func (mr *MockStoreMockRecorder) GetEnabledMCPServerConfigsByOrganization(ctx, organizationID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEnabledMCPServerConfigs", reflect.TypeOf((*MockStore)(nil).GetEnabledMCPServerConfigs), ctx) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEnabledMCPServerConfigsByOrganization", reflect.TypeOf((*MockStore)(nil).GetEnabledMCPServerConfigsByOrganization), ctx, organizationID) } // GetExternalAgentTokensByTemplateID mocks base method. @@ -3885,21 +3885,6 @@ func (mr *MockStoreMockRecorder) GetFilteredInboxNotificationsByUserID(ctx, arg return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFilteredInboxNotificationsByUserID", reflect.TypeOf((*MockStore)(nil).GetFilteredInboxNotificationsByUserID), ctx, arg) } -// GetForcedMCPServerConfigs mocks base method. -func (m *MockStore) GetForcedMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetForcedMCPServerConfigs", ctx) - ret0, _ := ret[0].([]database.MCPServerConfig) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetForcedMCPServerConfigs indicates an expected call of GetForcedMCPServerConfigs. -func (mr *MockStoreMockRecorder) GetForcedMCPServerConfigs(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetForcedMCPServerConfigs", reflect.TypeOf((*MockStore)(nil).GetForcedMCPServerConfigs), ctx) -} - // GetForcedMCPServerConfigsByOrganization mocks base method. func (m *MockStore) GetForcedMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { m.ctrl.T.Helper() @@ -4365,64 +4350,34 @@ func (mr *MockStoreMockRecorder) GetMCPServerConfigByOrganizationAndSlug(ctx, ar return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigByOrganizationAndSlug", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigByOrganizationAndSlug), ctx, arg) } -// GetMCPServerConfigManagementList mocks base method. -func (m *MockStore) GetMCPServerConfigManagementList(ctx context.Context) ([]database.MCPServerConfig, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetMCPServerConfigManagementList", ctx) - ret0, _ := ret[0].([]database.MCPServerConfig) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetMCPServerConfigManagementList indicates an expected call of GetMCPServerConfigManagementList. -func (mr *MockStoreMockRecorder) GetMCPServerConfigManagementList(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigManagementList", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigManagementList), ctx) -} - -// GetMCPServerConfigs mocks base method. -func (m *MockStore) GetMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetMCPServerConfigs", ctx) - ret0, _ := ret[0].([]database.MCPServerConfig) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetMCPServerConfigs indicates an expected call of GetMCPServerConfigs. -func (mr *MockStoreMockRecorder) GetMCPServerConfigs(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigs", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigs), ctx) -} - -// GetMCPServerConfigsByIDs mocks base method. -func (m *MockStore) GetMCPServerConfigsByIDs(ctx context.Context, ids []uuid.UUID) ([]database.MCPServerConfig, error) { +// GetMCPServerConfigsByOrganization mocks base method. +func (m *MockStore) GetMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetMCPServerConfigsByIDs", ctx, ids) + ret := m.ctrl.Call(m, "GetMCPServerConfigsByOrganization", ctx, organizationID) ret0, _ := ret[0].([]database.MCPServerConfig) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetMCPServerConfigsByIDs indicates an expected call of GetMCPServerConfigsByIDs. -func (mr *MockStoreMockRecorder) GetMCPServerConfigsByIDs(ctx, ids any) *gomock.Call { +// GetMCPServerConfigsByOrganization indicates an expected call of GetMCPServerConfigsByOrganization. +func (mr *MockStoreMockRecorder) GetMCPServerConfigsByOrganization(ctx, organizationID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigsByIDs", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigsByIDs), ctx, ids) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigsByOrganization", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigsByOrganization), ctx, organizationID) } -// GetMCPServerConfigsByIDsAndOrganizations mocks base method. -func (m *MockStore) GetMCPServerConfigsByIDsAndOrganizations(ctx context.Context, arg database.GetMCPServerConfigsByIDsAndOrganizationsParams) ([]database.MCPServerConfig, error) { +// GetMCPServerConfigsByOrganizationAndIDs mocks base method. +func (m *MockStore) GetMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg database.GetMCPServerConfigsByOrganizationAndIDsParams) ([]database.MCPServerConfig, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetMCPServerConfigsByIDsAndOrganizations", ctx, arg) + ret := m.ctrl.Call(m, "GetMCPServerConfigsByOrganizationAndIDs", ctx, arg) ret0, _ := ret[0].([]database.MCPServerConfig) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetMCPServerConfigsByIDsAndOrganizations indicates an expected call of GetMCPServerConfigsByIDsAndOrganizations. -func (mr *MockStoreMockRecorder) GetMCPServerConfigsByIDsAndOrganizations(ctx, arg any) *gomock.Call { +// GetMCPServerConfigsByOrganizationAndIDs indicates an expected call of GetMCPServerConfigsByOrganizationAndIDs. +func (mr *MockStoreMockRecorder) GetMCPServerConfigsByOrganizationAndIDs(ctx, arg any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigsByIDsAndOrganizations", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigsByIDsAndOrganizations), ctx, arg) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigsByOrganizationAndIDs", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigsByOrganizationAndIDs), ctx, arg) } // GetMCPServerUserToken mocks base method. diff --git a/coderd/database/migrations/000565_mcp_server_config_scopes.down.sql b/coderd/database/migrations/000565_mcp_server_config_scopes.down.sql deleted file mode 100644 index 04f101ceb4e84..0000000000000 --- a/coderd/database/migrations/000565_mcp_server_config_scopes.down.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Enum additions to api_key_scope are intentionally not reverted because --- Postgres cannot drop enum values safely. diff --git a/coderd/database/migrations/000565_mcp_server_config_scopes.up.sql b/coderd/database/migrations/000565_mcp_server_config_scopes.up.sql deleted file mode 100644 index 8e08c8d78d168..0000000000000 --- a/coderd/database/migrations/000565_mcp_server_config_scopes.up.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'mcp_server_config:*'; -ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'mcp_server_config:create'; -ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'mcp_server_config:read'; -ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'mcp_server_config:update'; -ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'mcp_server_config:delete'; diff --git a/coderd/database/migrations/000565_mcp_server_configs_organization_id.down.sql b/coderd/database/migrations/000565_mcp_server_configs_organization_id.down.sql index d4505385a2197..eceefc90071fb 100644 --- a/coderd/database/migrations/000565_mcp_server_configs_organization_id.down.sql +++ b/coderd/database/migrations/000565_mcp_server_configs_organization_id.down.sql @@ -1,10 +1,47 @@ --- Restore the deployment-wide slug uniqueness before dropping the org --- column. This is safe only because every row lives in the default --- organization during the schema-stage window; the org-scoping cutover --- (CODAGT-711 B3) removes this assumption. -ALTER TABLE mcp_server_configs DROP CONSTRAINT mcp_server_configs_organization_id_slug_key; -ALTER TABLE mcp_server_configs ADD CONSTRAINT mcp_server_configs_slug_key UNIQUE (slug); +CREATE TEMP TABLE mcp_server_config_restore_map ( + config_id UUID PRIMARY KEY, + default_config_id UUID NOT NULL +) ON COMMIT DROP; + +INSERT INTO mcp_server_config_restore_map (config_id, default_config_id) +SELECT config.id, default_config.id +FROM mcp_server_configs AS config +JOIN mcp_server_configs AS default_config + ON default_config.slug = config.slug + AND default_config.organization_id = ( + SELECT id FROM organizations WHERE is_default = true LIMIT 1 + ) +WHERE config.organization_id != default_config.organization_id; + +UPDATE chats AS chat +SET mcp_server_ids = remapped.ids +FROM ( + SELECT + source.id, + COALESCE( + array_agg(COALESCE(mapping.default_config_id, item.config_id) ORDER BY item.position) + FILTER (WHERE item.config_id IS NOT NULL), + '{}'::UUID[] + ) AS ids + FROM chats AS source + LEFT JOIN LATERAL unnest(source.mcp_server_ids) WITH ORDINALITY + AS item(config_id, position) ON true + LEFT JOIN mcp_server_config_restore_map AS mapping + ON mapping.config_id = item.config_id + GROUP BY source.id +) AS remapped +WHERE remapped.id = chat.id; + +DELETE FROM mcp_server_configs +WHERE organization_id != ( + SELECT id FROM organizations WHERE is_default = true LIMIT 1 +); DROP INDEX idx_mcp_server_configs_organization_id; -ALTER TABLE mcp_server_configs DROP COLUMN organization_id; +ALTER TABLE mcp_server_configs + DROP CONSTRAINT mcp_server_configs_organization_id_slug_key, + DROP COLUMN organization_id, + ADD CONSTRAINT mcp_server_configs_slug_key UNIQUE (slug); + +-- Enum values cannot be removed safely from api_key_scope. diff --git a/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql index 72ab28dc1bc80..c411aa1d493bb 100644 --- a/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql +++ b/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql @@ -1,32 +1,110 @@ --- Org-scope MCP server configs: every config belongs to exactly one --- organization. This migration backfills all existing rows to the default --- organization; runtime behavior is preserved by a chat-org-then-default-org --- lookup window that ends at the org-scoping cutover (CODAGT-711 B3). +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'mcp_server_config:*'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'mcp_server_config:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'mcp_server_config:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'mcp_server_config:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'mcp_server_config:delete'; --- Step 1: Add the nullable column with FK (000467 recipe). ALTER TABLE mcp_server_configs ADD COLUMN organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE; --- Step 2: Backfill every row to the default organization. Abort loudly if --- the deployment has no default organization; a silent partial backfill --- would fail the NOT NULL step with an opaque error. DO $$ -DECLARE - default_org_id UUID; BEGIN - SELECT id INTO default_org_id FROM organizations WHERE is_default = true LIMIT 1; - IF default_org_id IS NULL THEN - RAISE EXCEPTION 'cannot backfill mcp_server_configs.organization_id: no default organization exists'; + IF NOT EXISTS (SELECT 1 FROM organizations WHERE is_default = true) THEN + RAISE EXCEPTION 'cannot scope mcp_server_configs: no default organization exists'; END IF; - UPDATE mcp_server_configs SET organization_id = default_org_id; END $$; --- Step 3: Enforce NOT NULL going forward. -ALTER TABLE mcp_server_configs ALTER COLUMN organization_id SET NOT NULL; +UPDATE mcp_server_configs +SET organization_id = (SELECT id FROM organizations WHERE is_default = true LIMIT 1); --- Step 4: Slug uniqueness becomes per-organization. -ALTER TABLE mcp_server_configs DROP CONSTRAINT mcp_server_configs_slug_key; -ALTER TABLE mcp_server_configs ADD CONSTRAINT mcp_server_configs_organization_id_slug_key UNIQUE (organization_id, slug); +CREATE TEMP TABLE mcp_server_config_org_map ( + old_id UUID NOT NULL, + organization_id UUID NOT NULL, + new_id UUID NOT NULL, + PRIMARY KEY (old_id, organization_id), + UNIQUE (new_id) +) ON COMMIT DROP; --- Step 5: Index for efficient lookups by organization. -CREATE INDEX idx_mcp_server_configs_organization_id ON mcp_server_configs (organization_id); +INSERT INTO mcp_server_config_org_map (old_id, organization_id, new_id) +SELECT config.id, organization.id, gen_random_uuid() +FROM mcp_server_configs AS config +CROSS JOIN organizations AS organization +WHERE organization.deleted = false + AND organization.is_default = false; + +INSERT INTO mcp_server_configs ( + id, organization_id, display_name, slug, description, icon_url, transport, + url, auth_type, oauth2_client_id, oauth2_client_secret, + oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, + oauth2_revocation_url, oauth2_scopes, api_key_header, api_key_value, + api_key_value_key_id, custom_headers, custom_headers_key_id, + tool_allow_list, tool_deny_list, availability, enabled, model_intent, + allow_in_plan_mode, forward_coder_headers, created_by, updated_by, + created_at, updated_at +) +SELECT + mapping.new_id, + mapping.organization_id, + config.display_name, + config.slug, + config.description, + config.icon_url, + config.transport, + config.url, + config.auth_type, + CASE WHEN config.auth_type = 'oauth2' THEN '' ELSE config.oauth2_client_id END, + CASE WHEN config.auth_type = 'oauth2' THEN '' ELSE config.oauth2_client_secret END, + CASE WHEN config.auth_type = 'oauth2' THEN NULL ELSE config.oauth2_client_secret_key_id END, + config.oauth2_auth_url, + config.oauth2_token_url, + config.oauth2_revocation_url, + config.oauth2_scopes, + config.api_key_header, + config.api_key_value, + config.api_key_value_key_id, + config.custom_headers, + config.custom_headers_key_id, + config.tool_allow_list, + config.tool_deny_list, + config.availability, + CASE WHEN config.auth_type = 'oauth2' THEN false ELSE config.enabled END, + config.model_intent, + config.allow_in_plan_mode, + config.forward_coder_headers, + config.created_by, + config.updated_by, + config.created_at, + config.updated_at +FROM mcp_server_config_org_map AS mapping +JOIN mcp_server_configs AS config ON config.id = mapping.old_id; + +UPDATE chats AS chat +SET mcp_server_ids = remapped.ids +FROM ( + SELECT + source.id, + COALESCE( + array_agg(COALESCE(mapping.new_id, item.config_id) ORDER BY item.position) + FILTER (WHERE item.config_id IS NOT NULL), + '{}'::UUID[] + ) AS ids + FROM chats AS source + LEFT JOIN LATERAL unnest(source.mcp_server_ids) WITH ORDINALITY + AS item(config_id, position) ON true + LEFT JOIN mcp_server_config_org_map AS mapping + ON mapping.old_id = item.config_id + AND mapping.organization_id = source.organization_id + WHERE source.organization_id != ( + SELECT id FROM organizations WHERE is_default = true LIMIT 1 + ) + GROUP BY source.id +) AS remapped +WHERE remapped.id = chat.id; + +ALTER TABLE mcp_server_configs + ALTER COLUMN organization_id SET NOT NULL, + DROP CONSTRAINT mcp_server_configs_slug_key, + ADD CONSTRAINT mcp_server_configs_organization_id_slug_key UNIQUE (organization_id, slug); + +CREATE INDEX idx_mcp_server_configs_organization_id + ON mcp_server_configs (organization_id); diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 92bc43907af41..1da24c391d69a 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -1201,27 +1201,31 @@ func (q *sqlQuerier) UpdateUserLinkRawJSON(ctx context.Context, userID uuid.UUID return err } +type GetAuthorizedMCPServerConfigsParams struct { + OrganizationID uuid.UUID + Prepared rbac.PreparedAuthorized +} + type mcpServerConfigQuerier interface { - GetAuthorizedMCPServerConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]MCPServerConfig, error) - GetMCPServerConfigManagementList(ctx context.Context) ([]MCPServerConfig, error) + GetAuthorizedMCPServerConfigs(ctx context.Context, arg GetAuthorizedMCPServerConfigsParams) ([]MCPServerConfig, error) } -func (q *sqlQuerier) GetAuthorizedMCPServerConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]MCPServerConfig, error) { - authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{ +func (q *sqlQuerier) GetAuthorizedMCPServerConfigs(ctx context.Context, arg GetAuthorizedMCPServerConfigsParams) ([]MCPServerConfig, error) { + authorizedFilter, err := arg.Prepared.CompileToSQL(ctx, regosql.ConvertConfig{ VariableConverter: regosql.MCPServerConfigNoACLConverter(), }) if err != nil { return nil, xerrors.Errorf("compile authorized filter: %w", err) } - filtered, err := insertAuthorizedFilter(getMCPServerConfigs, fmt.Sprintf(" AND %s", authorizedFilter)) + filtered, err := insertAuthorizedFilter(getMCPServerConfigsByOrganization, fmt.Sprintf(" AND %s", authorizedFilter)) if err != nil { return nil, xerrors.Errorf("insert authorized filter: %w", err) } // The name comment is for metric tracking query := fmt.Sprintf("-- name: GetAuthorizedMCPServerConfigs :many\n%s", filtered) - rows, err := q.db.QueryContext(ctx, query) + rows, err := q.db.QueryContext(ctx, query, arg.OrganizationID) if err != nil { return nil, err } @@ -1272,13 +1276,3 @@ func (q *sqlQuerier) GetAuthorizedMCPServerConfigs(ctx context.Context, prepared } return items, nil } - -// GetMCPServerConfigManagementList returns every MCP server config -// unfiltered. It backs the interim HTTP management list, whose gate is -// deployment_config read; the query behind that gate must carry the gate's -// contract until the B3 cutover swaps both to the authorized list. -// -// TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). -func (q *sqlQuerier) GetMCPServerConfigManagementList(ctx context.Context) ([]MCPServerConfig, error) { - return q.GetMCPServerConfigs(ctx) -} diff --git a/coderd/database/querier.go b/coderd/database/querier.go index d4e7bcc6c583f..330a9e1f0e7d0 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -554,7 +554,7 @@ type sqlcQuerier interface { // Check both to ensure the selected config is actually usable. GetEnabledChatModelConfigByID(ctx context.Context, id uuid.UUID) (ChatModelConfig, error) GetEnabledChatModelConfigs(ctx context.Context) ([]GetEnabledChatModelConfigsRow, error) - GetEnabledMCPServerConfigs(ctx context.Context) ([]MCPServerConfig, error) + GetEnabledMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]MCPServerConfig, error) // GetExternalAgentTokensByTemplateID returns the auth tokens for all // non-deleted external agents on the latest build of every running workspace // of the given template. "Running" means the latest build has @@ -579,7 +579,6 @@ type sqlcQuerier interface { // param created_at_opt: The created_at timestamp to filter by. This parameter is usd for pagination - it fetches notifications created before the specified timestamp if it is not the zero value // param limit_opt: The limit of notifications to fetch. If the limit is not specified, it defaults to 25 GetFilteredInboxNotificationsByUserID(ctx context.Context, arg GetFilteredInboxNotificationsByUserIDParams) ([]InboxNotification, error) - GetForcedMCPServerConfigs(ctx context.Context) ([]MCPServerConfig, error) GetForcedMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]MCPServerConfig, error) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) GetGroupAIBudget(ctx context.Context, groupID uuid.UUID) (GroupAIBudget, error) @@ -647,9 +646,8 @@ type sqlcQuerier interface { GetLogoURL(ctx context.Context) (string, error) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (MCPServerConfig, error) GetMCPServerConfigByOrganizationAndSlug(ctx context.Context, arg GetMCPServerConfigByOrganizationAndSlugParams) (MCPServerConfig, error) - GetMCPServerConfigs(ctx context.Context) ([]MCPServerConfig, error) - GetMCPServerConfigsByIDs(ctx context.Context, ids []uuid.UUID) ([]MCPServerConfig, error) - GetMCPServerConfigsByIDsAndOrganizations(ctx context.Context, arg GetMCPServerConfigsByIDsAndOrganizationsParams) ([]MCPServerConfig, error) + GetMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]MCPServerConfig, error) + GetMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg GetMCPServerConfigsByOrganizationAndIDsParams) ([]MCPServerConfig, error) GetMCPServerUserToken(ctx context.Context, arg GetMCPServerUserTokenParams) (MCPServerUserToken, error) GetMCPServerUserTokensByUserID(ctx context.Context, userID uuid.UUID) ([]MCPServerUserToken, error) // Must be called from within a transaction. The row lock is released diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 421175f9aaa83..962790bb5e73f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -17133,87 +17133,20 @@ func (q *sqlQuerier) DeleteMCPServerUserToken(ctx context.Context, arg DeleteMCP return err } -const getEnabledMCPServerConfigs = `-- name: GetEnabledMCPServerConfigs :many +const getEnabledMCPServerConfigsByOrganization = `-- name: GetEnabledMCPServerConfigsByOrganization :many SELECT id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id FROM mcp_server_configs WHERE - enabled = TRUE -ORDER BY - display_name ASC -` - -func (q *sqlQuerier) GetEnabledMCPServerConfigs(ctx context.Context) ([]MCPServerConfig, error) { - rows, err := q.db.QueryContext(ctx, getEnabledMCPServerConfigs) - if err != nil { - return nil, err - } - defer rows.Close() - var items []MCPServerConfig - for rows.Next() { - var i MCPServerConfig - if err := rows.Scan( - &i.ID, - &i.DisplayName, - &i.Slug, - &i.Description, - &i.IconURL, - &i.Transport, - &i.Url, - &i.AuthType, - &i.OAuth2ClientID, - &i.OAuth2ClientSecret, - &i.OAuth2ClientSecretKeyID, - &i.OAuth2AuthURL, - &i.OAuth2TokenURL, - &i.OAuth2Scopes, - &i.APIKeyHeader, - &i.APIKeyValue, - &i.APIKeyValueKeyID, - &i.CustomHeaders, - &i.CustomHeadersKeyID, - pq.Array(&i.ToolAllowList), - pq.Array(&i.ToolDenyList), - &i.Availability, - &i.Enabled, - &i.CreatedBy, - &i.UpdatedBy, - &i.CreatedAt, - &i.UpdatedAt, - &i.ModelIntent, - &i.AllowInPlanMode, - &i.ForwardCoderHeaders, - &i.OAuth2RevocationURL, - &i.OrganizationID, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getForcedMCPServerConfigs = `-- name: GetForcedMCPServerConfigs :many -SELECT - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id -FROM - mcp_server_configs -WHERE - enabled = TRUE - AND availability = 'force_on' + organization_id = $1::uuid + AND enabled = TRUE ORDER BY display_name ASC ` -func (q *sqlQuerier) GetForcedMCPServerConfigs(ctx context.Context) ([]MCPServerConfig, error) { - rows, err := q.db.QueryContext(ctx, getForcedMCPServerConfigs) +func (q *sqlQuerier) GetEnabledMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]MCPServerConfig, error) { + rows, err := q.db.QueryContext(ctx, getEnabledMCPServerConfigsByOrganization, organizationID) if err != nil { return nil, err } @@ -17441,21 +17374,21 @@ func (q *sqlQuerier) GetMCPServerConfigByOrganizationAndSlug(ctx context.Context return i, err } -const getMCPServerConfigs = `-- name: GetMCPServerConfigs :many +const getMCPServerConfigsByOrganization = `-- name: GetMCPServerConfigsByOrganization :many SELECT id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id FROM mcp_server_configs WHERE - true + organization_id = $1::uuid -- Authorize Filter clause will be injected below in GetAuthorizedMCPServerConfigs -- @authorize_filter ORDER BY display_name ASC ` -func (q *sqlQuerier) GetMCPServerConfigs(ctx context.Context) ([]MCPServerConfig, error) { - rows, err := q.db.QueryContext(ctx, getMCPServerConfigs) +func (q *sqlQuerier) GetMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]MCPServerConfig, error) { + rows, err := q.db.QueryContext(ctx, getMCPServerConfigsByOrganization, organizationID) if err != nil { return nil, err } @@ -17510,92 +17443,25 @@ func (q *sqlQuerier) GetMCPServerConfigs(ctx context.Context) ([]MCPServerConfig return items, nil } -const getMCPServerConfigsByIDs = `-- name: GetMCPServerConfigsByIDs :many +const getMCPServerConfigsByOrganizationAndIDs = `-- name: GetMCPServerConfigsByOrganizationAndIDs :many SELECT id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id FROM mcp_server_configs WHERE - id = ANY($1::uuid[]) -ORDER BY - display_name ASC -` - -func (q *sqlQuerier) GetMCPServerConfigsByIDs(ctx context.Context, ids []uuid.UUID) ([]MCPServerConfig, error) { - rows, err := q.db.QueryContext(ctx, getMCPServerConfigsByIDs, pq.Array(ids)) - if err != nil { - return nil, err - } - defer rows.Close() - var items []MCPServerConfig - for rows.Next() { - var i MCPServerConfig - if err := rows.Scan( - &i.ID, - &i.DisplayName, - &i.Slug, - &i.Description, - &i.IconURL, - &i.Transport, - &i.Url, - &i.AuthType, - &i.OAuth2ClientID, - &i.OAuth2ClientSecret, - &i.OAuth2ClientSecretKeyID, - &i.OAuth2AuthURL, - &i.OAuth2TokenURL, - &i.OAuth2Scopes, - &i.APIKeyHeader, - &i.APIKeyValue, - &i.APIKeyValueKeyID, - &i.CustomHeaders, - &i.CustomHeadersKeyID, - pq.Array(&i.ToolAllowList), - pq.Array(&i.ToolDenyList), - &i.Availability, - &i.Enabled, - &i.CreatedBy, - &i.UpdatedBy, - &i.CreatedAt, - &i.UpdatedAt, - &i.ModelIntent, - &i.AllowInPlanMode, - &i.ForwardCoderHeaders, - &i.OAuth2RevocationURL, - &i.OrganizationID, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getMCPServerConfigsByIDsAndOrganizations = `-- name: GetMCPServerConfigsByIDsAndOrganizations :many -SELECT - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id -FROM - mcp_server_configs -WHERE - id = ANY($1::uuid[]) - AND organization_id = ANY($2::uuid[]) + organization_id = $1::uuid + AND id = ANY($2::uuid[]) ORDER BY display_name ASC ` -type GetMCPServerConfigsByIDsAndOrganizationsParams struct { - IDs []uuid.UUID `db:"ids" json:"ids"` - OrganizationIds []uuid.UUID `db:"organization_ids" json:"organization_ids"` +type GetMCPServerConfigsByOrganizationAndIDsParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + IDs []uuid.UUID `db:"ids" json:"ids"` } -func (q *sqlQuerier) GetMCPServerConfigsByIDsAndOrganizations(ctx context.Context, arg GetMCPServerConfigsByIDsAndOrganizationsParams) ([]MCPServerConfig, error) { - rows, err := q.db.QueryContext(ctx, getMCPServerConfigsByIDsAndOrganizations, pq.Array(arg.IDs), pq.Array(arg.OrganizationIds)) +func (q *sqlQuerier) GetMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg GetMCPServerConfigsByOrganizationAndIDsParams) ([]MCPServerConfig, error) { + rows, err := q.db.QueryContext(ctx, getMCPServerConfigsByOrganizationAndIDs, arg.OrganizationID, pq.Array(arg.IDs)) if err != nil { return nil, err } diff --git a/coderd/database/queries/mcpserverconfigs.sql b/coderd/database/queries/mcpserverconfigs.sql index 48173f9a9e550..27f8bc352cf21 100644 --- a/coderd/database/queries/mcpserverconfigs.sql +++ b/coderd/database/queries/mcpserverconfigs.sql @@ -15,57 +15,37 @@ WHERE organization_id = @organization_id::uuid AND slug = @slug::text; --- name: GetMCPServerConfigs :many +-- name: GetMCPServerConfigsByOrganization :many SELECT * FROM mcp_server_configs WHERE - true + organization_id = @organization_id::uuid -- Authorize Filter clause will be injected below in GetAuthorizedMCPServerConfigs -- @authorize_filter ORDER BY display_name ASC; --- name: GetEnabledMCPServerConfigs :many +-- name: GetEnabledMCPServerConfigsByOrganization :many SELECT * FROM mcp_server_configs WHERE - enabled = TRUE -ORDER BY - display_name ASC; - --- name: GetMCPServerConfigsByIDs :many -SELECT - * -FROM - mcp_server_configs -WHERE - id = ANY(@ids::uuid[]) -ORDER BY - display_name ASC; - --- name: GetMCPServerConfigsByIDsAndOrganizations :many -SELECT - * -FROM - mcp_server_configs -WHERE - id = ANY(@ids::uuid[]) - AND organization_id = ANY(@organization_ids::uuid[]) + organization_id = @organization_id::uuid + AND enabled = TRUE ORDER BY display_name ASC; --- name: GetForcedMCPServerConfigs :many +-- name: GetMCPServerConfigsByOrganizationAndIDs :many SELECT * FROM mcp_server_configs WHERE - enabled = TRUE - AND availability = 'force_on' + organization_id = @organization_id::uuid + AND id = ANY(@ids::uuid[]) ORDER BY display_name ASC; diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 8c3d090a4d84f..42fa4b48f258e 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -1257,46 +1257,46 @@ func (api *API) validateExplicitChatModelConfigAvailable( // @Failure 413 {object} codersdk.Response "Request body exceeds 256 KiB" // @Router /api/experimental/chats [post] // @Description Experimental: this endpoint is subject to change. -// chatMCPServerConfigs returns the requested MCP server configs that exist -// within the chat's organization OR the default organization, in -// display_name order with one row per unique ID (both properties of the -// query). Chat create/update request validation compares the result against -// the raw request, so duplicates and out-of-org IDs are both rejected -// exactly as they were before configs were org-scoped. Disabled configs -// count as existing: the generation path skips them, as it did before -// org-scoping. -// -// TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). -func chatMCPServerConfigs( +func validateChatMCPServerIDs( ctx context.Context, db database.Store, organizationID uuid.UUID, ids []uuid.UUID, -) ([]database.MCPServerConfig, error) { - if len(ids) == 0 { - return []database.MCPServerConfig{}, nil - } - - // The default organization is resolved as chatd: callers may hold a - // custom role without organization:read on it. - //nolint:gocritic // Organization resolution is an internal detail, not a permission the caller must hold. - defaultOrg, err := db.GetDefaultOrganization(dbauthz.AsChatd(ctx)) - if err != nil { - return nil, xerrors.Errorf("get default organization: %w", err) +) (normalized []uuid.UUID, invalid []uuid.UUID, err error) { + unique := make([]uuid.UUID, 0, len(ids)) + seen := make(map[uuid.UUID]struct{}, len(ids)) + for _, id := range ids { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) } - organizationIDs := []uuid.UUID{organizationID} - if !slices.Contains(organizationIDs, defaultOrg.ID) { - organizationIDs = append(organizationIDs, defaultOrg.ID) + if len(unique) == 0 { + return unique, nil, nil } - configs, err := db.GetMCPServerConfigsByIDsAndOrganizations(ctx, database.GetMCPServerConfigsByIDsAndOrganizationsParams{ - IDs: ids, - OrganizationIds: organizationIDs, + configs, err := db.GetMCPServerConfigsByOrganizationAndIDs(ctx, database.GetMCPServerConfigsByOrganizationAndIDsParams{ + OrganizationID: organizationID, + IDs: unique, }) if err != nil { - return nil, xerrors.Errorf("get MCP server configs for organizations: %w", err) + return nil, nil, xerrors.Errorf("get MCP server configs for organization: %w", err) + } + + enabled := make(map[uuid.UUID]struct{}, len(configs)) + for _, config := range configs { + if config.Enabled { + enabled[config.ID] = struct{}{} + } + } + invalid = make([]uuid.UUID, 0, len(unique)-len(enabled)) + for _, id := range unique { + if _, ok := enabled[id]; !ok { + invalid = append(invalid, id) + } } - return configs, nil + return unique, invalid, nil } func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { @@ -1379,38 +1379,25 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { return } - // Validate MCP server IDs exist and belong to the chat's - // organization (falling back to the default organization until - // the org-scoping cutover). Disabled configs are accepted: the - // generation path skips them, as it did before org-scoping. - // TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). - if len(req.MCPServerIDs) > 0 { - //nolint:gocritic // Need to validate MCP server IDs exist and are usable by the chat's organization. - existingConfigs, err := chatMCPServerConfigs(dbauthz.AsSystemRestricted(ctx), api.Database, req.OrganizationID, req.MCPServerIDs) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to validate MCP server IDs.", - Detail: err.Error(), - }) - return - } - if len(existingConfigs) != len(req.MCPServerIDs) { - found := make(map[uuid.UUID]struct{}, len(existingConfigs)) - for _, c := range existingConfigs { - found[c.ID] = struct{}{} - } - var missing []string - for _, id := range req.MCPServerIDs { - if _, ok := found[id]; !ok { - missing = append(missing, id.String()) - } - } - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "One or more MCP server IDs are invalid.", - Detail: fmt.Sprintf("Invalid IDs: %s", strings.Join(missing, ", ")), - }) - return + normalizedMCPServerIDs, invalidMCPServerIDs, err := validateChatMCPServerIDs(ctx, api.Database, req.OrganizationID, req.MCPServerIDs) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to validate MCP server IDs.", + Detail: err.Error(), + }) + return + } + req.MCPServerIDs = normalizedMCPServerIDs + if len(invalidMCPServerIDs) > 0 { + invalid := make([]string, 0, len(invalidMCPServerIDs)) + for _, id := range invalidMCPServerIDs { + invalid = append(invalid, id.String()) } + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "One or more MCP server IDs are invalid or disabled.", + Detail: fmt.Sprintf("Invalid IDs: %s", strings.Join(invalid, ", ")), + }) + return } mcpServerIDs := req.MCPServerIDs @@ -2781,14 +2768,8 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { return } - // Validate MCP server IDs exist and belong to the chat's - // organization (falling back to the default organization until - // the org-scoping cutover). Disabled configs are accepted: the - // generation path skips them, as it did before org-scoping. - // TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). - if req.MCPServerIDs != nil && len(*req.MCPServerIDs) > 0 { - //nolint:gocritic // Need to validate MCP server IDs exist and are usable by the chat's organization. - existingConfigs, err := chatMCPServerConfigs(dbauthz.AsSystemRestricted(ctx), api.Database, chat.OrganizationID, *req.MCPServerIDs) + if req.MCPServerIDs != nil { + normalizedMCPServerIDs, invalidMCPServerIDs, err := validateChatMCPServerIDs(ctx, api.Database, chat.OrganizationID, *req.MCPServerIDs) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to validate MCP server IDs.", @@ -2796,20 +2777,15 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { }) return } - if len(existingConfigs) != len(*req.MCPServerIDs) { - found := make(map[uuid.UUID]struct{}, len(existingConfigs)) - for _, c := range existingConfigs { - found[c.ID] = struct{}{} - } - var missing []string - for _, id := range *req.MCPServerIDs { - if _, ok := found[id]; !ok { - missing = append(missing, id.String()) - } + req.MCPServerIDs = &normalizedMCPServerIDs + if len(invalidMCPServerIDs) > 0 { + invalid := make([]string, 0, len(invalidMCPServerIDs)) + for _, id := range invalidMCPServerIDs { + invalid = append(invalid, id.String()) } httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "One or more MCP server IDs are invalid.", - Detail: fmt.Sprintf("Invalid IDs: %s", strings.Join(missing, ", ")), + Message: "One or more MCP server IDs are invalid or disabled.", + Detail: fmt.Sprintf("Invalid IDs: %s", strings.Join(invalid, ", ")), }) return } diff --git a/coderd/mcp.go b/coderd/mcp.go index d4c0f362245ae..4defbb2a4248a 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -151,19 +151,18 @@ func shouldRefreshOIDCToken(link database.UserLink) (bool, time.Time) { func (api *API) listMCPServerConfigs(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) + organization := httpmw.OrganizationParam(r) - // Admin users can see all MCP server configs (including disabled - // ones) for management purposes. Non-admin users see only enabled - // configs, which is sufficient for using the chat feature. - isAdmin := api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) + // Organization admins can see disabled configs and management fields. + // Other members see enabled configs with management fields redacted. + isAdmin := api.Authorize(r, policy.ActionUpdate, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) var configs []database.MCPServerConfig var err error if isAdmin { - configs, err = api.Database.GetMCPServerConfigManagementList(ctx) + configs, err = api.Database.GetMCPServerConfigsByOrganization(ctx, organization.ID) } else { - //nolint:gocritic // All authenticated users need to read enabled MCP server configs to use the chat feature. - configs, err = api.Database.GetEnabledMCPServerConfigs(dbauthz.AsSystemRestricted(ctx)) + configs, err = api.Database.GetEnabledMCPServerConfigsByOrganization(ctx, organization.ID) } if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -176,7 +175,7 @@ func (api *API) listMCPServerConfigs(rw http.ResponseWriter, r *http.Request) { // Look up the calling user's OAuth2 tokens so we can populate // auth_connected per server. Attempt to refresh expired tokens // so the status is accurate and the token is ready for use. - //nolint:gocritic // Need to check user tokens across all servers. + //nolint:gocritic // Token authorization is handled separately from config RBAC. userTokens, err := api.Database.GetMCPServerUserTokensByUserID(dbauthz.AsSystemRestricted(ctx), apiKey.UserID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -226,7 +225,8 @@ func (api *API) listMCPServerConfigs(rw http.ResponseWriter, r *http.Request) { func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) - if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { + organization := httpmw.OrganizationParam(r) + if !api.Authorize(r, policy.ActionCreate, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) { httpapi.Forbidden(rw) return } @@ -269,23 +269,8 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { return } - // New configs are created in the default organization. - // Callers may hold a custom role with - // deployment_config:update and no organization:read, so - // resolve the organization as chatd while the insert - // itself stays under the caller's context. - //nolint:gocritic // Organization resolution is an internal detail, not a permission the caller must hold. - defaultOrg, orgErr := api.Database.GetDefaultOrganization(dbauthz.AsChatd(ctx)) - if orgErr != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to resolve default organization.", - Detail: orgErr.Error(), - }) - return - } - inserted, err := api.Database.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{ - OrganizationID: defaultOrg.ID, + OrganizationID: organization.ID, DisplayName: strings.TrimSpace(req.DisplayName), Slug: strings.TrimSpace(req.Slug), Description: strings.TrimSpace(req.Description), @@ -464,20 +449,8 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { return } - // New configs are created in the default organization. See the - // auto-discovery branch above for why this resolves as chatd. - //nolint:gocritic // Organization resolution is an internal detail, not a permission the caller must hold. - defaultOrg, orgErr := api.Database.GetDefaultOrganization(dbauthz.AsChatd(ctx)) - if orgErr != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to resolve default organization.", - Detail: orgErr.Error(), - }) - return - } - inserted, err := api.Database.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{ - OrganizationID: defaultOrg.ID, + OrganizationID: organization.ID, DisplayName: strings.TrimSpace(req.DisplayName), Slug: strings.TrimSpace(req.Slug), Description: strings.TrimSpace(req.Description), @@ -547,20 +520,8 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) { return } - isAdmin := api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) - - var config database.MCPServerConfig - var err error - if isAdmin { - config, err = api.Database.GetMCPServerConfigByID(ctx, mcpServerID) - } else { - //nolint:gocritic // All authenticated users can view enabled MCP server configs. - config, err = api.Database.GetMCPServerConfigByID(dbauthz.AsSystemRestricted(ctx), mcpServerID) - if err == nil && !config.Enabled { - httpapi.ResourceNotFound(rw) - return - } - } + //nolint:gocritic // The item must be loaded to derive its organization before authorization. + config, err := api.Database.GetMCPServerConfigByID(dbauthz.AsSystemRestricted(ctx), mcpServerID) if err != nil { if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) @@ -573,6 +534,16 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) { return } + if !api.Authorize(r, policy.ActionRead, config) { + httpapi.ResourceNotFound(rw) + return + } + isAdmin := api.Authorize(r, policy.ActionUpdate, config) + if !isAdmin && !config.Enabled { + httpapi.ResourceNotFound(rw) + return + } + var sdkConfig codersdk.MCPServerConfig if isAdmin { sdkConfig = convertMCPServerConfig(config) @@ -583,26 +554,48 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) { // Populate AuthConnected for the calling user. Attempt to // refresh the token so the status is accurate. if config.AuthType == "oauth2" { - //nolint:gocritic // Need to check user token for this server. - userTokens, err := api.Database.GetMCPServerUserTokensByUserID(dbauthz.AsSystemRestricted(ctx), apiKey.UserID) - if err != nil { + //nolint:gocritic // Token authorization is handled separately from config RBAC. + tok, err := api.Database.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{ + MCPServerConfigID: config.ID, + UserID: apiKey.UserID, + }) + if err == nil { + sdkConfig.AuthConnected = api.refreshMCPUserToken(ctx, config, tok) + } else if !errors.Is(err, sql.ErrNoRows) { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get user tokens.", + Message: "Failed to get user token.", Detail: err.Error(), }) return } - for _, tok := range userTokens { - if tok.MCPServerConfigID == config.ID { - sdkConfig.AuthConnected = api.refreshMCPUserToken(ctx, config, tok) - break - } - } } httpapi.Write(ctx, rw, http.StatusOK, sdkConfig) } +func (api *API) getMCPServerConfigForMutation(rw http.ResponseWriter, r *http.Request, action policy.Action) (database.MCPServerConfig, bool) { + ctx := r.Context() + mcpServerID, ok := parseMCPServerConfigID(rw, r) + if !ok { + return database.MCPServerConfig{}, false + } + //nolint:gocritic // The item must be loaded to derive its organization before authorization. + config, err := api.Database.GetMCPServerConfigByID(dbauthz.AsSystemRestricted(ctx), mcpServerID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) || httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return database.MCPServerConfig{}, false + } + httpapi.InternalServerError(rw, err) + return database.MCPServerConfig{}, false + } + if !api.Authorize(r, action, config) { + httpapi.ResourceNotFound(rw) + return database.MCPServerConfig{}, false + } + return config, true +} + // @Summary Update MCP server config // @x-apidocgen {"skip": true} // EXPERIMENTAL: this endpoint is experimental and is subject to change. @@ -611,12 +604,7 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) { func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) - if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { - httpapi.Forbidden(rw) - return - } - - mcpServerID, ok := parseMCPServerConfigID(rw, r) + existing, ok := api.getMCPServerConfigForMutation(rw, r, policy.ActionUpdate) if !ok { return } @@ -665,11 +653,6 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { var updated database.MCPServerConfig err := api.Database.InTx(func(tx database.Store) error { - existing, err := tx.GetMCPServerConfigByID(ctx, mcpServerID) - if err != nil { - return err - } - displayName := existing.DisplayName if req.DisplayName != nil { displayName = strings.TrimSpace(*req.DisplayName) @@ -857,7 +840,7 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { } } - updated, err = tx.UpdateMCPServerConfig(ctx, database.UpdateMCPServerConfigParams{ + updatedConfig, err := tx.UpdateMCPServerConfig(ctx, database.UpdateMCPServerConfigParams{ DisplayName: displayName, Slug: slug, Description: description, @@ -887,7 +870,11 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { UpdatedBy: apiKey.UserID, ID: existing.ID, }) - return err + if err != nil { + return err + } + updated = updatedConfig + return nil }, nil) if err != nil { switch { @@ -923,29 +910,12 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { // EXPERIMENTAL: this endpoint is experimental and is subject to change. func (api *API) deleteMCPServerConfig(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { - httpapi.Forbidden(rw) - return - } - - mcpServerID, ok := parseMCPServerConfigID(rw, r) + config, ok := api.getMCPServerConfigForMutation(rw, r, policy.ActionDelete) if !ok { return } - if _, err := api.Database.GetMCPServerConfigByID(ctx, mcpServerID); err != nil { - if httpapi.Is404Error(err) { - httpapi.ResourceNotFound(rw) - return - } - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get MCP server config.", - Detail: err.Error(), - }) - return - } - - if err := api.Database.DeleteMCPServerConfigByID(ctx, mcpServerID); err != nil { + if err := api.Database.DeleteMCPServerConfigByID(ctx, config.ID); err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to delete MCP server config.", Detail: err.Error(), @@ -965,25 +935,11 @@ func (api *API) deleteMCPServerConfig(rw http.ResponseWriter, r *http.Request) { func (api *API) mcpServerOAuth2Connect(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - mcpServerID, ok := parseMCPServerConfigID(rw, r) + config, ok := api.getMCPServerConfigForMutation(rw, r, policy.ActionRead) if !ok { return } - //nolint:gocritic // Any authenticated user can initiate OAuth2 for an enabled MCP server. - config, err := api.Database.GetMCPServerConfigByID(dbauthz.AsSystemRestricted(ctx), mcpServerID) - if err != nil { - if httpapi.Is404Error(err) { - httpapi.ResourceNotFound(rw) - return - } - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get MCP server config.", - Detail: err.Error(), - }) - return - } - if !config.Enabled { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "MCP server is not enabled.", @@ -1060,25 +1016,11 @@ func (api *API) mcpServerOAuth2Callback(rw http.ResponseWriter, r *http.Request) ctx := r.Context() apiKey := httpmw.APIKey(r) - mcpServerID, ok := parseMCPServerConfigID(rw, r) + config, ok := api.getMCPServerConfigForMutation(rw, r, policy.ActionRead) if !ok { return } - //nolint:gocritic // Any authenticated user can complete OAuth2 for an enabled MCP server. - config, err := api.Database.GetMCPServerConfigByID(dbauthz.AsSystemRestricted(ctx), mcpServerID) - if err != nil { - if httpapi.Is404Error(err) { - httpapi.ResourceNotFound(rw) - return - } - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get MCP server config.", - Detail: err.Error(), - }) - return - } - if !config.Enabled { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "MCP server is not enabled.", @@ -1199,7 +1141,7 @@ func (api *API) mcpServerOAuth2Callback(rw http.ResponseWriter, r *http.Request) //nolint:gocritic // Users store their own tokens. _, err = api.Database.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{ - MCPServerConfigID: mcpServerID, + MCPServerConfigID: config.ID, UserID: apiKey.UserID, AccessToken: token.AccessToken, AccessTokenKeyID: sql.NullString{}, @@ -1239,39 +1181,29 @@ func (api *API) mcpServerOAuth2Disconnect(rw http.ResponseWriter, r *http.Reques ctx := r.Context() apiKey := httpmw.APIKey(r) - mcpServerID, ok := parseMCPServerConfigID(rw, r) + config, ok := api.getMCPServerConfigForMutation(rw, r, policy.ActionRead) if !ok { return } //nolint:gocritic // Users manage their own tokens. systemCtx := dbauthz.AsSystemRestricted(ctx) - var ( - config database.MCPServerConfig - token database.MCPServerUserToken - ) + var token database.MCPServerUserToken // Serializable isolation keeps the revoked token aligned with the row deleted locally. err := api.Database.InTx(func(tx database.Store) error { dbToken, err := tx.GetMCPServerUserToken(systemCtx, database.GetMCPServerUserTokenParams{ - MCPServerConfigID: mcpServerID, + MCPServerConfigID: config.ID, UserID: apiKey.UserID, }) if err != nil { return err } - // Load the config only after the token is found so callers - // without a token cannot probe which config IDs exist. - dbConfig, err := tx.GetMCPServerConfigByID(systemCtx, mcpServerID) - if err != nil { - return err - } if err := tx.DeleteMCPServerUserToken(systemCtx, database.DeleteMCPServerUserTokenParams{ - MCPServerConfigID: mcpServerID, + MCPServerConfigID: config.ID, UserID: apiKey.UserID, }); err != nil { return err } - config = dbConfig token = dbToken return nil }, &database.TxOptions{Isolation: sql.LevelSerializable}) @@ -1466,11 +1398,12 @@ func parseMCPServerConfigID(rw http.ResponseWriter, r *http.Request) (uuid.UUID, // Admin-only fields (OAuth2 client ID, auth URLs, etc.) are included. func convertMCPServerConfig(config database.MCPServerConfig) codersdk.MCPServerConfig { return codersdk.MCPServerConfig{ - ID: config.ID, - DisplayName: config.DisplayName, - Slug: config.Slug, - Description: config.Description, - IconURL: config.IconURL, + ID: config.ID, + OrganizationID: config.OrganizationID, + DisplayName: config.DisplayName, + Slug: config.Slug, + Description: config.Description, + IconURL: config.IconURL, Transport: config.Transport, URL: config.Url, diff --git a/coderd/mcp_b2_test.go b/coderd/mcp_b2_test.go deleted file mode 100644 index 08314822fd4d2..0000000000000 --- a/coderd/mcp_b2_test.go +++ /dev/null @@ -1,296 +0,0 @@ -package coderd_test - -import ( - "context" - "database/sql" - "testing" - - "github.com/prometheus/client_golang/prometheus" - "github.com/stretchr/testify/require" - - "cdr.dev/slog/v3/sloggers/slogtest" - "github.com/coder/coder/v2/coderd/coderdtest" - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbauthz" - "github.com/coder/coder/v2/coderd/database/dbgen" - "github.com/coder/coder/v2/coderd/database/dbtestutil" - "github.com/coder/coder/v2/coderd/rbac" - "github.com/coder/coder/v2/coderd/rbac/policy" - "github.com/coder/coder/v2/coderd/rbac/rolestore" - "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/testutil" -) - -// TestMCPServerConfigListReadContracts pins the read contracts of the -// authorized MCP server config list while the B1 fallback window is open. -// Exercised through the dbauthz boundary: the org-scoped read grants are -// HTTP-ineffective until the B3 org-scoped routes exist, so the contracts -// live at the database authorization layer. -// -// Org admin and org auditor read only their own org's configs (including -// disabled ones) through the authorized list. A custom site role holding -// only mcp_server_config:read reads across orgs (site scope), and a plain -// org member reads only their own org via the temporary member grant. -// -//nolint:tparallel,paralleltest // Subtests share one seeded database. -func TestMCPServerConfigListReadContracts(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - rawStore, _, rawSQLDB := dbtestutil.NewDBWithSQLDB(t) - adminClient := coderdtest.New(t, &coderdtest.Options{Database: rawStore}) - db := rawStore - firstUser := coderdtest.CreateFirstUser(t, adminClient) - defaultOrgID := firstUser.OrganizationID - - // A second organization with a config that org-scoped readers of the - // default org must not see. - secondOrg := dbgen.Organization(t, db, database.Organization{}) - - // Seed one enabled and one disabled config in the default org (the - // create handler seeds the default org during the window), and one - // enabled config in the second org directly in the DB. - enabledDefault := createMCPServerConfig(t, adminClient, "enabled-default", true) - disabledDefault := createMCPServerConfig(t, adminClient, "disabled-default", false) - otherOrgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - OrganizationID: secondOrg.ID, - Enabled: true, - }) - - authzDB := func() database.Store { - return dbauthz.New(db, rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()), slogtest.Make(t, nil), coderdtest.AccessControlStorePointer()) - } - - // authorizedSlugs returns the slugs the given user sees through the - // authorized list under their full (db-backed) subject. - authorizedSlugs := func(user codersdk.User) []string { - subject := coderdtest.AuthzUserSubjectWithDB(ctx, t, db, user) - cfgs, err := authzDB().GetMCPServerConfigs(dbauthz.As(ctx, subject)) - require.NoError(t, err) - out := make([]string, 0, len(cfgs)) - for _, c := range cfgs { - out = append(out, c.Slug) - } - return out - } - - t.Run("OrgAdminSeesOwnOrgOnly", func(t *testing.T) { - _, orgAdmin := coderdtest.CreateAnotherUser(t, adminClient, defaultOrgID, rbac.ScopedRoleOrgAdmin(defaultOrgID)) - got := authorizedSlugs(orgAdmin) - require.Contains(t, got, enabledDefault.Slug) - require.Contains(t, got, disabledDefault.Slug) - require.NotContains(t, got, otherOrgConfig.Slug) - }) - - t.Run("OrgAuditorSeesOwnOrgOnly", func(t *testing.T) { - _, orgAuditor := coderdtest.CreateAnotherUser(t, adminClient, defaultOrgID, rbac.ScopedRoleOrgAuditor(defaultOrgID)) - got := authorizedSlugs(orgAuditor) - require.Contains(t, got, enabledDefault.Slug) - require.Contains(t, got, disabledDefault.Slug) - require.NotContains(t, got, otherOrgConfig.Slug) - }) - - t.Run("OrgMemberSeesOwnOrgOnly", func(t *testing.T) { - // A plain member of the default org, relying on the temporary - // orgMember read grant. - memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, defaultOrgID) - _ = memberClient - got := authorizedSlugs(member) - require.Contains(t, got, enabledDefault.Slug) - require.Contains(t, got, disabledDefault.Slug) - require.NotContains(t, got, otherOrgConfig.Slug) - }) - - t.Run("CustomSiteReadRoleSeesAcrossOrgs", func(t *testing.T) { - // A site custom role holding only mcp_server_config:read. Custom - // roles persisted through dbauthz cannot carry site permissions, - // so write it directly to the raw database. The user lives in the - // second org so no implicit organization read on the default org - // masks a broken authorization path. - customRole, err := database.New(rawSQLDB).InsertCustomRole(ctx, database.InsertCustomRoleParams{ - Name: "mcp-config-reader", - SitePermissions: []database.CustomRolePermission{ - {ResourceType: rbac.ResourceMCPServerConfig.Type, Action: policy.ActionRead}, - }, - OrgPermissions: []database.CustomRolePermission{}, - UserPermissions: []database.CustomRolePermission{}, - }) - require.NoError(t, err) - user := dbgen.User(t, db, database.User{RBACRoles: []string{customRole.Name}}) - dbgen.OrganizationMember(t, db, database.OrganizationMember{ - UserID: user.ID, - OrganizationID: secondOrg.ID, - }) - - subject := mcpCustomRoleSubject(ctx, t, db, user) - // The custom site role grants read across orgs, so this user sees - // both orgs' configs. - cfgs, err := authzDB().GetMCPServerConfigs(dbauthz.As(ctx, subject)) - require.NoError(t, err) - got := make([]string, 0, len(cfgs)) - for _, c := range cfgs { - got = append(got, c.Slug) - } - require.Contains(t, got, enabledDefault.Slug) - require.Contains(t, got, otherOrgConfig.Slug) - }) -} - -// mcpCustomRoleSubject builds the authorization subject for a dbgen user -// with custom RBAC roles, expanding roles and org membership from the DB. -func mcpCustomRoleSubject(ctx context.Context, t *testing.T, db database.Store, user database.User) rbac.Subject { - t.Helper() - - roles := rbac.RoleIdentifiers{rbac.RoleMember()} - for _, name := range user.RBACRoles { - roles = append(roles, rbac.RoleIdentifier{Name: name}) - } - orgs, err := db.GetOrganizationsByUserID(dbauthz.AsSystemRestricted(ctx), database.GetOrganizationsByUserIDParams{ - UserID: user.ID, - Deleted: sql.NullBool{Valid: true, Bool: false}, - }) - require.NoError(t, err) - for _, org := range orgs { - roles = append(roles, rbac.ScopedRoleOrgMember(org.ID)) - } - rbacRoles, err := rolestore.Expand(dbauthz.AsSystemRestricted(ctx), db, roles) - require.NoError(t, err) - - return rbac.Subject{ - ID: user.ID.String(), - Roles: rbacRoles, - Groups: []string{}, - Scope: rbac.ScopeAll, - }.WithCachedASTValue() -} - -// TestMCPServerConfigDeploymentConfigOnlyRoleWritesThroughWindow proves the -// interim write gate stays on deployment_config while the B1 fallback window -// is open: a site custom role holding only deployment_config read+update can -// create, update, and delete an MCP server config. Swapping the write-side -// dbauthz checks (or the concealed-404 fetch in the update/delete flows) to -// the new resource early would contract this write set, a behavior -// regression (invariant 1). -func TestMCPServerConfigDeploymentConfigOnlyRoleWritesThroughWindow(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - rawStore, _, rawSQLDB := dbtestutil.NewDBWithSQLDB(t) - client := coderdtest.New(t, &coderdtest.Options{Database: rawStore}) - _ = coderdtest.CreateFirstUser(t, client) - db := rawStore - - // The user belongs to a non-default org and holds only deployment_config - // read+update via a site custom role written directly to the raw - // database (dbauthz cannot persist site permissions on custom roles). - secondOrg := dbgen.Organization(t, db, database.Organization{}) - customRole, err := database.New(rawSQLDB).InsertCustomRole(ctx, database.InsertCustomRoleParams{ - Name: "deployment-config-manager", - SitePermissions: []database.CustomRolePermission{ - {ResourceType: rbac.ResourceDeploymentConfig.Type, Action: policy.ActionRead}, - {ResourceType: rbac.ResourceDeploymentConfig.Type, Action: policy.ActionUpdate}, - }, - OrgPermissions: []database.CustomRolePermission{}, - UserPermissions: []database.CustomRolePermission{}, - }) - require.NoError(t, err) - user := dbgen.User(t, db, database.User{RBACRoles: []string{customRole.Name}}) - dbgen.OrganizationMember(t, db, database.OrganizationMember{ - UserID: user.ID, - OrganizationID: secondOrg.ID, - }) - _, token := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID}) - userClient := codersdk.New(client.URL) - userClient.SetSessionToken(token) - - created, err := userClient.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ - DisplayName: "Window Server", - Slug: "window-server", - Transport: "streamable_http", - URL: "https://mcp.example.com/window", - AuthType: "none", - Availability: "default_on", - Enabled: true, - ToolAllowList: []string{}, - ToolDenyList: []string{}, - }) - require.NoError(t, err) - - newName := "Window Server Renamed" - _, err = userClient.UpdateMCPServerConfig(ctx, created.ID, codersdk.UpdateMCPServerConfigRequest{ - DisplayName: &newName, - }) - require.NoError(t, err) - - require.NoError(t, userClient.DeleteMCPServerConfig(ctx, created.ID)) -} - -// TestMCPServerConfigManagementListParentEquivalence proves the interim -// privileged management list keeps the parent's read contract: any principal -// the deployment_config read gate admits sees the full unfiltered row set -// (default-org enabled AND disabled, and any other org's rows), exactly as on -// the parent. The authorized-list swap must not narrow this path during the -// window. The subject holds ONLY deployment_config read via a site custom -// role and is a member of a non-default org, so the implicit default-org -// member read cannot mask a broken authorization path. -func TestMCPServerConfigManagementListParentEquivalence(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - rawStore, _, rawSQLDB := dbtestutil.NewDBWithSQLDB(t) - client := coderdtest.New(t, &coderdtest.Options{Database: rawStore}) - _ = coderdtest.CreateFirstUser(t, client) - db := rawStore - - // Seed default-org enabled + disabled configs (via the admin HTTP path) - // and a config in the subject's own (second) org. - enabledDefault := createMCPServerConfig(t, client, "enabled-default", true) - disabledDefault := createMCPServerConfig(t, client, "disabled-default", false) - secondOrg := dbgen.Organization(t, db, database.Organization{}) - ownOrgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - OrganizationID: secondOrg.ID, - Enabled: true, - }) - - // A site custom role holding ONLY deployment_config read, member of the - // non-default second org. - customRole, err := database.New(rawSQLDB).InsertCustomRole(ctx, database.InsertCustomRoleParams{ - Name: "deployment-config-reader", - SitePermissions: []database.CustomRolePermission{ - {ResourceType: rbac.ResourceDeploymentConfig.Type, Action: policy.ActionRead}, - }, - OrgPermissions: []database.CustomRolePermission{}, - UserPermissions: []database.CustomRolePermission{}, - }) - require.NoError(t, err) - user := dbgen.User(t, db, database.User{RBACRoles: []string{customRole.Name}}) - dbgen.OrganizationMember(t, db, database.OrganizationMember{ - UserID: user.ID, - OrganizationID: secondOrg.ID, - }) - _, token := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID}) - userClient := codersdk.New(client.URL) - userClient.SetSessionToken(token) - - cfgs, err := userClient.MCPServerConfigs(ctx) - require.NoError(t, err) - got := make(map[string]bool, len(cfgs)) - for _, c := range cfgs { - got[c.Slug] = true - } - require.True(t, got[enabledDefault.Slug], "privileged list missing default-org enabled config") - require.True(t, got[disabledDefault.Slug], "privileged list missing default-org disabled config") - require.True(t, got[ownOrgConfig.Slug], "privileged list missing subject-org config") - - // The privileged view carries connection metadata, not just redaction. - var enabled codersdk.MCPServerConfig - found := false - for _, c := range cfgs { - if c.Slug == enabledDefault.Slug { - enabled = c - found = true - } - } - require.True(t, found) - require.Equal(t, "https://mcp.example.com/"+enabledDefault.Slug, enabled.URL) -} diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 460e1ec89ab46..8b9f19aafc053 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -23,10 +23,6 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" - "github.com/coder/coder/v2/coderd/database/dbgen" - "github.com/coder/coder/v2/coderd/database/dbtestutil" - "github.com/coder/coder/v2/coderd/rbac" - "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -52,11 +48,11 @@ func newMCPClient(t testing.TB) *codersdk.Client { // createMCPServerConfig is a helper that creates a minimal enabled // MCP server config with auth_type=none. -func createMCPServerConfig(t testing.TB, client *codersdk.Client, slug string, enabled bool) codersdk.MCPServerConfig { +func createMCPServerConfig(t testing.TB, client *codersdk.Client, organizationID uuid.UUID, slug string, enabled bool) codersdk.MCPServerConfig { t.Helper() ctx := testutil.Context(t, testutil.WaitLong) - config, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + config, err := client.CreateMCPServerConfig(ctx, organizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Test Server " + slug, Slug: slug, Description: "A test MCP server.", @@ -73,64 +69,24 @@ func createMCPServerConfig(t testing.TB, client *codersdk.Client, slug string, e return config } -// TestCreateMCPServerConfigCustomRole verifies that a caller holding a -// persisted site custom role with deployment_config read+update but no -// organization:read can still create configs: the default-organization -// resolution behind the gate must not require permissions the gate itself -// does not imply. -func TestCreateMCPServerConfigCustomRole(t *testing.T) { +func TestMCPServerConfigLegacyRoutesRemoved(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - rawStore, _, rawSQLDB := dbtestutil.NewDBWithSQLDB(t) - client := coderdtest.New(t, &coderdtest.Options{Database: rawStore}) + client := newMCPClient(t) firstUser := coderdtest.CreateFirstUser(t, client) - db := rawStore - - // The user belongs to a non-default org, so they hold no implicit - // organization read on the default org. Their only permissions come - // from a persisted site custom role granting deployment_config - // read+update and nothing else. Custom roles persisted through - // dbauthz cannot carry site permissions, so write directly to the - // raw database. - secondOrg := dbgen.Organization(t, db, database.Organization{}) - customRole, err := database.New(rawSQLDB).InsertCustomRole(ctx, database.InsertCustomRoleParams{ - Name: "mcp-config-manager", - SitePermissions: []database.CustomRolePermission{ - {ResourceType: rbac.ResourceDeploymentConfig.Type, Action: policy.ActionRead}, - {ResourceType: rbac.ResourceDeploymentConfig.Type, Action: policy.ActionUpdate}, - }, - OrgPermissions: []database.CustomRolePermission{}, - UserPermissions: []database.CustomRolePermission{}, - }) - require.NoError(t, err) - user := dbgen.User(t, db, database.User{RBACRoles: []string{customRole.Name}}) - dbgen.OrganizationMember(t, db, database.OrganizationMember{ - UserID: user.ID, - OrganizationID: secondOrg.ID, - }) - _, token := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID}) - userClient := codersdk.New(client.URL) - userClient.SetSessionToken(token) - - created, err := userClient.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ - DisplayName: "Custom Role Server", - Slug: "custom-role-server", - Transport: "streamable_http", - URL: "https://mcp.example.com/custom-role", - AuthType: "none", - Availability: "default_on", - Enabled: true, - ToolAllowList: []string{}, - ToolDenyList: []string{}, - }) - require.NoError(t, err) - require.NotEqual(t, uuid.Nil, created.ID) - - // The config lands in the default organization. - stored, err := db.GetMCPServerConfigByID(dbauthz.AsSystemRestricted(ctx), created.ID) - require.NoError(t, err) - require.Equal(t, firstUser.OrganizationID, stored.OrganizationID) + config := createMCPServerConfig(t, client, firstUser.OrganizationID, "legacy-route-test", true) + + for _, path := range []string{ + "/api/experimental/mcp/servers", + "/api/experimental/mcp/servers/" + config.ID.String(), + "/api/experimental/mcp/servers/" + config.ID.String() + "/oauth2/connect", + } { + res, err := client.Request(ctx, http.MethodGet, path, nil) + require.NoError(t, err) + res.Body.Close() + require.Equal(t, http.StatusNotFound, res.StatusCode, path) + } } func TestMCPServerConfigsCRUD(t *testing.T) { @@ -138,11 +94,11 @@ func TestMCPServerConfigsCRUD(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) // Create a config with all fields populated including OAuth2 // secrets so we can verify they are not leaked. - created, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "My MCP Server", Slug: "my-mcp-server", Description: "Integration test server.", @@ -162,6 +118,7 @@ func TestMCPServerConfigsCRUD(t *testing.T) { }) require.NoError(t, err) require.NotEqual(t, uuid.Nil, created.ID) + require.Equal(t, firstUser.OrganizationID, created.OrganizationID) require.Equal(t, "My MCP Server", created.DisplayName) require.Equal(t, "my-mcp-server", created.Slug) require.Equal(t, "Integration test server.", created.Description) @@ -178,7 +135,7 @@ func TestMCPServerConfigsCRUD(t *testing.T) { require.True(t, created.HasOAuth2Secret) // Verify the config appears in the list and direct get responses. - configs, err := client.MCPServerConfigs(ctx) + configs, err := client.MCPServerConfigs(ctx, firstUser.OrganizationID) require.NoError(t, err) require.Len(t, configs, 1) require.Equal(t, created.ID, configs[0].ID) @@ -214,7 +171,7 @@ func TestMCPServerConfigsCRUD(t *testing.T) { require.Equal(t, "oauth2", updated.AuthType) // Verify the update took effect through the list and direct get. - configs, err = client.MCPServerConfigs(ctx) + configs, err = client.MCPServerConfigs(ctx, firstUser.OrganizationID) require.NoError(t, err) require.Len(t, configs, 1) require.Equal(t, "Renamed Server", configs[0].DisplayName) @@ -232,7 +189,7 @@ func TestMCPServerConfigsCRUD(t *testing.T) { require.NoError(t, err) // Verify it's gone. - configs, err = client.MCPServerConfigs(ctx) + configs, err = client.MCPServerConfigs(ctx, firstUser.OrganizationID) require.NoError(t, err) require.Empty(t, configs) } @@ -246,16 +203,16 @@ func TestMCPServerConfigsNonAdmin(t *testing.T) { memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) // Admin creates two configs: one enabled, one disabled. - _ = createMCPServerConfig(t, adminClient, "enabled-server", true) - _ = createMCPServerConfig(t, adminClient, "disabled-server", false) + _ = createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "enabled-server", true) + _ = createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "disabled-server", false) // Admin sees both. - adminConfigs, err := adminClient.MCPServerConfigs(ctx) + adminConfigs, err := adminClient.MCPServerConfigs(ctx, firstUser.OrganizationID) require.NoError(t, err) require.Len(t, adminConfigs, 2) // Regular user sees only the enabled one. - memberConfigs, err := memberClient.MCPServerConfigs(ctx) + memberConfigs, err := memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID) require.NoError(t, err) require.Len(t, memberConfigs, 1) require.Equal(t, "enabled-server", memberConfigs[0].Slug) @@ -275,7 +232,7 @@ func TestMCPServerConfigsSecretsNeverLeaked(t *testing.T) { memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) // Create a config with ALL secret fields populated. - created, err := adminClient.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Secrets Test", Slug: "secrets-test", Transport: "streamable_http", @@ -324,7 +281,7 @@ func TestMCPServerConfigsSecretsNeverLeaked(t *testing.T) { require.True(t, created.HasCustomHeaders, "HasCustomHeaders should be true") // Admin list endpoint. - adminConfigs, err := adminClient.MCPServerConfigs(ctx) + adminConfigs, err := adminClient.MCPServerConfigs(ctx, firstUser.OrganizationID) require.NoError(t, err) require.NotEmpty(t, adminConfigs) for _, cfg := range adminConfigs { @@ -337,7 +294,7 @@ func TestMCPServerConfigsSecretsNeverLeaked(t *testing.T) { assertNoSecrets(t, "admin get-by-id", adminSingle) // Non-admin list endpoint. - memberConfigs, err := memberClient.MCPServerConfigs(ctx) + memberConfigs, err := memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID) require.NoError(t, err) require.NotEmpty(t, memberConfigs) for _, cfg := range memberConfigs { @@ -375,7 +332,7 @@ func TestMCPServerConfigsAuthConnected(t *testing.T) { memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) // Create an oauth2 server config (enabled). - created, err := adminClient.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "OAuth Server", Slug: "oauth-server", Transport: "streamable_http", @@ -393,7 +350,7 @@ func TestMCPServerConfigsAuthConnected(t *testing.T) { // Regular user lists configs — auth_connected should be false // because no token has been stored. - memberConfigs, err := memberClient.MCPServerConfigs(ctx) + memberConfigs, err := memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID) require.NoError(t, err) require.Len(t, memberConfigs, 1) require.Equal(t, created.ID, memberConfigs[0].ID) @@ -401,12 +358,12 @@ func TestMCPServerConfigsAuthConnected(t *testing.T) { // Also create a non-oauth server. It should report // auth_connected=true because no auth is needed. - _ = createMCPServerConfig(t, adminClient, "no-auth-server", true) + _ = createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "no-auth-server", true) // And a user_oidc server. user_oidc never requires a per-user // connect step, so auth_connected is always true regardless of // whether the calling user has an OIDC link. - _, err = adminClient.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + _, err = adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "User OIDC Server", Slug: "user-oidc-server", Transport: "streamable_http", @@ -419,7 +376,7 @@ func TestMCPServerConfigsAuthConnected(t *testing.T) { }) require.NoError(t, err) - memberConfigs, err = memberClient.MCPServerConfigs(ctx) + memberConfigs, err = memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID) require.NoError(t, err) require.Len(t, memberConfigs, 3) for _, cfg := range memberConfigs { @@ -437,12 +394,12 @@ func TestMCPServerConfigsUserOIDCClearsFields(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) // Start with an oauth2 config that has a client secret, then // switch the auth_type to user_oidc and verify all auth-specific // fields are cleared. - created, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Switch Server", Slug: "switch-server", Transport: "streamable_http", @@ -488,7 +445,7 @@ func TestMCPServerConfigsUserOIDCClearsFields(t *testing.T) { require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) - _, err = client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + _, err = client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Plaintext Revoke", Slug: "plaintext-revoke", Transport: "streamable_http", @@ -544,9 +501,9 @@ func TestMCPServerConfigsUserOIDCDirect(t *testing.T) { // while no auth-specific fields are persisted on the row. ctx := testutil.Context(t, testutil.WaitLong) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) - created, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "User OIDC Direct", Slug: "user-oidc-direct", Transport: "streamable_http", @@ -568,7 +525,7 @@ func TestMCPServerConfigsAvailability(t *testing.T) { t.Parallel() client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) validValues := []string{"force_on", "default_on", "default_off"} for _, av := range validValues { @@ -576,7 +533,7 @@ func TestMCPServerConfigsAvailability(t *testing.T) { t.Run(av, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - created, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Server " + av, Slug: "server-" + av, Transport: "streamable_http", @@ -595,7 +552,7 @@ func TestMCPServerConfigsAvailability(t *testing.T) { t.Run("InvalidAvailability", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - _, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + _, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Bad Availability", Slug: "bad-avail", Transport: "streamable_http", @@ -618,9 +575,9 @@ func TestMCPServerConfigsUniqueSlug(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) - _, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + _, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "First", Slug: "test-server", Transport: "streamable_http", @@ -634,7 +591,7 @@ func TestMCPServerConfigsUniqueSlug(t *testing.T) { require.NoError(t, err) // Attempt to create another config with the same slug. - _, err = client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + _, err = client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Second", Slug: "test-server", Transport: "streamable_http", @@ -666,7 +623,7 @@ func TestMCPServerConfigsOAuth2Disconnect(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, adminClient) memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) - created, err := adminClient.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "OAuth Disconnect " + slug, Slug: slug, Transport: "streamable_http", @@ -726,45 +683,6 @@ func TestMCPServerConfigsOAuth2Disconnect(t *testing.T) { require.Empty(t, resp.TokenRevocationError) }) - t.Run("DoesNotRevealHiddenConfigs", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t) - adminClient, _ := coderdtest.NewWithDatabase(t, &coderdtest.Options{ - DeploymentValues: mcpDeploymentValues(t), - ChatProviderAPIKeys: &providerKeys, - }) - firstUser := coderdtest.CreateFirstUser(t, adminClient) - memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) - - created, err := adminClient.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ - DisplayName: "OAuth Disconnect Hidden", - Slug: "disc-hidden", - Transport: "streamable_http", - URL: "https://mcp.example.com/disc-hidden", - AuthType: "oauth2", - OAuth2ClientID: "cid", - OAuth2AuthURL: "https://auth.example.com/authorize", - OAuth2TokenURL: "https://auth.example.com/token", - Availability: "default_on", - Enabled: false, - ToolAllowList: []string{}, - ToolDenyList: []string{}, - }) - require.NoError(t, err) - - // Disconnecting a disabled config the member cannot see must be - // indistinguishable from disconnecting a nonexistent config ID. - hiddenResp, err := memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, created.ID) - require.NoError(t, err) - missingResp, err := memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, uuid.New()) - require.NoError(t, err) - require.Equal(t, missingResp, hiddenResp) - require.False(t, hiddenResp.TokenRevoked) - require.Empty(t, hiddenResp.TokenRevocationError) - }) - t.Run("RevokesAtProvider", func(t *testing.T) { t.Parallel() @@ -823,7 +741,7 @@ func TestMCPServerConfigsOAuth2Disconnect(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, adminClient) memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) - created, err := adminClient.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "OAuth Disconnect Refresh Race", Slug: "disc-refresh-race", Transport: "streamable_http", @@ -856,7 +774,7 @@ func TestMCPServerConfigsOAuth2Disconnect(t *testing.T) { } result := make(chan configResult, 1) go func() { - configs, listErr := memberClient.MCPServerConfigs(ctx) + configs, listErr := memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID) result <- configResult{configs: configs, err: listErr} }() @@ -932,7 +850,7 @@ func TestMCPServerConfigsOAuth2Disconnect(t *testing.T) { memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) otherClient, other := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) - created, err := adminClient.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "OAuth Disconnect Isolation", Slug: "disc-isolation", Transport: "streamable_http", @@ -962,7 +880,7 @@ func TestMCPServerConfigsOAuth2Disconnect(t *testing.T) { requireAuthConnected := func(client *codersdk.Client, want bool) { t.Helper() - configs, err := client.MCPServerConfigs(ctx) + configs, err := client.MCPServerConfigs(ctx, firstUser.OrganizationID) require.NoError(t, err) require.Len(t, configs, 1) require.Equal(t, want, configs[0].AuthConnected) @@ -1040,11 +958,11 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { t.Cleanup(mcpServer.Close) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) // Create config with auth_type=oauth2 but no OAuth2 fields — // the server should auto-discover them. - created, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Auto-Discovery Server", Slug: "auto-discovery", Transport: "streamable_http", @@ -1064,7 +982,7 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { require.Equal(t, "read write", created.OAuth2Scopes) // An explicit revocation URL wins over the discovered one. - overridden, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + overridden, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Auto-Discovery Override", Slug: "auto-discovery-override", Transport: "streamable_http", @@ -1175,9 +1093,9 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { t.Cleanup(mcpServer.Close) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) - created, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Priority Test", Slug: "priority-test", Transport: "streamable_http", @@ -1251,9 +1169,9 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { t.Cleanup(mcpServer.Close) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) - created, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Root Fallback Server", Slug: "root-fallback", Transport: "streamable_http", @@ -1329,9 +1247,9 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { t.Cleanup(mcpServer.Close) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) - created, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Path-Aware Auth", Slug: "path-aware-auth", Transport: "streamable_http", @@ -1425,11 +1343,11 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { t.Cleanup(mcpServer.Close) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) // Create config with auth_type=oauth2 but no OAuth2 fields to // trigger auto-discovery and dynamic client registration. - created, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Redirect URI Test", Slug: "redirect-uri-test", Transport: "streamable_http", @@ -1467,7 +1385,7 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { // Sanity-check the full path structure. require.Contains(t, redirectURI, - "/api/experimental/mcp/servers/"+created.ID.String()+"/oauth2/callback", + "/api/experimental/mcp-servers/"+created.ID.String()+"/oauth2/callback", "redirect URI should have the expected callback path") // Double-check that the ID segment is a valid UUID (not some @@ -1491,10 +1409,10 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) // Provide client_id but omit auth_url and token_url. - _, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + _, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Partial Fields", Slug: "partial-oauth2", Transport: "streamable_http", @@ -1527,9 +1445,9 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { t.Cleanup(mcpServer.Close) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) - _, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + _, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Will Fail", Slug: "discovery-fail", Transport: "streamable_http", @@ -1552,10 +1470,10 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) // Providing all three OAuth2 fields bypasses discovery entirely. - created, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Manual Config", Slug: "manual-oauth2", Transport: "streamable_http", @@ -1660,7 +1578,7 @@ func TestMCPServerOAuth2PKCE(t *testing.T) { memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) // Create an OAuth2 MCP server config. - created, err := adminClient.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "PKCE Test", Slug: "pkce-test", Transport: "streamable_http", @@ -1683,7 +1601,7 @@ func TestMCPServerOAuth2PKCE(t *testing.T) { } connectURL, err := memberClient.URL.Parse( - "/api/experimental/mcp/servers/" + created.ID.String() + "/oauth2/connect", + "/api/experimental/mcp-servers/" + created.ID.String() + "/oauth2/connect", ) require.NoError(t, err) @@ -1757,7 +1675,7 @@ func TestMCPServerOAuth2PKCE(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, adminClient) memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) - created, err := adminClient.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "PKCE Callback Test", Slug: "pkce-callback", Transport: "streamable_http", @@ -1782,7 +1700,7 @@ func TestMCPServerOAuth2PKCE(t *testing.T) { verifier := "test-verifier-value-that-is-at-least-43-chars-long-for-pkce-spec" callbackURL, err := memberClient.URL.Parse( - "/api/experimental/mcp/servers/" + created.ID.String() + "/oauth2/callback", + "/api/experimental/mcp-servers/" + created.ID.String() + "/oauth2/callback", ) require.NoError(t, err) q := callbackURL.Query() @@ -1854,7 +1772,7 @@ func TestMCPServerOAuth2PKCE(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, adminClient) memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) - created, err := adminClient.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "No PKCE Callback", Slug: "no-pkce-callback", Transport: "streamable_http", @@ -1878,7 +1796,7 @@ func TestMCPServerOAuth2PKCE(t *testing.T) { // backwards compatibility with providers that don't use PKCE. state := "test-state-no-pkce" callbackURL, err := memberClient.URL.Parse( - "/api/experimental/mcp/servers/" + created.ID.String() + "/oauth2/callback", + "/api/experimental/mcp-servers/" + created.ID.String() + "/oauth2/callback", ) require.NoError(t, err) q := callbackURL.Query() @@ -1920,8 +1838,8 @@ func TestChatWithMCPServerIDs(t *testing.T) { _ = createChatModelConfigForMCP(t, expClient) // Create enabled MCP server configs. - mcpConfigA := createMCPServerConfig(t, client, "chat-mcp-server-a", true) - mcpConfigB := createMCPServerConfig(t, client, "chat-mcp-server-b", true) + mcpConfigA := createMCPServerConfig(t, client, firstUser.OrganizationID, "chat-mcp-server-a", true) + mcpConfigB := createMCPServerConfig(t, client, firstUser.OrganizationID, "chat-mcp-server-b", true) // Create a chat referencing the MCP servers. chat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -2023,9 +1941,9 @@ func TestMCPOAuth2DiscoveryEdgeCases(t *testing.T) { t.Cleanup(mcpServer.Close) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) - created, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Empty Auth Servers Fallback", Slug: "empty-as-fallback", Transport: "streamable_http", @@ -2066,9 +1984,9 @@ func TestMCPOAuth2DiscoveryEdgeCases(t *testing.T) { t.Cleanup(mcpServer.Close) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) - _, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + _, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Both Empty", Slug: "both-empty-as", Transport: "streamable_http", @@ -2142,9 +2060,9 @@ func TestMCPOAuth2DiscoveryEdgeCases(t *testing.T) { t.Cleanup(mcpServer.Close) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) - created, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Malformed JSON Fallback", Slug: "malformed-json", Transport: "streamable_http", @@ -2225,9 +2143,9 @@ func TestMCPOAuth2DiscoveryEdgeCases(t *testing.T) { t.Cleanup(mcpServer.Close) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) - created, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Missing Endpoints Fallback", Slug: "missing-endpoints", Transport: "streamable_http", @@ -2301,9 +2219,9 @@ func TestMCPOAuth2DiscoveryEdgeCases(t *testing.T) { t.Cleanup(mcpServer.Close) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) - created, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "OIDC Fallback", Slug: "oidc-fallback", Transport: "streamable_http", @@ -2373,9 +2291,9 @@ func TestMCPOAuth2DiscoveryEdgeCases(t *testing.T) { t.Cleanup(mcpServer.Close) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) - _, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + _, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Missing Client ID", Slug: "missing-client-id", Transport: "streamable_http", @@ -2447,11 +2365,11 @@ func TestMCPOAuth2DiscoveryEdgeCases(t *testing.T) { t.Cleanup(mcpServer.Close) client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) // URL has a trailing slash, matching the GitHub Copilot URL // pattern: https://api.githubcopilot.com/mcp/ - created, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Trailing Slash", Slug: "trailing-slash", Transport: "streamable_http", @@ -2489,7 +2407,7 @@ func TestMCPServerConfigsRevokedGrant(t *testing.T) { })) t.Cleanup(tokenSrv.Close) - created, err := adminClient.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Revoked Server", Slug: "revoked-server", Transport: "streamable_http", @@ -2521,7 +2439,7 @@ func TestMCPServerConfigsRevokedGrant(t *testing.T) { // First list: the refresh fails permanently, so the server is // reported as not connected and the failure is persisted. - configs, err := memberClient.MCPServerConfigs(ctx) + configs, err := memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID) require.NoError(t, err) require.Len(t, configs, 1) require.False(t, configs[0].AuthConnected) @@ -2543,7 +2461,7 @@ func TestMCPServerConfigsRevokedGrant(t *testing.T) { // Second list: the cached failure short-circuits, so the provider // is not called again. - configs, err = memberClient.MCPServerConfigs(ctx) + configs, err = memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID) require.NoError(t, err) require.Len(t, configs, 1) require.False(t, configs[0].AuthConnected) @@ -2584,7 +2502,7 @@ func TestMCPServerConfigsRevokedGrant(t *testing.T) { }) require.NoError(t, err) - configs, err = memberClient.MCPServerConfigs(ctx) + configs, err = memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID) require.NoError(t, err) require.Len(t, configs, 1) require.True(t, configs[0].AuthConnected) @@ -2609,7 +2527,7 @@ func TestMCPServerConfigsTransientRefreshFailure(t *testing.T) { })) t.Cleanup(tokenSrv.Close) - created, err := adminClient.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Flaky Server", Slug: "flaky-server", Transport: "streamable_http", @@ -2636,7 +2554,7 @@ func TestMCPServerConfigsTransientRefreshFailure(t *testing.T) { }) require.NoError(t, err) - configs, err := memberClient.MCPServerConfigs(ctx) + configs, err := memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID) require.NoError(t, err) require.Len(t, configs, 1) require.False(t, configs[0].AuthConnected) diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 11865529fb139..033f77d876f27 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -405,7 +405,7 @@ func TestPlanModeSubagentChatExcludesAskUserQuestion(t *testing.T) { mcpTS := httptest.NewServer(testMCPHTTPHandler(mcpSrv)) t.Cleanup(mcpTS.Close) - mcpConfig, err := client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + mcpConfig, err := client.CreateMCPServerConfig(ctx, user.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Plan Root MCP", Slug: "plan-root-mcp", Transport: "streamable_http", diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index 27db8aa856171..6c7d750adbfb4 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -917,15 +917,6 @@ func latestAssistantText(messages []database.ChatMessage) string { // organization and are enabled. The enabled filter is applied here in Go, // mirroring the MCP client's connect-time skip of disabled configs // (mcpclient.go), so the fetch shape matches pre-org-scoping behavior. -// -// TODO(mafredri): remove after CODAGT-711 B3 (org-scoping cutover). Until -// the cutover, configs fetched for a chat are valid when they belong to the -// chat's organization OR to the default organization. All existing configs -// were backfilled to the default organization in migration 000561 and every -// create path still assigns it, so a strict chat-org check would detach MCP -// servers from chats in every other organization. The cutover fans configs -// out to every organization and switches this to a strict -// chat-organization-only lookup. func enabledMCPServerConfigsForChatOrg( ctx context.Context, db database.Store, @@ -936,50 +927,19 @@ func enabledMCPServerConfigsForChatOrg( return []database.MCPServerConfig{}, nil } - organizationIDs, err := eligibleMCPServerConfigOrganizations(ctx, db, organizationID) - if err != nil { - return nil, err - } - configs, err := db.GetMCPServerConfigsByIDsAndOrganizations(ctx, database.GetMCPServerConfigsByIDsAndOrganizationsParams{ - IDs: ids, - OrganizationIds: organizationIDs, + configs, err := db.GetMCPServerConfigsByOrganizationAndIDs(ctx, database.GetMCPServerConfigsByOrganizationAndIDsParams{ + OrganizationID: organizationID, + IDs: ids, }) if err != nil { - return nil, xerrors.Errorf("get MCP server configs for organizations: %w", err) + return nil, xerrors.Errorf("get MCP server configs for organization: %w", err) } enabled := make([]database.MCPServerConfig, 0, len(configs)) for _, cfg := range configs { - if !cfg.Enabled { - continue + if cfg.Enabled { + enabled = append(enabled, cfg) } - enabled = append(enabled, cfg) } return enabled, nil } - -// eligibleMCPServerConfigOrganizations returns the chat's organization plus -// the default organization (deduplicated), the pair every chat MCP config -// lookup accepts during the fallback window. -// -// The default organization lookup is a second failure surface that did not -// exist before org-scoping: when it fails transiently, callers that -// log-and-continue (generation preparation) strip all MCP tools for the -// turn rather than just the config that would have failed. Window-limited; -// the B3 strict scoping removes it. -func eligibleMCPServerConfigOrganizations( - ctx context.Context, - db database.Store, - organizationID uuid.UUID, -) ([]uuid.UUID, error) { - defaultOrg, err := db.GetDefaultOrganization(ctx) - if err != nil { - return nil, xerrors.Errorf("get default organization: %w", err) - } - - organizationIDs := []uuid.UUID{organizationID} - if !slices.Contains(organizationIDs, defaultOrg.ID) { - organizationIDs = append(organizationIDs, defaultOrg.ID) - } - return organizationIDs, nil -} diff --git a/codersdk/mcp.go b/codersdk/mcp.go index ed68bba704dca..7f7596fbeddbd 100644 --- a/codersdk/mcp.go +++ b/codersdk/mcp.go @@ -13,7 +13,7 @@ import ( // start the OAuth2 flow for an MCP server. The frontend opens this // in a new window/popup. func (c *Client) MCPServerOAuth2ConnectURL(id uuid.UUID) string { - return fmt.Sprintf("%s/api/experimental/mcp/servers/%s/oauth2/connect", c.URL.String(), id) + return fmt.Sprintf("%s/api/experimental/mcp-servers/%s/oauth2/connect", c.URL.String(), id) } // MCPServerOAuth2DisconnectResponse reports whether the removed token @@ -34,7 +34,7 @@ func (c *Client) MCPServerOAuth2Disconnect(ctx context.Context, id uuid.UUID) er // 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/experimental/mcp/servers/%s/oauth2/disconnect", id), nil) + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/mcp-servers/%s/oauth2/disconnect", id), nil) if err != nil { return MCPServerOAuth2DisconnectResponse{}, err } @@ -52,11 +52,12 @@ func (c *Client) MCPServerOAuth2DisconnectWithResponse(ctx context.Context, id u // MCPServerConfig represents an admin-configured MCP server. type MCPServerConfig struct { - ID uuid.UUID `json:"id" format:"uuid"` - DisplayName string `json:"display_name"` - Slug string `json:"slug"` - Description string `json:"description"` - IconURL string `json:"icon_url"` + 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"` @@ -173,8 +174,8 @@ type UpdateMCPServerConfigRequest struct { ForwardCoderHeaders *bool `json:"forward_coder_headers,omitempty"` } -func (c *Client) MCPServerConfigs(ctx context.Context) ([]MCPServerConfig, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/experimental/mcp/servers", nil) +func (c *Client) MCPServerConfigs(ctx context.Context, organizationID uuid.UUID) ([]MCPServerConfig, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/mcp-servers", organizationID), nil) if err != nil { return nil, err } @@ -187,7 +188,7 @@ func (c *Client) MCPServerConfigs(ctx context.Context) ([]MCPServerConfig, error } func (c *Client) MCPServerConfigByID(ctx context.Context, id uuid.UUID) (MCPServerConfig, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/mcp/servers/%s", id), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/mcp-servers/%s", id), nil) if err != nil { return MCPServerConfig{}, err } @@ -199,8 +200,8 @@ func (c *Client) MCPServerConfigByID(ctx context.Context, id uuid.UUID) (MCPServ return config, ReadBodyAsJSON(res, &config) } -func (c *Client) CreateMCPServerConfig(ctx context.Context, req CreateMCPServerConfigRequest) (MCPServerConfig, error) { - res, err := c.Request(ctx, http.MethodPost, "/api/experimental/mcp/servers", req) +func (c *Client) CreateMCPServerConfig(ctx context.Context, organizationID uuid.UUID, req CreateMCPServerConfigRequest) (MCPServerConfig, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/organizations/%s/mcp-servers", organizationID), req) if err != nil { return MCPServerConfig{}, err } @@ -213,7 +214,7 @@ func (c *Client) CreateMCPServerConfig(ctx context.Context, req CreateMCPServerC } func (c *Client) UpdateMCPServerConfig(ctx context.Context, id uuid.UUID, req UpdateMCPServerConfigRequest) (MCPServerConfig, error) { - res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/mcp/servers/%s", id), req) + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/mcp-servers/%s", id), req) if err != nil { return MCPServerConfig{}, err } @@ -226,7 +227,7 @@ func (c *Client) UpdateMCPServerConfig(ctx context.Context, id uuid.UUID, req Up } func (c *Client) DeleteMCPServerConfig(ctx context.Context, id uuid.UUID) error { - res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/mcp/servers/%s", id), nil) + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/mcp-servers/%s", id), nil) if err != nil { return err } diff --git a/enterprise/coderd/mcp_test.go b/enterprise/coderd/mcp_test.go new file mode 100644 index 0000000000000..6e9248fec7847 --- /dev/null +++ b/enterprise/coderd/mcp_test.go @@ -0,0 +1,129 @@ +package coderd_test + +import ( + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +func createMCPServerConfigForOrganization( + t testing.TB, + client *codersdk.Client, + organizationID uuid.UUID, + slug string, +) codersdk.MCPServerConfig { + t.Helper() + + config, err := client.CreateMCPServerConfig( + testutil.Context(t, testutil.WaitLong), + organizationID, + codersdk.CreateMCPServerConfigRequest{ + DisplayName: slug, + Slug: slug, + Transport: "streamable_http", + URL: "https://mcp.example.com/" + slug, + AuthType: "none", + Availability: "default_on", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, + }, + ) + require.NoError(t, err) + return config +} + +func requireMCPServerConfigRequestStatus( + t *testing.T, + client *codersdk.Client, + method string, + configID uuid.UUID, + pathSuffix string, + body any, + wantStatus int, +) { + t.Helper() + + res, err := client.Request( + testutil.Context(t, testutil.WaitLong), + method, + "/api/experimental/mcp-servers/"+configID.String()+pathSuffix, + body, + ) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, wantStatus, res.StatusCode) +} + +func TestMCPServerConfigCollectionOrganizationIsolation(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, firstUser := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureMultipleOrganizations: 1, + }, + }, + }) + secondOrg := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{}) + firstConfig := createMCPServerConfigForOrganization(t, client, firstUser.OrganizationID, "org-one-mcp") + secondConfig := createMCPServerConfigForOrganization(t, client, secondOrg.ID, "org-two-mcp") + + //nolint:gocritic // Site owner access is the behavior under test. + firstConfigs, err := client.MCPServerConfigs(ctx, firstUser.OrganizationID) + require.NoError(t, err) + require.Len(t, firstConfigs, 1) + require.Equal(t, firstConfig.ID, firstConfigs[0].ID) + require.Equal(t, firstUser.OrganizationID, firstConfigs[0].OrganizationID) + + //nolint:gocritic // Site owner access is the behavior under test. + secondConfigs, err := client.MCPServerConfigs(ctx, secondOrg.ID) + require.NoError(t, err) + require.Len(t, secondConfigs, 1) + require.Equal(t, secondConfig.ID, secondConfigs[0].ID) + require.Equal(t, secondOrg.ID, secondConfigs[0].OrganizationID) +} + +func TestMCPServerConfigItemCrossOrganizationConcealment(t *testing.T) { + t.Parallel() + + client, firstUser := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureMultipleOrganizations: 1, + }, + }, + }) + secondOrg := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{}) + otherClient, _ := coderdtest.CreateAnotherUser(t, client, secondOrg.ID) + config := createMCPServerConfigForOrganization(t, client, firstUser.OrganizationID, "private-org-one-mcp") + + for _, test := range []struct { + name string + method string + pathSuffix string + body any + }{ + {name: "Get", method: http.MethodGet}, + {name: "Patch", method: http.MethodPatch, body: codersdk.UpdateMCPServerConfigRequest{DisplayName: ptr.Ref("cross-org")}}, + {name: "Delete", method: http.MethodDelete}, + {name: "OAuthConnect", method: http.MethodGet, pathSuffix: "/oauth2/connect"}, + {name: "OAuthCallback", method: http.MethodGet, pathSuffix: "/oauth2/callback"}, + {name: "OAuthDisconnect", method: http.MethodDelete, pathSuffix: "/oauth2/disconnect"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + requireMCPServerConfigRequestStatus(t, otherClient, test.method, config.ID, test.pathSuffix, test.body, http.StatusNotFound) + }) + } +} diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index 852072db8ff5c..f9b1bbc03a0dc 100644 --- a/enterprise/dbcrypt/dbcrypt.go +++ b/enterprise/dbcrypt/dbcrypt.go @@ -724,8 +724,8 @@ func (db *dbCrypt) GetMCPServerConfigByOrganizationAndSlug(ctx context.Context, return cfg, nil } -func (db *dbCrypt) GetMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { - cfgs, err := db.Store.GetMCPServerConfigs(ctx) +func (db *dbCrypt) GetMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { + cfgs, err := db.Store.GetMCPServerConfigsByOrganization(ctx, organizationID) if err != nil { return nil, err } @@ -737,8 +737,8 @@ func (db *dbCrypt) GetMCPServerConfigs(ctx context.Context) ([]database.MCPServe return cfgs, nil } -func (db *dbCrypt) GetMCPServerConfigsByIDs(ctx context.Context, ids []uuid.UUID) ([]database.MCPServerConfig, error) { - cfgs, err := db.Store.GetMCPServerConfigsByIDs(ctx, ids) +func (db *dbCrypt) GetMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg database.GetMCPServerConfigsByOrganizationAndIDsParams) ([]database.MCPServerConfig, error) { + cfgs, err := db.Store.GetMCPServerConfigsByOrganizationAndIDs(ctx, arg) if err != nil { return nil, err } @@ -750,8 +750,8 @@ func (db *dbCrypt) GetMCPServerConfigsByIDs(ctx context.Context, ids []uuid.UUID return cfgs, nil } -func (db *dbCrypt) GetMCPServerConfigsByIDsAndOrganizations(ctx context.Context, arg database.GetMCPServerConfigsByIDsAndOrganizationsParams) ([]database.MCPServerConfig, error) { - cfgs, err := db.Store.GetMCPServerConfigsByIDsAndOrganizations(ctx, arg) +func (db *dbCrypt) GetEnabledMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { + cfgs, err := db.Store.GetEnabledMCPServerConfigsByOrganization(ctx, organizationID) if err != nil { return nil, err } @@ -763,21 +763,8 @@ func (db *dbCrypt) GetMCPServerConfigsByIDsAndOrganizations(ctx context.Context, return cfgs, nil } -func (db *dbCrypt) GetEnabledMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { - cfgs, err := db.Store.GetEnabledMCPServerConfigs(ctx) - if err != nil { - return nil, err - } - for i := range cfgs { - if err := db.decryptMCPServerConfig(&cfgs[i]); err != nil { - return nil, err - } - } - return cfgs, nil -} - -func (db *dbCrypt) GetForcedMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { - cfgs, err := db.Store.GetForcedMCPServerConfigs(ctx) +func (db *dbCrypt) GetAuthorizedMCPServerConfigs(ctx context.Context, arg database.GetAuthorizedMCPServerConfigsParams) ([]database.MCPServerConfig, error) { + cfgs, err := db.Store.GetAuthorizedMCPServerConfigs(ctx, arg) if err != nil { return nil, err } diff --git a/enterprise/dbcrypt/dbcrypt_internal_test.go b/enterprise/dbcrypt/dbcrypt_internal_test.go index 9a249d5beee3f..e6f02896505ab 100644 --- a/enterprise/dbcrypt/dbcrypt_internal_test.go +++ b/enterprise/dbcrypt/dbcrypt_internal_test.go @@ -983,48 +983,51 @@ func TestMCPServerConfigs(t *testing.T) { require.ErrorIs(t, err, sql.ErrNoRows) }) - t.Run("GetMCPServerConfigs", func(t *testing.T) { + t.Run("GetMCPServerConfigsByOrganization", func(t *testing.T) { t.Parallel() db, crypt, ciphers := setup(t) cfg := insertConfig(t, crypt, ciphers) - cfgs, err := crypt.GetMCPServerConfigs(ctx) + cfgs, err := crypt.GetMCPServerConfigsByOrganization(ctx, cfg.OrganizationID) require.NoError(t, err) require.Len(t, cfgs, 1) requireMCPServerConfigDecrypted(t, cfgs[0], ciphers, oauthSecret, apiKeyValue, customHeaders) requireMCPServerConfigRawEncrypted(ctx, t, db, cfg.ID, ciphers, oauthSecret, apiKeyValue, customHeaders) }) - t.Run("GetMCPServerConfigsByIDs", func(t *testing.T) { + t.Run("GetMCPServerConfigsByOrganizationAndIDs", func(t *testing.T) { t.Parallel() db, crypt, ciphers := setup(t) cfg := insertConfig(t, crypt, ciphers) - cfgs, err := crypt.GetMCPServerConfigsByIDs(ctx, []uuid.UUID{cfg.ID}) + cfgs, err := crypt.GetMCPServerConfigsByOrganizationAndIDs(ctx, database.GetMCPServerConfigsByOrganizationAndIDsParams{ + OrganizationID: cfg.OrganizationID, + IDs: []uuid.UUID{cfg.ID}, + }) require.NoError(t, err) require.Len(t, cfgs, 1) requireMCPServerConfigDecrypted(t, cfgs[0], ciphers, oauthSecret, apiKeyValue, customHeaders) requireMCPServerConfigRawEncrypted(ctx, t, db, cfg.ID, ciphers, oauthSecret, apiKeyValue, customHeaders) }) - t.Run("GetEnabledMCPServerConfigs", func(t *testing.T) { + t.Run("GetEnabledMCPServerConfigsByOrganization", func(t *testing.T) { t.Parallel() db, crypt, ciphers := setup(t) cfg := insertConfig(t, crypt, ciphers) - cfgs, err := crypt.GetEnabledMCPServerConfigs(ctx) + cfgs, err := crypt.GetEnabledMCPServerConfigsByOrganization(ctx, cfg.OrganizationID) require.NoError(t, err) require.Len(t, cfgs, 1) requireMCPServerConfigDecrypted(t, cfgs[0], ciphers, oauthSecret, apiKeyValue, customHeaders) requireMCPServerConfigRawEncrypted(ctx, t, db, cfg.ID, ciphers, oauthSecret, apiKeyValue, customHeaders) }) - t.Run("GetForcedMCPServerConfigs", func(t *testing.T) { + t.Run("GetForcedMCPServerConfigsByOrganization", func(t *testing.T) { t.Parallel() db, crypt, ciphers := setup(t) cfg := insertConfig(t, crypt, ciphers) - cfgs, err := crypt.GetForcedMCPServerConfigs(ctx) + cfgs, err := crypt.GetForcedMCPServerConfigsByOrganization(ctx, cfg.OrganizationID) require.NoError(t, err) require.Len(t, cfgs, 1) requireMCPServerConfigDecrypted(t, cfgs[0], ciphers, oauthSecret, apiKeyValue, customHeaders) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 07cf5894028bd..8c93fd9e509db 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5955,6 +5955,7 @@ export interface LoginWithPasswordResponse { */ export interface MCPServerConfig { readonly id: string; + readonly organization_id: string; readonly display_name: string; readonly slug: string; readonly description: string; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index ce3b7c1b23b51..e410a13aa51f6 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -1663,6 +1663,7 @@ export const TaskNameGenericRendering: Story = { const sampleMCPServers = [ { id: "mcp-server-1", + organization_id: "00000000-0000-4000-8000-000000000001", slug: "linear", display_name: "Linear", description: "Project management", diff --git a/site/src/testHelpers/chatEntities.ts b/site/src/testHelpers/chatEntities.ts index c86519d192515..238732dbceaf0 100644 --- a/site/src/testHelpers/chatEntities.ts +++ b/site/src/testHelpers/chatEntities.ts @@ -92,6 +92,7 @@ export const MockChatContextDirty: ChatContext = { export const MockMCPServerConfig: MCPServerConfig = { id: "mcp-1", + organization_id: "00000000-0000-4000-8000-000000000001", display_name: "MCP Server", slug: "mcp-server", description: "", From f05991d4fadd513682b03f145993f28f3981adae Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:12:10 +0000 Subject: [PATCH 04/59] fix: cover MCP organization migration gaps --- ..._mcp_server_configs_organization_id.up.sql | 4 +- coderd/database/migrations/migrate_test.go | 327 ++++++++++++++++++ enterprise/dbcrypt/dbcrypt_internal_test.go | 28 ++ 3 files changed, 358 insertions(+), 1 deletion(-) diff --git a/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql index c411aa1d493bb..d27a00246a1a1 100644 --- a/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql +++ b/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql @@ -17,6 +17,9 @@ END $$; UPDATE mcp_server_configs SET organization_id = (SELECT id FROM organizations WHERE is_default = true LIMIT 1); +ALTER TABLE mcp_server_configs + DROP CONSTRAINT mcp_server_configs_slug_key; + CREATE TEMP TABLE mcp_server_config_org_map ( old_id UUID NOT NULL, organization_id UUID NOT NULL, @@ -103,7 +106,6 @@ WHERE remapped.id = chat.id; ALTER TABLE mcp_server_configs ALTER COLUMN organization_id SET NOT NULL, - DROP CONSTRAINT mcp_server_configs_slug_key, ADD CONSTRAINT mcp_server_configs_organization_id_slug_key UNIQUE (organization_id, slug); CREATE INDEX idx_mcp_server_configs_organization_id diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index df0c7d14ea9bb..fed6263035cc4 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2992,3 +2992,330 @@ func TestMigration000566OAuth2AuthMethodBackfill(t *testing.T) { require.Equal(t, "confidential", stillConfidential, "the backfill aligns the declaration to what is enforced, so the enforced value must be unchanged") } + +func TestMigration000565MCPServerConfigsOrganizationID(t *testing.T) { + t.Parallel() + + const priorMigrationVersion = 564 + + sqlDB := testSQLDB(t) + next, err := migrations.Stepper(sqlDB) + require.NoError(t, err) + for { + version, more, err := next() + require.NoError(t, err) + if !more { + t.Fatalf("migration %d not found", priorMigrationVersion) + } + if version == priorMigrationVersion { + break + } + } + + ctx := testutil.Context(t, testutil.WaitSuperLong) + now := time.Now().UTC().Truncate(time.Microsecond) + + var defaultOrgID uuid.UUID + err = sqlDB.QueryRowContext(ctx, `SELECT id FROM organizations WHERE is_default = true`).Scan(&defaultOrgID) + require.NoError(t, err) + + liveOrgIDs := []uuid.UUID{uuid.New(), uuid.New()} + deletedOrgID := uuid.New() + organizationIDs := []uuid.UUID{liveOrgIDs[0], liveOrgIDs[1], deletedOrgID} + for i, orgID := range organizationIDs { + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO organizations ( + id, name, display_name, description, icon, created_at, updated_at, + is_default, deleted, default_org_member_roles + ) VALUES ($1, $2, $3, '', '', $4, $4, false, $5, '{}') + `, orgID, fmt.Sprintf("migration-565-org-%d", i), fmt.Sprintf("Migration 565 Org %d", i), now, orgID == deletedOrgID) + require.NoError(t, err) + } + + userID := uuid.New() + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO users ( + id, username, email, hashed_password, created_at, updated_at, + status, rbac_roles, login_type + ) VALUES ($1, 'migration-565-user', 'migration-565@example.com', ''::bytea, $2, $2, 'active', '{}', 'password') + `, userID, now) + require.NoError(t, err) + + const keyDigest = "migration-565-key" + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO dbcrypt_keys (number, active_key_digest, test) + VALUES (565000, $1, 'migration-565-test') + `, keyDigest) + require.NoError(t, err) + + providerID := uuid.New() + modelConfigID := uuid.New() + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO ai_providers ( + id, type, name, display_name, enabled, base_url, created_at, updated_at + ) VALUES ($1, 'openai', 'migration-565-provider', 'Migration 565 Provider', true, 'https://provider.example.com', $2, $2) + `, providerID, now) + require.NoError(t, err) + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO chat_model_configs ( + id, model, display_name, ai_provider_id, context_limit, + compression_threshold, created_at, updated_at + ) VALUES ($1, 'migration-565-model', 'Migration 565 Model', $2, 128000, 70, $3, $3) + `, modelConfigID, providerID, now) + require.NoError(t, err) + + type configSeed struct { + id uuid.UUID + slug string + authType string + oauth2ClientID string + oauth2ClientSecret string + oauth2ClientSecretKeyID sql.NullString + oauth2AuthURL string + oauth2TokenURL string + oauth2RevocationURL string + oauth2Scopes string + apiKeyHeader string + apiKeyValue string + apiKeyValueKeyID sql.NullString + customHeaders string + customHeadersKeyID sql.NullString + } + keyID := sql.NullString{String: keyDigest, Valid: true} + configs := []configSeed{ + {id: uuid.New(), slug: "migration-565-none", authType: "none", apiKeyHeader: "Authorization", customHeaders: "{}"}, + {id: uuid.New(), slug: "migration-565-api-key", authType: "api_key", apiKeyHeader: "X-API-Key", apiKeyValue: "api-key-ciphertext", apiKeyValueKeyID: keyID, customHeaders: "{}"}, + {id: uuid.New(), slug: "migration-565-custom-headers", authType: "custom_headers", apiKeyHeader: "Authorization", customHeaders: "custom-headers-ciphertext", customHeadersKeyID: keyID}, + { + id: uuid.New(), + slug: "migration-565-oauth2", + authType: "oauth2", + oauth2ClientID: "oauth-client-id", + oauth2ClientSecret: "oauth-secret-ciphertext", + oauth2ClientSecretKeyID: keyID, + oauth2AuthURL: "https://oauth.example.com/authorize", + oauth2TokenURL: "https://oauth.example.com/token", + oauth2RevocationURL: "https://oauth.example.com/revoke", + oauth2Scopes: "openid profile", + apiKeyHeader: "Authorization", + customHeaders: "{}", + }, + } + + originalJSON := make(map[uuid.UUID]string, len(configs)) + for _, config := range configs { + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO mcp_server_configs ( + id, display_name, slug, description, url, auth_type, + oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, + oauth2_auth_url, oauth2_token_url, oauth2_revocation_url, oauth2_scopes, + api_key_header, api_key_value, api_key_value_key_id, + custom_headers, custom_headers_key_id, availability, enabled, + created_by, updated_by, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, $17, $18, 'default_on', true, + $19, $19, $20, $20 + ) + `, + config.id, "Migration 565 "+config.authType, config.slug, "migration 565 config", "https://mcp.example.com/"+config.slug, config.authType, + config.oauth2ClientID, config.oauth2ClientSecret, config.oauth2ClientSecretKeyID, + config.oauth2AuthURL, config.oauth2TokenURL, config.oauth2RevocationURL, config.oauth2Scopes, + config.apiKeyHeader, config.apiKeyValue, config.apiKeyValueKeyID, + config.customHeaders, config.customHeadersKeyID, userID, now, + ) + require.NoError(t, err) + + var rowJSON string + err = sqlDB.QueryRowContext(ctx, `SELECT to_jsonb(config) FROM mcp_server_configs AS config WHERE id = $1`, config.id).Scan(&rowJSON) + require.NoError(t, err) + originalJSON[config.id] = rowJSON + } + + oauthConfigID := configs[3].id + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO mcp_server_user_tokens ( + id, mcp_server_config_id, user_id, access_token, access_token_key_id, + refresh_token, refresh_token_key_id, created_at, updated_at + ) VALUES ($1, $2, $3, 'access-token-ciphertext', $4, 'refresh-token-ciphertext', $4, $5, $5) + `, uuid.New(), oauthConfigID, userID, keyDigest, now) + require.NoError(t, err) + + type chatSeed struct { + id uuid.UUID + organizationID uuid.UUID + configIDs []uuid.UUID + } + chats := []chatSeed{ + {id: uuid.New(), organizationID: defaultOrgID, configIDs: []uuid.UUID{configs[0].id, configs[3].id, configs[1].id, configs[2].id}}, + {id: uuid.New(), organizationID: liveOrgIDs[0], configIDs: []uuid.UUID{configs[2].id, configs[0].id, configs[3].id, configs[1].id}}, + {id: uuid.New(), organizationID: liveOrgIDs[1], configIDs: []uuid.UUID{configs[1].id, configs[3].id, configs[0].id}}, + } + for i, chat := range chats { + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO chats ( + id, owner_id, organization_id, last_model_config_id, title, + mcp_server_ids, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $7) + `, chat.id, userID, chat.organizationID, modelConfigID, fmt.Sprintf("Migration 565 Chat %d", i), pq.Array(chat.configIDs), now) + require.NoError(t, err) + } + + version, _, err := next() + require.NoError(t, err) + require.EqualValues(t, 565, version) + + var totalConfigs int + err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_server_configs`).Scan(&totalConfigs) + require.NoError(t, err) + require.Equal(t, len(configs)*(len(liveOrgIDs)+1), totalConfigs) + + for _, orgID := range append([]uuid.UUID{defaultOrgID}, liveOrgIDs...) { + var count int + err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_server_configs WHERE organization_id = $1`, orgID).Scan(&count) + require.NoError(t, err) + require.Equal(t, len(configs), count) + } + var deletedOrgConfigs int + err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_server_configs WHERE organization_id = $1`, deletedOrgID).Scan(&deletedOrgConfigs) + require.NoError(t, err) + require.Zero(t, deletedOrgConfigs) + + for _, config := range configs { + var gotJSON string + var organizationID uuid.UUID + err = sqlDB.QueryRowContext(ctx, ` + SELECT to_jsonb(config) - 'organization_id', organization_id + FROM mcp_server_configs AS config + WHERE id = $1 + `, config.id).Scan(&gotJSON, &organizationID) + require.NoError(t, err) + require.JSONEq(t, originalJSON[config.id], gotJSON) + require.Equal(t, defaultOrgID, organizationID) + + var slugCount int + err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_server_configs WHERE slug = $1`, config.slug).Scan(&slugCount) + require.NoError(t, err) + require.Equal(t, len(liveOrgIDs)+1, slugCount) + } + + copiedIDs := make(map[uuid.UUID]map[uuid.UUID]uuid.UUID, len(liveOrgIDs)) + for _, orgID := range liveOrgIDs { + copiedIDs[orgID] = make(map[uuid.UUID]uuid.UUID, len(configs)) + for _, config := range configs { + var copiedID uuid.UUID + var authType, oauth2ClientID, oauth2ClientSecret string + var oauth2ClientSecretKeyID sql.NullString + var oauth2AuthURL, oauth2TokenURL, oauth2RevocationURL, oauth2Scopes string + var apiKeyValue string + var apiKeyValueKeyID sql.NullString + var customHeaders string + var customHeadersKeyID sql.NullString + var enabled bool + err = sqlDB.QueryRowContext(ctx, ` + SELECT + id, auth_type, oauth2_client_id, oauth2_client_secret, + oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, + oauth2_revocation_url, oauth2_scopes, api_key_value, + api_key_value_key_id, custom_headers, custom_headers_key_id, enabled + FROM mcp_server_configs + WHERE organization_id = $1 AND slug = $2 + `, orgID, config.slug).Scan( + &copiedID, &authType, &oauth2ClientID, &oauth2ClientSecret, + &oauth2ClientSecretKeyID, &oauth2AuthURL, &oauth2TokenURL, + &oauth2RevocationURL, &oauth2Scopes, &apiKeyValue, + &apiKeyValueKeyID, &customHeaders, &customHeadersKeyID, &enabled, + ) + require.NoError(t, err) + require.NotEqual(t, config.id, copiedID) + require.Equal(t, config.authType, authType) + copiedIDs[orgID][config.id] = copiedID + + switch config.authType { + case "api_key": + require.Equal(t, config.apiKeyValue, apiKeyValue) + require.Equal(t, config.apiKeyValueKeyID, apiKeyValueKeyID) + case "custom_headers": + require.Equal(t, config.customHeaders, customHeaders) + require.Equal(t, config.customHeadersKeyID, customHeadersKeyID) + case "oauth2": + require.Empty(t, oauth2ClientID) + require.Empty(t, oauth2ClientSecret) + require.False(t, oauth2ClientSecretKeyID.Valid) + require.Equal(t, config.oauth2AuthURL, oauth2AuthURL) + require.Equal(t, config.oauth2TokenURL, oauth2TokenURL) + require.Equal(t, config.oauth2RevocationURL, oauth2RevocationURL) + require.Equal(t, config.oauth2Scopes, oauth2Scopes) + require.False(t, enabled) + } + } + } + + var tokenCount int + var tokenConfigID uuid.UUID + err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*), MIN(mcp_server_config_id::text)::uuid FROM mcp_server_user_tokens`).Scan(&tokenCount, &tokenConfigID) + require.NoError(t, err) + require.Equal(t, 1, tokenCount) + require.Equal(t, oauthConfigID, tokenConfigID) + + var slugConstraint string + err = sqlDB.QueryRowContext(ctx, ` + SELECT pg_get_constraintdef(oid) + FROM pg_constraint + WHERE conname = 'mcp_server_configs_organization_id_slug_key' + `).Scan(&slugConstraint) + require.NoError(t, err) + require.Equal(t, "UNIQUE (organization_id, slug)", slugConstraint) + + getChatIDs := func(t *testing.T, chatID uuid.UUID) []uuid.UUID { + t.Helper() + var ids []uuid.UUID + err := sqlDB.QueryRowContext(ctx, `SELECT mcp_server_ids FROM chats WHERE id = $1`, chatID).Scan(pq.Array(&ids)) + require.NoError(t, err) + return ids + } + remap := func(orgID uuid.UUID, ids []uuid.UUID) []uuid.UUID { + if orgID == defaultOrgID { + return ids + } + remapped := make([]uuid.UUID, len(ids)) + for i, id := range ids { + remapped[i] = copiedIDs[orgID][id] + } + return remapped + } + for _, chat := range chats { + require.Equal(t, remap(chat.organizationID, chat.configIDs), getChatIDs(t, chat.id)) + } + + orgOnlyConfigID := uuid.New() + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO mcp_server_configs ( + id, organization_id, display_name, slug, url, auth_type + ) VALUES ($1, $2, 'Org-only config', 'migration-565-org-only', 'https://mcp.example.com/org-only', 'none') + `, orgOnlyConfigID, liveOrgIDs[0]) + require.NoError(t, err) + _, err = sqlDB.ExecContext(ctx, ` + UPDATE chats SET mcp_server_ids = array_append(mcp_server_ids, $2) WHERE id = $1 + `, chats[1].id, orgOnlyConfigID) + require.NoError(t, err) + + downSQL, err := os.ReadFile("000565_mcp_server_configs_organization_id.down.sql") + require.NoError(t, err) + _, err = sqlDB.ExecContext(ctx, string(downSQL)) + require.NoError(t, err) + + for _, chat := range chats { + require.Equal(t, chat.configIDs, getChatIDs(t, chat.id)) + } + var danglingIDs int + err = sqlDB.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM chats AS chat + CROSS JOIN LATERAL unnest(chat.mcp_server_ids) AS config_id + WHERE NOT EXISTS (SELECT 1 FROM mcp_server_configs WHERE id = config_id) + `).Scan(&danglingIDs) + require.NoError(t, err) + require.Zero(t, danglingIDs) +} diff --git a/enterprise/dbcrypt/dbcrypt_internal_test.go b/enterprise/dbcrypt/dbcrypt_internal_test.go index e6f02896505ab..9c48b34f46db6 100644 --- a/enterprise/dbcrypt/dbcrypt_internal_test.go +++ b/enterprise/dbcrypt/dbcrypt_internal_test.go @@ -19,6 +19,8 @@ import ( "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/regosql" ) func TestUserLinks(t *testing.T) { @@ -916,6 +918,16 @@ func requireMCPServerConfigRawEncrypted( requireEncryptedEquals(t, ciphers[0], raw.CustomHeaders, wantHeaders) } +type allowAllPreparedAuthorized struct{} + +func (allowAllPreparedAuthorized) Authorize(context.Context, rbac.Object) error { + return nil +} + +func (allowAllPreparedAuthorized) CompileToSQL(context.Context, regosql.ConvertConfig) (string, error) { + return "TRUE", nil +} + func TestMCPServerConfigs(t *testing.T) { t.Parallel() ctx := context.Background() @@ -995,6 +1007,22 @@ func TestMCPServerConfigs(t *testing.T) { requireMCPServerConfigRawEncrypted(ctx, t, db, cfg.ID, ciphers, oauthSecret, apiKeyValue, customHeaders) }) + t.Run("GetAuthorizedMCPServerConfigs", func(t *testing.T) { + t.Parallel() + db, crypt, ciphers := setup(t) + cfg := insertConfig(t, crypt, ciphers) + + cfgs, err := crypt.GetAuthorizedMCPServerConfigs(ctx, database.GetAuthorizedMCPServerConfigsParams{ + OrganizationID: cfg.OrganizationID, + Prepared: allowAllPreparedAuthorized{}, + }) + require.NoError(t, err) + require.Len(t, cfgs, 1) + require.Equal(t, cfg.ID, cfgs[0].ID) + requireMCPServerConfigDecrypted(t, cfgs[0], ciphers, oauthSecret, apiKeyValue, customHeaders) + requireMCPServerConfigRawEncrypted(ctx, t, db, cfg.ID, ciphers, oauthSecret, apiKeyValue, customHeaders) + }) + t.Run("GetMCPServerConfigsByOrganizationAndIDs", func(t *testing.T) { t.Parallel() db, crypt, ciphers := setup(t) From ff4a1ffe0ce5c204327c59e125273c0fea5f1f02 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:09:45 +0000 Subject: [PATCH 05/59] fix(coderd): resolve MCP item routes via param middleware --- coderd/coderd.go | 5 +- coderd/exp_chats.go | 26 +++--- coderd/exp_chats_test.go | 126 +++++++++++--------------- coderd/httpmw/mcpserverconfigparam.go | 54 +++++++++++ coderd/mcp.go | 87 +++++++----------- coderd/mcp_test.go | 6 +- coderd/x/chatd/chatd.go | 18 ++-- coderd/x/chatd/generation_preparer.go | 2 +- 8 files changed, 172 insertions(+), 152 deletions(-) create mode 100644 coderd/httpmw/mcpserverconfigparam.go diff --git a/coderd/coderd.go b/coderd/coderd.go index 34a12dd71ba8f..2bda20e3285bb 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1379,7 +1379,10 @@ func New(options *Options) *API { r.Get("/chats/files/{file}/download", api.downloadChatFile) }) r.Route("/mcp-servers/{mcpserverconfig}", func(r chi.Router) { - r.Use(apiKeyMiddleware) + r.Use( + apiKeyMiddleware, + httpmw.ExtractMCPServerConfigParam(options.Database), + ) r.Get("/", api.getMCPServerConfig) r.Patch("/", api.updateMCPServerConfig) r.Delete("/", api.deleteMCPServerConfig) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 42fa4b48f258e..e2f1e6d98df9a 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -1244,19 +1244,6 @@ func (api *API) validateExplicitChatModelConfigAvailable( return status, resp } -// EXPERIMENTAL: this endpoint is experimental and is subject to change. -// -// @Summary Create chat -// @ID create-chat -// @Security CoderSessionToken -// @Tags Chats -// @Accept json -// @Produce json -// @Param request body codersdk.CreateChatRequest true "Create chat request" -// @Success 201 {object} codersdk.Chat -// @Failure 413 {object} codersdk.Response "Request body exceeds 256 KiB" -// @Router /api/experimental/chats [post] -// @Description Experimental: this endpoint is subject to change. func validateChatMCPServerIDs( ctx context.Context, db database.Store, @@ -1299,6 +1286,19 @@ func validateChatMCPServerIDs( return unique, invalid, nil } +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +// +// @Summary Create chat +// @ID create-chat +// @Security CoderSessionToken +// @Tags Chats +// @Accept json +// @Produce json +// @Param request body codersdk.CreateChatRequest true "Create chat request" +// @Success 201 {object} codersdk.Chat +// @Failure 413 {object} codersdk.Response "Request body exceeds 256 KiB" +// @Router /api/experimental/chats [post] +// @Description Experimental: this endpoint is subject to change. func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index f9e1d800ac640..363c7984e25fb 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -570,7 +570,7 @@ func TestPostChats(t *testing.T) { })) }) - t.Run("MCPServerIDsDefaultOrgFallback", func(t *testing.T) { + t.Run("MCPServerIDsCrossOrgRejected", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -578,9 +578,8 @@ func TestPostChats(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, client.Client) _ = createChatModelConfig(t, client) - // The chat lives in a second organization, but the only enabled - // MCP server config lives in the default organization. During the - // fallback window the create must accept it. + // The chat lives in a second organization; an enabled config in + // the default organization is out of scope for it. defaultOrgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ OrganizationID: firstUser.OrganizationID, Enabled: true, @@ -590,7 +589,7 @@ func TestPostChats(t *testing.T) { memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, secondOrg.ID, rbac.ScopedRoleAgentsAccess(secondOrg.ID)) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: secondOrg.ID, Content: []codersdk.ChatInputPart{ { @@ -600,33 +599,28 @@ func TestPostChats(t *testing.T) { }, MCPServerIDs: []uuid.UUID{defaultOrgConfig.ID}, }) - require.NoError(t, err) - require.Equal(t, []uuid.UUID{defaultOrgConfig.ID}, chat.MCPServerIDs) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "One or more MCP server IDs are invalid or disabled.", sdkErr.Message) + require.Equal(t, "Invalid IDs: "+defaultOrgConfig.ID.String(), sdkErr.Detail) }) - t.Run("MCPServerIDsDuplicatesRejected", func(t *testing.T) { + t.Run("MCPServerIDsDuplicatesNormalized", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) - firstUser := coderdtest.CreateFirstUser(t, client.Client) + coderdtest.CreateFirstUser(t, client.Client) _ = createChatModelConfig(t, client) - // Duplicate valid IDs are rejected with the pre-org-scoping - // response: 400, fixed message, and an EMPTY Invalid IDs detail - // (the SQL dedupes, the raw-length comparison flags the - // mismatch, and no ID is missing). Whether duplicates should be - // rejected, and what the detail should say, is CODAGT-870's - // decision; this stage preserves the old behavior. - defaultOrgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - OrganizationID: firstUser.OrganizationID, + secondOrg := dbgen.Organization(t, db, database.Organization{}) + orgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: secondOrg.ID, Enabled: true, }) - - secondOrg := dbgen.Organization(t, db, database.Organization{}) memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, secondOrg.ID, rbac.ScopedRoleAgentsAccess(secondOrg.ID)) memberClient := codersdk.NewExperimentalClient(memberClientRaw) + // Duplicate valid IDs are deduplicated, not rejected. chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: secondOrg.ID, Content: []codersdk.ChatInputPart{ @@ -635,42 +629,28 @@ func TestPostChats(t *testing.T) { Text: "chat with a duplicated MCP server ID", }, }, - MCPServerIDs: []uuid.UUID{defaultOrgConfig.ID, defaultOrgConfig.ID}, - }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "One or more MCP server IDs are invalid.", sdkErr.Message) - require.Equal(t, "Invalid IDs: ", sdkErr.Detail) - require.Equal(t, uuid.Nil, chat.ID) - - // Seed a valid chat, then reject the duplicate on message - // update with the identical response. - validChat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ - OrganizationID: secondOrg.ID, - Content: []codersdk.ChatInputPart{ - { - Type: codersdk.ChatInputPartTypeText, - Text: "chat with a single MCP server ID", - }, - }, - MCPServerIDs: []uuid.UUID{defaultOrgConfig.ID}, + MCPServerIDs: []uuid.UUID{orgConfig.ID, orgConfig.ID}, }) require.NoError(t, err) + require.Equal(t, []uuid.UUID{orgConfig.ID}, chat.MCPServerIDs) - _, err = memberClient.CreateChatMessage(ctx, validChat.ID, codersdk.CreateChatMessageRequest{ + _, err = memberClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "update to a duplicated MCP server ID", }, }, - MCPServerIDs: &[]uuid.UUID{defaultOrgConfig.ID, defaultOrgConfig.ID}, + MCPServerIDs: &[]uuid.UUID{orgConfig.ID, orgConfig.ID}, }) - sdkErr = requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "One or more MCP server IDs are invalid.", sdkErr.Message) - require.Equal(t, "Invalid IDs: ", sdkErr.Detail) + require.NoError(t, err) + + storedChat, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + require.Equal(t, []uuid.UUID{orgConfig.ID}, storedChat.MCPServerIDs) }) - t.Run("MCPServerIDsDisabledConfigAccepted", func(t *testing.T) { + t.Run("MCPServerIDsDisabledConfigRejected", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -678,55 +658,58 @@ func TestPostChats(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, client.Client) _ = createChatModelConfig(t, client) - // A disabled config in the default organization: attaching it to - // a chat (and updating the chat to keep it) must stay accepted, - // as before org-scoping. The generation path skips disabled - // configs, so this changes nothing about tool exposure. - user := dbgen.User(t, db, database.User{}) - disabledCfg, err := db.InsertMCPServerConfig(dbauthz.AsSystemRestricted(ctx), database.InsertMCPServerConfigParams{ + // A disabled config in the chat's own organization is not + // selectable at create or update time. + enabledCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ OrganizationID: firstUser.OrganizationID, - DisplayName: "Disabled MCP Server", - Slug: testutil.GetRandomName(t), - Url: "https://mcp.example.com", - Transport: "streamable_http", - AuthType: "none", - ToolAllowList: []string{}, - ToolDenyList: []string{}, - Availability: "default_off", - Enabled: false, - CreatedBy: user.ID, - UpdatedBy: user.ID, + Enabled: true, + }) + disabledCfg, err := client.Client.UpdateMCPServerConfig(ctx, enabledCfg.ID, codersdk.UpdateMCPServerConfigRequest{ + Enabled: ptr.Ref(false), }) require.NoError(t, err) - secondOrg := dbgen.Organization(t, db, database.Organization{}) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, secondOrg.ID, rbac.ScopedRoleAgentsAccess(secondOrg.ID)) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ - OrganizationID: secondOrg.ID, + _, err = memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, - Text: "chat with a disabled default-org MCP server", + Text: "chat with a disabled MCP server", }, }, MCPServerIDs: []uuid.UUID{disabledCfg.ID}, }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "One or more MCP server IDs are invalid or disabled.", sdkErr.Message) + require.Equal(t, "Invalid IDs: "+disabledCfg.ID.String(), sdkErr.Detail) + + // The message-update validation path rejects it too. + chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "chat without MCP servers", + }, + }, + }) require.NoError(t, err) - require.Equal(t, []uuid.UUID{disabledCfg.ID}, chat.MCPServerIDs) - // The message-update validation path accepts it too. _, err = memberClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, - Text: "still keeping the disabled config", + Text: "selecting the disabled config", }, }, MCPServerIDs: &[]uuid.UUID{disabledCfg.ID}, }) - require.NoError(t, err) + sdkErr = requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "One or more MCP server IDs are invalid or disabled.", sdkErr.Message) + require.Equal(t, "Invalid IDs: "+disabledCfg.ID.String(), sdkErr.Detail) }) t.Run("MCPServerIDsThirdOrgRejected", func(t *testing.T) { @@ -739,7 +722,7 @@ func TestPostChats(t *testing.T) { // The enabled config belongs to a third organization: neither the // chat's organization nor the default organization, so the create - // must reject it even during the fallback window. + // must reject it. thirdOrg := dbgen.Organization(t, db, database.Organization{}) thirdOrgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ OrganizationID: thirdOrg.ID, @@ -761,7 +744,8 @@ func TestPostChats(t *testing.T) { MCPServerIDs: []uuid.UUID{thirdOrgConfig.ID}, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "One or more MCP server IDs are invalid.", sdkErr.Message) + require.Equal(t, "One or more MCP server IDs are invalid or disabled.", sdkErr.Message) + require.Equal(t, "Invalid IDs: "+thirdOrgConfig.ID.String(), sdkErr.Detail) }) t.Run("MemberWithoutAgentsAccess", func(t *testing.T) { diff --git a/coderd/httpmw/mcpserverconfigparam.go b/coderd/httpmw/mcpserverconfigparam.go new file mode 100644 index 0000000000000..2248eb87d5120 --- /dev/null +++ b/coderd/httpmw/mcpserverconfigparam.go @@ -0,0 +1,54 @@ +package httpmw + +import ( + "context" + "net/http" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" +) + +type mcpServerConfigParamContextKey struct{} + +// MCPServerConfigParam returns the MCP server config from the +// ExtractMCPServerConfigParam handler. +func MCPServerConfigParam(r *http.Request) database.MCPServerConfig { + config, ok := r.Context().Value(mcpServerConfigParamContextKey{}).(database.MCPServerConfig) + if !ok { + panic("developer error: mcp server config param middleware not provided") + } + return config +} + +// ExtractMCPServerConfigParam grabs an MCP server config from the +// "mcpserverconfig" URL parameter. dbauthz conceals unauthorized reads +// as not-found, so read-denied callers receive the same 404 as a +// missing row. +func ExtractMCPServerConfigParam(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + configID, parsed := ParseUUIDParam(rw, r, "mcpserverconfig") + if !parsed { + return + } + + config, err := db.GetMCPServerConfigByID(ctx, configID) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching MCP server config.", + Detail: err.Error(), + }) + return + } + + ctx = context.WithValue(ctx, mcpServerConfigParamContextKey{}, config) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} diff --git a/coderd/mcp.go b/coderd/mcp.go index 4defbb2a4248a..37dcbe15ffd44 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -324,7 +324,7 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { } // Now build the callback URL with the actual ID. - callbackURL := fmt.Sprintf("%s/api/experimental/mcp/servers/%s/oauth2/callback", api.AccessURL.String(), inserted.ID) + callbackURL := api.AccessURL.String() + mcpServerOAuth2CallbackPath(inserted.ID) // Discovery targets are attacker-influenced (the MCP // server URL and any endpoints or redirects it // advertises), so all discovery traffic goes through an @@ -514,30 +514,8 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) + config := httpmw.MCPServerConfigParam(r) - mcpServerID, ok := parseMCPServerConfigID(rw, r) - if !ok { - return - } - - //nolint:gocritic // The item must be loaded to derive its organization before authorization. - config, err := api.Database.GetMCPServerConfigByID(dbauthz.AsSystemRestricted(ctx), mcpServerID) - if err != nil { - if httpapi.Is404Error(err) { - httpapi.ResourceNotFound(rw) - return - } - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get MCP server config.", - Detail: err.Error(), - }) - return - } - - if !api.Authorize(r, policy.ActionRead, config) { - httpapi.ResourceNotFound(rw) - return - } isAdmin := api.Authorize(r, policy.ActionUpdate, config) if !isAdmin && !config.Enabled { httpapi.ResourceNotFound(rw) @@ -573,24 +551,14 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, sdkConfig) } +// getMCPServerConfigForMutation returns the config resolved by the +// param middleware after checking the write action. The middleware +// already concealed read-denied as 404, so a write denial here is an +// explicit 403. func (api *API) getMCPServerConfigForMutation(rw http.ResponseWriter, r *http.Request, action policy.Action) (database.MCPServerConfig, bool) { - ctx := r.Context() - mcpServerID, ok := parseMCPServerConfigID(rw, r) - if !ok { - return database.MCPServerConfig{}, false - } - //nolint:gocritic // The item must be loaded to derive its organization before authorization. - config, err := api.Database.GetMCPServerConfigByID(dbauthz.AsSystemRestricted(ctx), mcpServerID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) || httpapi.Is404Error(err) { - httpapi.ResourceNotFound(rw) - return database.MCPServerConfig{}, false - } - httpapi.InternalServerError(rw, err) - return database.MCPServerConfig{}, false - } + config := httpmw.MCPServerConfigParam(r) if !api.Authorize(r, action, config) { - httpapi.ResourceNotFound(rw) + httpapi.Forbidden(rw) return database.MCPServerConfig{}, false } return config, true @@ -934,11 +902,7 @@ func (api *API) deleteMCPServerConfig(rw http.ResponseWriter, r *http.Request) { //nolint:revive // HTTP handler writes to ResponseWriter. func (api *API) mcpServerOAuth2Connect(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - - config, ok := api.getMCPServerConfigForMutation(rw, r, policy.ActionRead) - if !ok { - return - } + config := httpmw.MCPServerConfigParam(r) if !config.Enabled { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -965,7 +929,7 @@ func (api *API) mcpServerOAuth2Connect(rw http.ResponseWriter, r *http.Request) // The callback URL is on our server; after the exchange we store // the token and close the popup. state := uuid.New().String() - callbackPath := fmt.Sprintf("/api/experimental/mcp/servers/%s/oauth2/callback", config.ID) + callbackPath := mcpServerOAuth2CallbackPath(config.ID) http.SetCookie(rw, api.DeploymentValues.HTTPCookies.Apply(&http.Cookie{ Name: "mcp_oauth2_state_" + config.ID.String(), Value: state, @@ -1016,10 +980,19 @@ func (api *API) mcpServerOAuth2Callback(rw http.ResponseWriter, r *http.Request) ctx := r.Context() apiKey := httpmw.APIKey(r) - config, ok := api.getMCPServerConfigForMutation(rw, r, policy.ActionRead) + mcpServerID, ok := parseMCPServerConfigID(rw, r) if !ok { return } + config, err := api.Database.GetMCPServerConfigByID(ctx, mcpServerID) + if err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.InternalServerError(rw, err) + return + } if !config.Enabled { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -1070,7 +1043,7 @@ func (api *API) mcpServerOAuth2Callback(rw http.ResponseWriter, r *http.Request) return } // Clear the state cookie. - callbackPath := fmt.Sprintf("/api/experimental/mcp/servers/%s/oauth2/callback", config.ID) + callbackPath := mcpServerOAuth2CallbackPath(config.ID) http.SetCookie(rw, api.DeploymentValues.HTTPCookies.Apply(&http.Cookie{ Name: "mcp_oauth2_state_" + config.ID.String(), Value: "", @@ -1180,11 +1153,7 @@ func (api *API) mcpServerOAuth2Callback(rw http.ResponseWriter, r *http.Request) func (api *API) mcpServerOAuth2Disconnect(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) - - config, ok := api.getMCPServerConfigForMutation(rw, r, policy.ActionRead) - if !ok { - return - } + config := httpmw.MCPServerConfigParam(r) //nolint:gocritic // Users manage their own tokens. systemCtx := dbauthz.AsSystemRestricted(ctx) @@ -1379,8 +1348,18 @@ func (api *API) markMCPTokenRefreshFailure( return false } +// mcpServerOAuth2CallbackPath returns the OAuth2 callback path for a +// config. This path is frozen: it is the redirect URI registered with +// external authorization servers, so it must not change when other MCP +// routes move. The route registration in coderd.go and the OAuth cookie +// Path values must stay aligned with it. +func mcpServerOAuth2CallbackPath(configID uuid.UUID) string { + return fmt.Sprintf("/api/experimental/mcp/servers/%s/oauth2/callback", configID) +} + // parseMCPServerConfigID extracts the MCP server config UUID from the -// "mcpServer" path parameter. +// "mcpServer" path parameter, which is part of the frozen callback +// route shape. func parseMCPServerConfigID(rw http.ResponseWriter, r *http.Request) (uuid.UUID, bool) { mcpServerID, err := uuid.Parse(chi.URLParam(r, "mcpServer")) if err != nil { diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 8b9f19aafc053..d936d24222ea7 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -1385,7 +1385,7 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { // Sanity-check the full path structure. require.Contains(t, redirectURI, - "/api/experimental/mcp-servers/"+created.ID.String()+"/oauth2/callback", + "/api/experimental/mcp/servers/"+created.ID.String()+"/oauth2/callback", "redirect URI should have the expected callback path") // Double-check that the ID segment is a valid UUID (not some @@ -1700,7 +1700,7 @@ func TestMCPServerOAuth2PKCE(t *testing.T) { verifier := "test-verifier-value-that-is-at-least-43-chars-long-for-pkce-spec" callbackURL, err := memberClient.URL.Parse( - "/api/experimental/mcp-servers/" + created.ID.String() + "/oauth2/callback", + "/api/experimental/mcp/servers/" + created.ID.String() + "/oauth2/callback", ) require.NoError(t, err) q := callbackURL.Query() @@ -1796,7 +1796,7 @@ func TestMCPServerOAuth2PKCE(t *testing.T) { // backwards compatibility with providers that don't use PKCE. state := "test-state-no-pkce" callbackURL, err := memberClient.URL.Parse( - "/api/experimental/mcp-servers/" + created.ID.String() + "/oauth2/callback", + "/api/experimental/mcp/servers/" + created.ID.String() + "/oauth2/callback", ) require.NoError(t, err) q := callbackURL.Query() diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 37e5fcc8570dd..b9888e17399c0 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1204,14 +1204,14 @@ type PromoteQueuedResult struct { } // enforceForcedMCPServerIDs appends the ID of every enabled Force On -// MCP server config missing from ids. Force On availability is a -// server-side policy: callers must not be able to exclude such -// servers by stripping IDs from a request (Cure53 CDM-02-010). The -// forced set is read with daemon scope because regular users cannot -// read MCP server configs directly. -func enforceForcedMCPServerIDs(ctx context.Context, store database.Store, ids []uuid.UUID) ([]uuid.UUID, error) { +// MCP server config in the chat's organization missing from ids. Force +// On availability is a server-side policy: callers must not be able to +// exclude such servers by stripping IDs from a request (Cure53 +// CDM-02-010). The forced set is read with daemon scope because +// regular users cannot read MCP server configs directly. +func enforceForcedMCPServerIDs(ctx context.Context, store database.Store, organizationID uuid.UUID, ids []uuid.UUID) ([]uuid.UUID, error) { //nolint:gocritic // Non-admin users need chatd-scoped config reads here. - forced, err := store.GetForcedMCPServerConfigs(dbauthz.AsChatd(ctx)) + forced, err := store.GetForcedMCPServerConfigsByOrganization(dbauthz.AsChatd(ctx), organizationID) if err != nil { // Fail closed: proceeding without the forced set would // silently bypass a security policy. @@ -1258,7 +1258,7 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C // Force On MCP servers are enforced server-side so a caller // cannot exclude them by stripping IDs from the request // (Cure53 CDM-02-010). - enforcedMCPServerIDs, err := enforceForcedMCPServerIDs(ctx, p.db, opts.MCPServerIDs) + enforcedMCPServerIDs, err := enforceForcedMCPServerIDs(ctx, p.db, opts.OrganizationID, opts.MCPServerIDs) if err != nil { return database.Chat{}, err } @@ -1516,7 +1516,7 @@ func (p *Server) SendMessage( // Force On MCP servers are enforced server-side so a // caller cannot remove them by tampering with the // update (Cure53 CDM-02-010). - enforcedIDs, enforceErr := enforceForcedMCPServerIDs(ctx, store, *requestedMCPServerIDs) + enforcedIDs, enforceErr := enforceForcedMCPServerIDs(ctx, store, lockedChat.OrganizationID, *requestedMCPServerIDs) if enforceErr != nil { return enforceErr } diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index 6c7d750adbfb4..afbbc1a6ba689 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -54,7 +54,7 @@ func (server *Server) effectiveMCPServerConfigs( if isExploreSubagentMode(chat.Mode) { return configs, nil } - forced, err := server.db.GetForcedMCPServerConfigs(ctx) + forced, err := server.db.GetForcedMCPServerConfigsByOrganization(ctx, chat.OrganizationID) if err != nil { // Fail closed: running the turn without the forced set would // silently bypass a security policy. From c7e66e8864bcb2898f43c99533c92652ac637db1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:20:04 +0000 Subject: [PATCH 06/59] feat(site): scope MCP servers to organizations --- site/src/api/api.ts | 25 +++++---- site/src/api/queries/chats.ts | 15 ++++-- .../AddMCPServerPage/AddMCPServerPage.tsx | 10 +++- .../MCPServersPage/MCPServersPage.tsx | 11 +++- .../UpdateMCPServerPage.tsx | 11 +++- site/src/pages/AgentsPage/AgentChatPage.tsx | 14 +++-- site/src/pages/AgentsPage/AgentCreatePage.tsx | 15 +++++- .../AgentsPage/components/AgentChatInput.tsx | 2 +- .../AgentsPage/components/AgentCreateForm.tsx | 28 +++++++--- .../components/MCPServerPicker.test.ts | 53 +++++++++++-------- .../AgentsPage/components/MCPServerPicker.tsx | 18 +++++-- 11 files changed, 145 insertions(+), 57 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 624925d9dbf4f..4b76e07a0f0a0 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -359,7 +359,10 @@ const userSkillPath = (user: string, name: string) => `${userSkillsPath(user)}/${encodeURIComponent(name)}`; const userAIProviderKeysPath = (user = "me") => `/api/experimental/users/${encodeURIComponent(user)}/ai-provider-keys`; -const mcpServerConfigsPath = "/api/experimental/mcp/servers"; +const mcpServerConfigsPath = (organization: string) => + `/api/experimental/organizations/${encodeURIComponent(organization)}/mcp-servers`; +const mcpServerConfigPath = (id: string) => + `/api/experimental/mcp-servers/${encodeURIComponent(id)}`; type Claims = { license_expires: number; @@ -3905,17 +3908,21 @@ class ExperimentalApiMethods { ); }; - getMCPServerConfigs = async (): Promise => { - const response = - await this.axios.get(mcpServerConfigsPath); + getMCPServerConfigs = async ( + organization: string, + ): Promise => { + const response = await this.axios.get( + mcpServerConfigsPath(organization), + ); return response.data; }; createMCPServerConfig = async ( + organization: string, req: TypesGen.CreateMCPServerConfigRequest, ): Promise => { const response = await this.axios.post( - mcpServerConfigsPath, + mcpServerConfigsPath(organization), req, ); return response.data; @@ -3926,16 +3933,14 @@ class ExperimentalApiMethods { req: TypesGen.UpdateMCPServerConfigRequest, ): Promise => { const response = await this.axios.patch( - `${mcpServerConfigsPath}/${encodeURIComponent(id)}`, + mcpServerConfigPath(id), req, ); return response.data; }; deleteMCPServerConfig = async (id: string): Promise => { - await this.axios.delete( - `${mcpServerConfigsPath}/${encodeURIComponent(id)}`, - ); + await this.axios.delete(mcpServerConfigPath(id)); }; disconnectMCPServerOAuth2 = async ( @@ -3943,7 +3948,7 @@ class ExperimentalApiMethods { ): Promise => { const response = await this.axios.delete( - `${mcpServerConfigsPath}/${encodeURIComponent(id)}/oauth2/disconnect`, + `${mcpServerConfigPath(id)}/oauth2/disconnect`, ); return response.data; }; diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 198573ab74923..914bb1c76c5c5 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -2327,20 +2327,25 @@ export const updateChatModelOverride = ( // ── MCP Server Configs ─────────────────────────────────────── export const mcpServersKey = ["mcp", "servers"] as const; +const mcpServerConfigsKey = (organization: string) => + [...mcpServersKey, organization] as const; -export const mcpServerConfigs = () => ({ - queryKey: mcpServersKey, +export const mcpServerConfigs = (organization: string) => ({ + queryKey: mcpServerConfigsKey(organization), queryFn: (): Promise => - API.experimental.getMCPServerConfigs(), + API.experimental.getMCPServerConfigs(organization), }); const invalidateMCPServerConfigQueries = async (queryClient: QueryClient) => { await queryClient.invalidateQueries({ queryKey: mcpServersKey }); }; -export const createMCPServerConfig = (queryClient: QueryClient) => ({ +export const createMCPServerConfig = ( + queryClient: QueryClient, + organization: string, +) => ({ mutationFn: (req: TypesGen.CreateMCPServerConfigRequest) => - API.experimental.createMCPServerConfig(req), + API.experimental.createMCPServerConfig(organization, req), onSuccess: async () => { await invalidateMCPServerConfigQueries(queryClient); }, diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx index 60f4988510d93..d1f14f5f80f15 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx @@ -5,14 +5,22 @@ import { toast } from "sonner"; import { getErrorMessage } from "#/api/errors"; import { createMCPServerConfig } from "#/api/queries/chats"; import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { + getDefaultOrganizationName, + useDashboard, +} from "#/modules/dashboard/useDashboard"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; import AddMCPServerPageView from "./AddMCPServerPageView"; const AddMCPServerPage: FC = () => { const { permissions } = useAuthenticated(); + const { organizations } = useDashboard(); + const organization = getDefaultOrganizationName(organizations); const queryClient = useQueryClient(); const navigate = useNavigate(); - const createMutation = useMutation(createMCPServerConfig(queryClient)); + const createMutation = useMutation( + createMCPServerConfig(queryClient, organization), + ); return ( diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx index 2d070c6e29839..35fbdb7bb51e4 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx @@ -2,13 +2,22 @@ import type { FC } from "react"; import { useQuery } from "react-query"; import { mcpServerConfigs } from "#/api/queries/chats"; import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { + getDefaultOrganizationName, + useDashboard, +} from "#/modules/dashboard/useDashboard"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { pageTitle } from "#/utils/page"; import MCPServersPageView from "./MCPServersPageView"; const MCPServersPage: FC = () => { const { permissions } = useAuthenticated(); - const serversQuery = useQuery(mcpServerConfigs()); + const { organizations } = useDashboard(); + const organization = getDefaultOrganizationName(organizations); + const serversQuery = useQuery({ + ...mcpServerConfigs(organization), + enabled: Boolean(organization), + }); const servers = (serversQuery.data ?? []).toSorted((a, b) => a.display_name.localeCompare(b.display_name), ); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx index d63232c1676e6..83064a94d8dd1 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -10,16 +10,25 @@ import { } from "#/api/queries/chats"; import { Loader } from "#/components/Loader/Loader"; import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { + getDefaultOrganizationName, + useDashboard, +} from "#/modules/dashboard/useDashboard"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { pageTitle } from "#/utils/page"; import UpdateMCPServerPageView from "./UpdateMCPServerPageView"; const UpdateMCPServerPage: FC = () => { const { permissions } = useAuthenticated(); + const { organizations } = useDashboard(); + const organization = getDefaultOrganizationName(organizations); const { serverId } = useParams<{ serverId: string }>(); const queryClient = useQueryClient(); const navigate = useNavigate(); - const serversQuery = useQuery(mcpServerConfigs()); + const serversQuery = useQuery({ + ...mcpServerConfigs(organization), + enabled: Boolean(organization), + }); const updateMutation = useMutation(updateMCPServerConfig(queryClient)); const deleteMutation = useMutation(deleteMCPServerConfig(queryClient)); const server = serversQuery.data?.find((item) => item.id === serverId); diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 4ea32c0957c38..c6ececf8e0d5e 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -895,6 +895,7 @@ const AgentChatPage: FC = () => { }, refetchIntervalInBackground: false, }); + const chatOrganizationId = chatQuery.data?.organization_id ?? ""; const chatMessagesQuery = useInfiniteQuery({ ...chatMessagesForInfiniteScroll(agentId ?? ""), enabled: Boolean(agentId), @@ -922,7 +923,10 @@ const AgentChatPage: FC = () => { const userThresholdsQuery = useQuery(userCompactionThresholds()); const preferencesQuery = useQuery(preferenceSettings()); const userDebugLoggingQuery = useQuery(userChatDebugLogging()); - const mcpServersQuery = useQuery(mcpServerConfigs()); + const mcpServersQuery = useQuery({ + ...mcpServerConfigs(chatOrganizationId), + enabled: Boolean(chatOrganizationId), + }); const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 })); const workspaceOptions = getWorkspaceOptionsWithLinkedWorkspace( workspacesQuery.data?.workspaces ?? [], @@ -941,7 +945,9 @@ const AgentChatPage: FC = () => { const handleMCPSelectionChange = (ids: string[]) => { setSelectedMCPServerIds(ids); - saveMCPSelection(ids); + if (chatOrganizationId) { + saveMCPSelection(chatOrganizationId, ids); + } }; const handleMCPAuthComplete = (_serverId: string) => { @@ -1096,7 +1102,9 @@ const AgentChatPage: FC = () => { return chatRecord.mcp_server_ids; } // Check for a previously saved selection in localStorage. - const saved = getSavedMCPSelection(mcpServers); + const saved = chatOrganizationId + ? getSavedMCPSelection(chatOrganizationId, mcpServers) + : null; if (saved !== null) { return saved; } diff --git a/site/src/pages/AgentsPage/AgentCreatePage.tsx b/site/src/pages/AgentsPage/AgentCreatePage.tsx index 38f019e9cd867..be1d0bb7ee3d4 100644 --- a/site/src/pages/AgentsPage/AgentCreatePage.tsx +++ b/site/src/pages/AgentsPage/AgentCreatePage.tsx @@ -18,6 +18,7 @@ import type * as TypesGen from "#/api/typesGenerated"; import { useWebpushNotifications } from "#/contexts/useWebpushNotifications"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useAIGatewayEnabled } from "#/hooks/useEmbeddedMetadata"; +import { useDashboard } from "#/modules/dashboard/useDashboard"; import { AgentCreateForm, type CreateChatOptions, @@ -41,6 +42,14 @@ const AgentCreatePage: FC = () => { const location = useLocation(); const navigate = useNavigate(); const { permissions } = useAuthenticated(); + const { organizations } = useDashboard(); + const [organizationId, setOrganizationId] = useState( + () => + ( + organizations.find((organization) => organization.is_default) ?? + organizations[0] + )?.id ?? "", + ); const aiGatewayDisabled = !useAIGatewayEnabled(); const chatModelsQuery = useQuery(chatModels()); @@ -54,7 +63,10 @@ const AgentCreatePage: FC = () => { userChatPersonalModelOverrides(), ); const preferencesQuery = useQuery(preferenceSettings()); - const mcpServersQuery = useQuery(mcpServerConfigs()); + const mcpServersQuery = useQuery({ + ...mcpServerConfigs(organizationId), + enabled: Boolean(organizationId), + }); const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 })); const createMutation = useMutation(createChat(queryClient)); const webPush = useWebpushNotifications(); @@ -160,6 +172,7 @@ const AgentCreatePage: FC = () => { = ({ const handleMcpConnect = (server: TypesGen.MCPServerConfig) => { setMcpConnectingId(server.id); - const connectUrl = `/api/experimental/mcp/servers/${encodeURIComponent(server.id)}/oauth2/connect`; + const connectUrl = `/api/experimental/mcp-servers/${encodeURIComponent(server.id)}/oauth2/connect`; mcpPopupRef.current = window.open( connectUrl, "_blank", diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 5b3f382214524..4769c4df5f05e 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -123,6 +123,7 @@ export function useEmptyStateDraft() { interface AgentCreateFormProps { onCreateChat: (options: CreateChatOptions) => Promise; + onOrganizationChange?: (organizationId: string) => void; sendShortcut: AgentChatSendShortcut; isCreating: boolean; createError: unknown; @@ -149,6 +150,7 @@ interface AgentCreateFormProps { export const AgentCreateForm: FC = ({ onCreateChat, + onOrganizationChange = () => {}, sendShortcut, isCreating, createError, @@ -283,6 +285,9 @@ export const AgentCreateForm: FC = ({ ); const [pendingOrgChange, setPendingOrgChange] = useState(null); + const [userMCPServerIds, setUserMCPServerIds] = useState( + null, + ); const permittedOrgsQuery = useQuery({ ...permittedOrganizations({ // agents-access grants chat:create only at member scope. "me" is @@ -354,8 +359,14 @@ export const AgentCreateForm: FC = ({ setLastSettledOrgId(organizationId); if (lastSettledOrgId !== null) { setSelectedWorkspaceId(null); + setUserMCPServerIds(null); } } + useEffect(() => { + if (organizationId) { + onOrganizationChange(organizationId); + } + }, [organizationId, onOrganizationChange]); useEffect(() => { if (selectedWorkspaceId === null) { localStorage.removeItem(selectedWorkspaceIdStorageKey); @@ -395,14 +406,11 @@ export const AgentCreateForm: FC = ({ lastUsedModelID, ]); - const [userMCPServerIds, setUserMCPServerIds] = useState( - null, - ); const effectiveMCPServerIds = (() => { if (userMCPServerIds !== null) { return userMCPServerIds; } - const saved = getSavedMCPSelection(mcpServers ?? []); + const saved = getSavedMCPSelection(organizationId, mcpServers ?? []); if (saved !== null) { return saved; } @@ -418,6 +426,12 @@ export const AgentCreateForm: FC = ({ localStorage.setItem(selectedWorkspaceIdStorageKey, value); }; + const selectOrganization = (organization: TypesGen.Organization) => { + setUserMCPServerIds(null); + setSelectedOrg(organization); + onOrganizationChange(organization.id); + }; + const handleModelChange = (value: string) => { setHasUserSelectedModel(true); setUserSelectedModel(value); @@ -581,6 +595,8 @@ export const AgentCreateForm: FC = ({ } if (orgChanged) { handleWorkspaceChange(null); + selectOrganization(newOrg); + return; } setSelectedOrg(newOrg); }} @@ -627,7 +643,7 @@ export const AgentCreateForm: FC = ({ selectedMCPServerIds={effectiveMCPServerIds} onMCPSelectionChange={(ids) => { setUserMCPServerIds(ids); - saveMCPSelection(ids); + saveMCPSelection(organizationId, ids); }} onMCPAuthComplete={onMCPAuthComplete} workspaceOptions={filteredWorkspaces} @@ -671,7 +687,7 @@ export const AgentCreateForm: FC = ({ } resetAttachments(); handleWorkspaceChange(null); - setSelectedOrg(pendingOrgChange); + selectOrganization(pendingOrgChange); }} onClose={() => setPendingOrgChange(null)} /> diff --git a/site/src/pages/AgentsPage/components/MCPServerPicker.test.ts b/site/src/pages/AgentsPage/components/MCPServerPicker.test.ts index 533cce5e9b937..9704556837e2a 100644 --- a/site/src/pages/AgentsPage/components/MCPServerPicker.test.ts +++ b/site/src/pages/AgentsPage/components/MCPServerPicker.test.ts @@ -18,6 +18,8 @@ const buildServer = ( ...overrides, }); +const organizationId = "organization-1"; + describe("MCP selection persistence", () => { beforeEach(() => { localStorage.clear(); @@ -25,15 +27,17 @@ describe("MCP selection persistence", () => { describe("saveMCPSelection", () => { it("writes a JSON array to localStorage", () => { - saveMCPSelection(["a", "b"]); - expect(localStorage.getItem(mcpSelectionStorageKey)).toBe( + saveMCPSelection(organizationId, ["a", "b"]); + expect(localStorage.getItem(mcpSelectionStorageKey(organizationId))).toBe( JSON.stringify(["a", "b"]), ); }); it("writes an empty array when no servers are selected", () => { - saveMCPSelection([]); - expect(localStorage.getItem(mcpSelectionStorageKey)).toBe("[]"); + saveMCPSelection(organizationId, []); + expect(localStorage.getItem(mcpSelectionStorageKey(organizationId))).toBe( + "[]", + ); }); }); @@ -45,34 +49,37 @@ describe("MCP selection persistence", () => { ]; it("returns null when nothing is stored", () => { - expect(getSavedMCPSelection(servers)).toBeNull(); + expect(getSavedMCPSelection(organizationId, servers)).toBeNull(); }); it("returns null when the server list is empty", () => { - saveMCPSelection(["s1", "s2"]); - expect(getSavedMCPSelection([])).toBeNull(); + saveMCPSelection(organizationId, ["s1", "s2"]); + expect(getSavedMCPSelection(organizationId, [])).toBeNull(); }); it("returns null for invalid JSON", () => { - localStorage.setItem(mcpSelectionStorageKey, "not-json"); - expect(getSavedMCPSelection(servers)).toBeNull(); + localStorage.setItem(mcpSelectionStorageKey(organizationId), "not-json"); + expect(getSavedMCPSelection(organizationId, servers)).toBeNull(); }); it("returns null when stored value is not an array", () => { - localStorage.setItem(mcpSelectionStorageKey, '"a string"'); - expect(getSavedMCPSelection(servers)).toBeNull(); + localStorage.setItem( + mcpSelectionStorageKey(organizationId), + '"a string"', + ); + expect(getSavedMCPSelection(organizationId, servers)).toBeNull(); }); it("restores saved IDs that still exist as enabled servers", () => { - saveMCPSelection(["s2", "s3"]); - const result = getSavedMCPSelection(servers); + saveMCPSelection(organizationId, ["s2", "s3"]); + const result = getSavedMCPSelection(organizationId, servers); expect(result).toContain("s2"); expect(result).toContain("s3"); }); it("filters out IDs for servers that no longer exist", () => { - saveMCPSelection(["s2", "deleted-server"]); - const result = getSavedMCPSelection(servers); + saveMCPSelection(organizationId, ["s2", "deleted-server"]); + const result = getSavedMCPSelection(organizationId, servers); expect(result).toContain("s2"); expect(result).not.toContain("deleted-server"); }); @@ -82,29 +89,29 @@ describe("MCP selection persistence", () => { ...servers, buildServer({ id: "s4", enabled: false }), ]; - saveMCPSelection(["s2", "s4"]); - const result = getSavedMCPSelection(withDisabled); + saveMCPSelection(organizationId, ["s2", "s4"]); + const result = getSavedMCPSelection(organizationId, withDisabled); expect(result).toContain("s2"); expect(result).not.toContain("s4"); }); it("always includes force_on servers even if not in saved list", () => { - saveMCPSelection(["s3"]); - const result = getSavedMCPSelection(servers); + saveMCPSelection(organizationId, ["s3"]); + const result = getSavedMCPSelection(organizationId, servers); expect(result).toContain("s1"); expect(result).toContain("s3"); }); it("does not duplicate force_on servers already in saved list", () => { - saveMCPSelection(["s1", "s3"]); - const result = getSavedMCPSelection(servers)!; + saveMCPSelection(organizationId, ["s1", "s3"]); + const result = getSavedMCPSelection(organizationId, servers)!; const s1Count = result.filter((id) => id === "s1").length; expect(s1Count).toBe(1); }); it("returns an empty selection (plus force_on) when user opted out", () => { - saveMCPSelection([]); - const result = getSavedMCPSelection(servers); + saveMCPSelection(organizationId, []); + const result = getSavedMCPSelection(organizationId, servers); // Only force_on should be present. expect(result).toEqual(["s1"]); }); diff --git a/site/src/pages/AgentsPage/components/MCPServerPicker.tsx b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx index 44ff80f645046..390b4e92b4658 100644 --- a/site/src/pages/AgentsPage/components/MCPServerPicker.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx @@ -92,16 +92,18 @@ export const getDefaultMCPSelection = ( }; /** localStorage key for persisting the user's MCP server selection. */ -export const mcpSelectionStorageKey = "agents.selected-mcp-server-ids"; +export const mcpSelectionStorageKey = (organizationId: string) => + `agents.selected-mcp-server-ids.${organizationId}`; /** * Read the persisted MCP selection from localStorage, filtered to only * include IDs that still exist in the current server list. * Returns `null` when nothing is stored (caller should fall back to defaults). */ export const getSavedMCPSelection = ( + organizationId: string, servers: readonly TypesGen.MCPServerConfig[], ): string[] | null => { - const raw = localStorage.getItem(mcpSelectionStorageKey); + const raw = localStorage.getItem(mcpSelectionStorageKey(organizationId)); if (raw === null) { return null; } @@ -143,8 +145,14 @@ export const mcpSelectionStorageKey = "agents.selected-mcp-server-ids"; /** * Persist the current MCP selection to localStorage. - */ export const saveMCPSelection = (ids: readonly string[]): void => { - localStorage.setItem(mcpSelectionStorageKey, JSON.stringify(ids)); + */ export const saveMCPSelection = ( + organizationId: string, + ids: readonly string[], +): void => { + localStorage.setItem( + mcpSelectionStorageKey(organizationId), + JSON.stringify(ids), + ); }; // ── Overlapping icon stack for the trigger ───────────────────── @@ -254,7 +262,7 @@ export const MCPServerPicker: FC = ({ const handleConnect = (server: TypesGen.MCPServerConfig) => { setConnectingServerId(server.id); - const connectUrl = `/api/experimental/mcp/servers/${encodeURIComponent(server.id)}/oauth2/connect`; + const connectUrl = `/api/experimental/mcp-servers/${encodeURIComponent(server.id)}/oauth2/connect`; popupRef.current = window.open( connectUrl, "_blank", From 03d5c0735259171a6a9ae9fa4ed67710a34fd545 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:21:47 +0000 Subject: [PATCH 07/59] fix(site): finish MCP organization cutover --- site/src/api/queries/chats.ts | 2 +- site/src/modules/dashboard/useDashboard.ts | 4 + .../AddMCPServerPage/AddMCPServerPage.tsx | 4 +- .../MCPServersPage/MCPServersPage.tsx | 4 +- .../UpdateMCPServerPage.tsx | 4 +- .../AgentsPage/AgentChatPage.stories.tsx | 7 +- site/src/pages/AgentsPage/AgentChatPage.tsx | 16 +++- site/src/pages/AgentsPage/AgentCreatePage.tsx | 12 +-- .../AgentsPage/AgentsPageLayout.stories.tsx | 88 ++++++++++++++++++- .../components/AgentChatInput.stories.tsx | 14 +++ .../components/AgentCreateForm.stories.tsx | 20 +++-- .../AgentsPage/components/AgentCreateForm.tsx | 32 +++++-- .../components/MCPServerPicker.stories.tsx | 18 +++- .../components/MCPServerPicker.test.ts | 52 ++++++++++- .../AgentsPage/components/MCPServerPicker.tsx | 33 +++++-- 15 files changed, 274 insertions(+), 36 deletions(-) diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 914bb1c76c5c5..65504be5db460 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -2326,7 +2326,7 @@ export const updateChatModelOverride = ( // ── MCP Server Configs ─────────────────────────────────────── -export const mcpServersKey = ["mcp", "servers"] as const; +const mcpServersKey = ["mcp", "servers"] as const; const mcpServerConfigsKey = (organization: string) => [...mcpServersKey, organization] as const; diff --git a/site/src/modules/dashboard/useDashboard.ts b/site/src/modules/dashboard/useDashboard.ts index ebee00fba1e2e..9eee67ca3c76c 100644 --- a/site/src/modules/dashboard/useDashboard.ts +++ b/site/src/modules/dashboard/useDashboard.ts @@ -13,6 +13,10 @@ export const useDashboard = (): DashboardValue => { return context; }; +export const getDefaultOrganizationId = ( + organizations: DashboardValue["organizations"], +): string => organizations.find((org) => org.is_default)?.id ?? ""; + export const getDefaultOrganizationName = ( organizations: DashboardValue["organizations"], ): string => organizations.find((org) => org.is_default)?.name ?? ""; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx index d1f14f5f80f15..11148d461253f 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPage.tsx @@ -6,7 +6,7 @@ import { getErrorMessage } from "#/api/errors"; import { createMCPServerConfig } from "#/api/queries/chats"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { - getDefaultOrganizationName, + getDefaultOrganizationId, useDashboard, } from "#/modules/dashboard/useDashboard"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; @@ -15,7 +15,7 @@ import AddMCPServerPageView from "./AddMCPServerPageView"; const AddMCPServerPage: FC = () => { const { permissions } = useAuthenticated(); const { organizations } = useDashboard(); - const organization = getDefaultOrganizationName(organizations); + const organization = getDefaultOrganizationId(organizations); const queryClient = useQueryClient(); const navigate = useNavigate(); const createMutation = useMutation( diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx index 35fbdb7bb51e4..75c43196d9be4 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx @@ -3,7 +3,7 @@ import { useQuery } from "react-query"; import { mcpServerConfigs } from "#/api/queries/chats"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { - getDefaultOrganizationName, + getDefaultOrganizationId, useDashboard, } from "#/modules/dashboard/useDashboard"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; @@ -13,7 +13,7 @@ import MCPServersPageView from "./MCPServersPageView"; const MCPServersPage: FC = () => { const { permissions } = useAuthenticated(); const { organizations } = useDashboard(); - const organization = getDefaultOrganizationName(organizations); + const organization = getDefaultOrganizationId(organizations); const serversQuery = useQuery({ ...mcpServerConfigs(organization), enabled: Boolean(organization), diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx index 83064a94d8dd1..68b34639d8f86 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -11,7 +11,7 @@ import { import { Loader } from "#/components/Loader/Loader"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { - getDefaultOrganizationName, + getDefaultOrganizationId, useDashboard, } from "#/modules/dashboard/useDashboard"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; @@ -21,7 +21,7 @@ import UpdateMCPServerPageView from "./UpdateMCPServerPageView"; const UpdateMCPServerPage: FC = () => { const { permissions } = useAuthenticated(); const { organizations } = useDashboard(); - const organization = getDefaultOrganizationName(organizations); + const organization = getDefaultOrganizationId(organizations); const { serverId } = useParams<{ serverId: string }>(); const queryClient = useQueryClient(); const navigate = useNavigate(); diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 9c17715e2fe04..e35a0ca1f92cc 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -17,7 +17,7 @@ import { chatModelConfigs, chatModelsKey, chatPromptsKey, - mcpServersKey, + mcpServerConfigs, toChatListParams, } from "#/api/queries/chats"; import { workspaceByIdKey } from "#/api/queries/workspaces"; @@ -306,7 +306,10 @@ const buildQueries = ( }, { key: chatModelsKey, data: mockModelCatalog }, { key: chatModelConfigs().queryKey, data: mockModelConfigs }, - { key: mcpServersKey, data: opts?.mcpServers ?? [] }, + { + key: mcpServerConfigs(chat.organization_id).queryKey, + data: opts?.mcpServers ?? [], + }, buildChatAuthorizationQuery(chat, { canShareChat: { action: "share", diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index c6ececf8e0d5e..1ca9cecf020b0 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -107,6 +107,7 @@ import { workspaceSkillsFromChat } from "./components/ChatPageContent"; import { getDefaultMCPSelection, getSavedMCPSelection, + migrateLegacyMCPSelection, saveMCPSelection, } from "./components/MCPServerPicker"; import { getModelSelectorHelp } from "./components/ModelSelectorHelp"; @@ -927,6 +928,15 @@ const AgentChatPage: FC = () => { ...mcpServerConfigs(chatOrganizationId), enabled: Boolean(chatOrganizationId), }); + const isDefaultChatOrganization = organizations.some( + (organization) => + organization.id === chatOrganizationId && organization.is_default, + ); + useEffect(() => { + if (isDefaultChatOrganization && mcpServersQuery.data) { + migrateLegacyMCPSelection(chatOrganizationId, mcpServersQuery.data); + } + }, [chatOrganizationId, isDefaultChatOrganization, mcpServersQuery.data]); const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 })); const workspaceOptions = getWorkspaceOptionsWithLinkedWorkspace( workspacesQuery.data?.workspaces ?? [], @@ -1103,7 +1113,11 @@ const AgentChatPage: FC = () => { } // Check for a previously saved selection in localStorage. const saved = chatOrganizationId - ? getSavedMCPSelection(chatOrganizationId, mcpServers) + ? getSavedMCPSelection( + chatOrganizationId, + mcpServers, + isDefaultChatOrganization, + ) : null; if (saved !== null) { return saved; diff --git a/site/src/pages/AgentsPage/AgentCreatePage.tsx b/site/src/pages/AgentsPage/AgentCreatePage.tsx index be1d0bb7ee3d4..4373263e94eb3 100644 --- a/site/src/pages/AgentsPage/AgentCreatePage.tsx +++ b/site/src/pages/AgentsPage/AgentCreatePage.tsx @@ -18,7 +18,10 @@ import type * as TypesGen from "#/api/typesGenerated"; import { useWebpushNotifications } from "#/contexts/useWebpushNotifications"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useAIGatewayEnabled } from "#/hooks/useEmbeddedMetadata"; -import { useDashboard } from "#/modules/dashboard/useDashboard"; +import { + getDefaultOrganizationId, + useDashboard, +} from "#/modules/dashboard/useDashboard"; import { AgentCreateForm, type CreateChatOptions, @@ -44,11 +47,7 @@ const AgentCreatePage: FC = () => { const { permissions } = useAuthenticated(); const { organizations } = useDashboard(); const [organizationId, setOrganizationId] = useState( - () => - ( - organizations.find((organization) => organization.is_default) ?? - organizations[0] - )?.id ?? "", + () => getDefaultOrganizationId(organizations) || organizations[0]?.id || "", ); const aiGatewayDisabled = !useAIGatewayEnabled(); @@ -192,6 +191,7 @@ const AgentCreatePage: FC = () => { isModelConfigsLoading={chatModelConfigsQuery.isLoading} rootPersonalModelOverride={rootPersonalModelOverride} isPersonalModelOverridesLoading={personalModelOverridesQuery.isLoading} + mcpServersOrganizationId={organizationId} mcpServers={mcpServersQuery.data ?? []} onMCPAuthComplete={() => void mcpServersQuery.refetch()} workspaceCount={workspacesQuery.data?.count} diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index c452dd94aea35..3025af644eccd 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -19,12 +19,15 @@ import { chatMessagesKey, chatPromptsKey, } from "#/api/queries/chats"; +import { permittedOrganizations } from "#/api/queries/organizations"; import type * as TypesGen from "#/api/typesGenerated"; import type { Chat } from "#/api/typesGenerated"; import { DeleteDialog } from "#/components/Dialog/DeleteDialog/DeleteDialog"; -import { MockChat } from "#/testHelpers/chatEntities"; +import { MockChat, MockMCPServerConfig } from "#/testHelpers/chatEntities"; import { + MockDefaultOrganization, MockNoPermissions, + MockOrganization2, MockPermissions, MockUserOwner, } from "#/testHelpers/entities"; @@ -71,6 +74,20 @@ const defaultModelConfigs: TypesGen.ChatModelConfig[] = [ }, ]; +const defaultOrganizationMCPServer: TypesGen.MCPServerConfig = { + ...MockMCPServerConfig, + id: "mcp-default-organization", + display_name: "Default organization MCP", + slug: "default-organization-mcp", +}; + +const secondOrganizationMCPServer: TypesGen.MCPServerConfig = { + ...MockMCPServerConfig, + id: "mcp-second-organization", + display_name: "Second organization MCP", + slug: "second-organization-mcp", +}; + const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); const todayTimestamp = new Date().toISOString(); @@ -433,7 +450,74 @@ const mockChats = (chats: Chat[]) => { spyOn(API.experimental, "getChats").mockResolvedValue(chats); }; -export const EmptyState: Story = {}; +export const EmptyState: Story = { + play: async () => { + await waitFor(() => { + expect(API.experimental.getMCPServerConfigs).toHaveBeenCalledWith( + MockDefaultOrganization.id, + ); + }); + }, +}; + +export const OrganizationScopedMCPServers: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + queries: [ + { + key: permittedOrganizations({ + object: { resource_type: "chat" }, + action: "create", + }).queryKey, + data: [MockDefaultOrganization, MockOrganization2], + }, + ], + }, + beforeEach: () => { + spyOn(API.experimental, "getMCPServerConfigs").mockImplementation( + async (organization) => + organization === MockDefaultOrganization.id + ? [defaultOrganizationMCPServer] + : [secondOrganizationMCPServer], + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await waitFor(() => { + expect(API.experimental.getMCPServerConfigs).toHaveBeenCalledWith( + MockDefaultOrganization.id, + ); + }); + await userEvent.click(canvas.getByRole("button", { name: "More options" })); + expect( + (await body.findAllByText("Default organization MCP")).length, + ).toBeGreaterThan(0); + await userEvent.keyboard("{Escape}"); + + await userEvent.click( + canvas.getByRole("button", { + name: `Organization: ${MockDefaultOrganization.display_name}`, + }), + ); + await userEvent.click( + body.getByRole("option", { name: MockOrganization2.display_name }), + ); + await waitFor(() => { + expect(API.experimental.getMCPServerConfigs).toHaveBeenCalledWith( + MockOrganization2.id, + ); + }); + await userEvent.click(canvas.getByRole("button", { name: "More options" })); + expect( + (await body.findAllByText("Second organization MCP")).length, + ).toBeGreaterThan(0); + expect( + body.queryByText("Default organization MCP"), + ).not.toBeInTheDocument(); + }, +}; export const WithChatList: Story = { beforeEach: () => { diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index 2419f33803697..93097b1d24e99 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -773,6 +773,20 @@ export const WithMCPNeedingAuth: Story = { mcpServers: [sentryMCP, githubMCP], selectedMCPServerIds: [sentryMCP.id, githubMCP.id], }, + beforeEach: () => { + spyOn(window, "open").mockReturnValue(null); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click(canvas.getByRole("button", { name: "More options" })); + await userEvent.click(body.getByRole("button", { name: "Auth" })); + expect(window.open).toHaveBeenCalledWith( + "/api/experimental/mcp-servers/mcp-github/oauth2/connect", + "_blank", + "width=900,height=600", + ); + }, }; /** No MCP servers active — shows only "MCP" label with chevron. */ diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 59ed37449314b..ab998aaa6ee88 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -110,6 +110,7 @@ const meta: Meta = { component: AgentCreateForm, decorators: [withDashboardProvider], args: { + onOrganizationChange: fn(), onCreateChat: fn(), sendShortcut: "enter", isCreating: false, @@ -118,6 +119,7 @@ const meta: Meta = { modelCatalog: null, modelOptions: [...modelOptions], isModelCatalogLoading: false, + mcpServersOrganizationId: MockDefaultOrganization.id, modelConfigs: [], isModelConfigsLoading: false, workspaceCount: 0, @@ -911,19 +913,25 @@ export const WithOrganizationPicker: Story = { }, ], }, - play: async ({ canvasElement }) => { + play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); - const organizationPicker = canvas.getByRole("button", { - name: "Organization: My Organization", + const body = within(canvasElement.ownerDocument.body); + const organizationSelector = await canvas.findByRole("button", { + name: `Organization: ${MockDefaultOrganization.display_name}`, }); - await expect(organizationPicker).toBeVisible(); - + await userEvent.click(organizationSelector); + await userEvent.click( + await body.findByRole("option", { name: MockOrganization2.display_name }), + ); + await expect(args.onOrganizationChange).toHaveBeenCalledWith( + MockOrganization2.id, + ); const input = canvas.getByRole("textbox", { name: "Chat message" }); await userEvent.click(input); await userEvent.keyboard("hello world"); await expect( canvas.getByRole("button", { - name: "Organization: My Organization", + name: `Organization: ${MockOrganization2.display_name}`, }), ).toBeVisible(); }, diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 4769c4df5f05e..5e29a4cbf0870 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -34,6 +34,7 @@ import { CompactOrgSelector } from "./ChatElements"; import { getDefaultMCPSelection, getSavedMCPSelection, + migrateLegacyMCPSelection, saveMCPSelection, } from "./MCPServerPicker"; import { getModelSelectorHelp } from "./ModelSelectorHelp"; @@ -123,7 +124,7 @@ export function useEmptyStateDraft() { interface AgentCreateFormProps { onCreateChat: (options: CreateChatOptions) => Promise; - onOrganizationChange?: (organizationId: string) => void; + onOrganizationChange: (organizationId: string) => void; sendShortcut: AgentChatSendShortcut; isCreating: boolean; createError: unknown; @@ -140,6 +141,7 @@ interface AgentCreateFormProps { isModelConfigsLoading: boolean; rootPersonalModelOverride?: TypesGen.ChatPersonalModelOverride; isPersonalModelOverridesLoading?: boolean; + mcpServersOrganizationId: string; mcpServers?: readonly TypesGen.MCPServerConfig[]; onMCPAuthComplete?: (serverId: string) => void; workspaceCount: number | undefined; @@ -150,7 +152,7 @@ interface AgentCreateFormProps { export const AgentCreateForm: FC = ({ onCreateChat, - onOrganizationChange = () => {}, + onOrganizationChange, sendShortcut, isCreating, createError, @@ -167,6 +169,7 @@ export const AgentCreateForm: FC = ({ isModelConfigsLoading, rootPersonalModelOverride, isPersonalModelOverridesLoading = false, + mcpServersOrganizationId, mcpServers, onMCPAuthComplete, workspaceCount: _workspaceCount, @@ -337,6 +340,8 @@ export const AgentCreateForm: FC = ({ initialOrg ?? null); const organizationId = effectiveOrg?.id ?? ""; + const scopedMCPServers = + mcpServersOrganizationId === organizationId ? (mcpServers ?? []) : []; // Adopt a permitted fallback so later refetches cannot switch the form to a // re-permitted default. The permission guard also avoids a render loop. if ( @@ -410,12 +415,29 @@ export const AgentCreateForm: FC = ({ if (userMCPServerIds !== null) { return userMCPServerIds; } - const saved = getSavedMCPSelection(organizationId, mcpServers ?? []); + const saved = getSavedMCPSelection( + organizationId, + scopedMCPServers, + effectiveOrg?.is_default, + ); if (saved !== null) { return saved; } - return getDefaultMCPSelection(mcpServers ?? []); + return getDefaultMCPSelection(scopedMCPServers); })(); + useEffect(() => { + if ( + effectiveOrg?.is_default && + mcpServersOrganizationId === organizationId + ) { + migrateLegacyMCPSelection(organizationId, mcpServers ?? []); + } + }, [ + organizationId, + mcpServers, + mcpServersOrganizationId, + effectiveOrg?.is_default, + ]); const handleWorkspaceChange = (value: string | null) => { if (value === null) { setSelectedWorkspaceId(null); @@ -639,7 +661,7 @@ export const AgentCreateForm: FC = ({ uploadStates={uploadStates} previewUrls={previewUrls} textContents={textContents} - mcpServers={mcpServers} + mcpServers={scopedMCPServers} selectedMCPServerIds={effectiveMCPServerIds} onMCPSelectionChange={(ids) => { setUserMCPServerIds(ids); diff --git a/site/src/pages/AgentsPage/components/MCPServerPicker.stories.tsx b/site/src/pages/AgentsPage/components/MCPServerPicker.stories.tsx index beeb2464bb1d1..951d55d85f811 100644 --- a/site/src/pages/AgentsPage/components/MCPServerPicker.stories.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerPicker.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { fn } from "storybook/test"; +import { expect, fn, spyOn, userEvent, within } from "storybook/test"; import type * as TypesGen from "#/api/typesGenerated"; import { MockMCPServerConfig } from "#/testHelpers/chatEntities"; import { getDefaultMCPSelection, MCPServerPicker } from "./MCPServerPicker"; @@ -181,6 +181,22 @@ export const OAuthNeedsAuth: Story = { servers: [githubServer], selectedServerIds: [githubServer.id], }, + beforeEach: () => { + spyOn(window, "open").mockReturnValue(null); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click(canvas.getByRole("button", { name: "MCP servers" })); + await userEvent.click( + body.getByRole("button", { name: "Authenticate with GitHub" }), + ); + expect(window.open).toHaveBeenCalledWith( + "/api/experimental/mcp-servers/mcp-github/oauth2/connect", + "_blank", + "width=900,height=600", + ); + }, }; /** OAuth2 server already authenticated — shows check icon. */ diff --git a/site/src/pages/AgentsPage/components/MCPServerPicker.test.ts b/site/src/pages/AgentsPage/components/MCPServerPicker.test.ts index 9704556837e2a..232157d161df5 100644 --- a/site/src/pages/AgentsPage/components/MCPServerPicker.test.ts +++ b/site/src/pages/AgentsPage/components/MCPServerPicker.test.ts @@ -5,6 +5,7 @@ import { getDefaultMCPSelection, getSavedMCPSelection, mcpSelectionStorageKey, + migrateLegacyMCPSelection, saveMCPSelection, } from "./MCPServerPicker"; @@ -39,6 +40,19 @@ describe("MCP selection persistence", () => { "[]", ); }); + + it("keeps selections separate by organization", () => { + const otherOrganizationId = "organization-2"; + saveMCPSelection(organizationId, ["a"]); + saveMCPSelection(otherOrganizationId, ["b"]); + + expect(localStorage.getItem(mcpSelectionStorageKey(organizationId))).toBe( + JSON.stringify(["a"]), + ); + expect( + localStorage.getItem(mcpSelectionStorageKey(otherOrganizationId)), + ).toBe(JSON.stringify(["b"])); + }); }); describe("getSavedMCPSelection", () => { @@ -70,6 +84,42 @@ describe("MCP selection persistence", () => { expect(getSavedMCPSelection(organizationId, servers)).toBeNull(); }); + it("migrates a legacy selection for the default organization", () => { + localStorage.setItem( + "agents.selected-mcp-server-ids", + JSON.stringify(["s3"]), + ); + + expect(getSavedMCPSelection(organizationId, servers, true)).toEqual([ + "s3", + "s1", + ]); + expect( + localStorage.getItem(mcpSelectionStorageKey(organizationId)), + ).toBeNull(); + + migrateLegacyMCPSelection(organizationId, servers); + + expect(localStorage.getItem(mcpSelectionStorageKey(organizationId))).toBe( + JSON.stringify(["s3", "s1"]), + ); + expect(localStorage.getItem("agents.selected-mcp-server-ids")).toBeNull(); + }); + + it("migrates an empty legacy selection without enabling default-on servers", () => { + localStorage.setItem("agents.selected-mcp-server-ids", "[]"); + + expect(getSavedMCPSelection(organizationId, servers, true)).toEqual([ + "s1", + ]); + + migrateLegacyMCPSelection(organizationId, servers); + + expect(localStorage.getItem(mcpSelectionStorageKey(organizationId))).toBe( + JSON.stringify(["s1"]), + ); + }); + it("restores saved IDs that still exist as enabled servers", () => { saveMCPSelection(organizationId, ["s2", "s3"]); const result = getSavedMCPSelection(organizationId, servers); @@ -104,7 +154,7 @@ describe("MCP selection persistence", () => { it("does not duplicate force_on servers already in saved list", () => { saveMCPSelection(organizationId, ["s1", "s3"]); - const result = getSavedMCPSelection(organizationId, servers)!; + const result = getSavedMCPSelection(organizationId, servers) ?? []; const s1Count = result.filter((id) => id === "s1").length; expect(s1Count).toBe(1); }); diff --git a/site/src/pages/AgentsPage/components/MCPServerPicker.tsx b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx index 390b4e92b4658..3b4f0c9d09cab 100644 --- a/site/src/pages/AgentsPage/components/MCPServerPicker.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx @@ -91,9 +91,11 @@ export const getDefaultMCPSelection = ( return ids; }; +const legacyMCPSelectionStorageKey = "agents.selected-mcp-server-ids"; + /** localStorage key for persisting the user's MCP server selection. */ export const mcpSelectionStorageKey = (organizationId: string) => - `agents.selected-mcp-server-ids.${organizationId}`; + `${legacyMCPSelectionStorageKey}.${organizationId}`; /** * Read the persisted MCP selection from localStorage, filtered to only @@ -102,8 +104,12 @@ export const mcpSelectionStorageKey = (organizationId: string) => */ export const getSavedMCPSelection = ( organizationId: string, servers: readonly TypesGen.MCPServerConfig[], + readLegacy = false, ): string[] | null => { - const raw = localStorage.getItem(mcpSelectionStorageKey(organizationId)); + let raw = localStorage.getItem(mcpSelectionStorageKey(organizationId)); + if (raw === null && readLegacy) { + raw = localStorage.getItem(legacyMCPSelectionStorageKey); + } if (raw === null) { return null; } @@ -143,9 +149,7 @@ export const mcpSelectionStorageKey = (organizationId: string) => } }; -/** - * Persist the current MCP selection to localStorage. - */ export const saveMCPSelection = ( +export const saveMCPSelection = ( organizationId: string, ids: readonly string[], ): void => { @@ -155,6 +159,25 @@ export const mcpSelectionStorageKey = (organizationId: string) => ); }; +export const migrateLegacyMCPSelection = ( + organizationId: string, + servers: readonly TypesGen.MCPServerConfig[], +): void => { + const storageKey = mcpSelectionStorageKey(organizationId); + if ( + localStorage.getItem(storageKey) !== null || + localStorage.getItem(legacyMCPSelectionStorageKey) === null + ) { + return; + } + const selection = getSavedMCPSelection(organizationId, servers, true); + if (selection === null) { + return; + } + saveMCPSelection(organizationId, selection); + localStorage.removeItem(legacyMCPSelectionStorageKey); +}; + // ── Overlapping icon stack for the trigger ───────────────────── const ICON_STACK_MAX = 3; From 7506ba40b90043b162f7f8c59b05bcfa1d5859e1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:29:58 +0000 Subject: [PATCH 08/59] fix(coderd/database): align GetAuthorizedMCPServerConfigs with authorized query convention --- coderd/database/dbauthz/dbauthz.go | 9 +++------ coderd/database/dbauthz/dbauthz_test.go | 15 ++++++-------- coderd/database/dbmetrics/querymetrics.go | 4 ++-- coderd/database/dbmock/dbmock.go | 8 ++++---- coderd/database/modelqueries.go | 13 ++++-------- coderd/x/chatd/chatd_test.go | 22 +++++++++++---------- enterprise/dbcrypt/dbcrypt.go | 5 +++-- enterprise/dbcrypt/dbcrypt_internal_test.go | 5 +---- 8 files changed, 35 insertions(+), 46 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index ed65c42a0c637..f5d5f58e1ac6d 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -4064,10 +4064,7 @@ func (q *querier) GetMCPServerConfigsByOrganization(ctx context.Context, organiz if err != nil { return nil, xerrors.Errorf("prepare sql filter: %w", err) } - return q.db.GetAuthorizedMCPServerConfigs(ctx, database.GetAuthorizedMCPServerConfigsParams{ - OrganizationID: organizationID, - Prepared: prepared, - }) + return q.db.GetAuthorizedMCPServerConfigs(ctx, organizationID, prepared) } func (q *querier) GetMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg database.GetMCPServerConfigsByOrganizationAndIDsParams) ([]database.MCPServerConfig, error) { @@ -9399,6 +9396,6 @@ func (q *querier) GetAuthorizedChatsByChatFileID(ctx context.Context, fileID uui return q.db.GetAuthorizedChatsByChatFileID(ctx, fileID, prepared) } -func (q *querier) GetAuthorizedMCPServerConfigs(ctx context.Context, arg database.GetAuthorizedMCPServerConfigsParams) ([]database.MCPServerConfig, error) { - return q.db.GetAuthorizedMCPServerConfigs(ctx, arg) +func (q *querier) GetAuthorizedMCPServerConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) { + return q.db.GetAuthorizedMCPServerConfigs(ctx, organizationID, prepared) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index caa922dd30eec..3ad8c44d5a3f6 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1678,18 +1678,15 @@ func (s *MethodTestSuite) TestChats() { orgID := uuid.New() configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: orgID}) configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: orgID}) - dbm.EXPECT().GetAuthorizedMCPServerConfigs(gomock.Any(), gomock.Any()).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() + dbm.EXPECT().GetAuthorizedMCPServerConfigs(gomock.Any(), orgID, gomock.Any()).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() check.Args(orgID).Asserts().Returns([]database.MCPServerConfig{configA, configB}) })) s.Run("GetAuthorizedMCPServerConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - arg := database.GetAuthorizedMCPServerConfigsParams{ - OrganizationID: uuid.New(), - Prepared: emptyPreparedAuthorized{}, - } - configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: arg.OrganizationID}) - configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: arg.OrganizationID}) - dbm.EXPECT().GetAuthorizedMCPServerConfigs(gomock.Any(), arg).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() - check.Args(arg).Asserts().Returns([]database.MCPServerConfig{configA, configB}) + orgID := uuid.New() + configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: orgID}) + configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: orgID}) + dbm.EXPECT().GetAuthorizedMCPServerConfigs(gomock.Any(), orgID, gomock.Any()).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() + check.Args(orgID, emptyPreparedAuthorized{}).Asserts().Returns([]database.MCPServerConfig{configA, configB}) })) s.Run("GetMCPServerConfigsByOrganizationAndIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { arg := database.GetMCPServerConfigsByOrganizationAndIDsParams{ diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index d25e654fc1ac0..fddcbb38dabb2 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -6793,9 +6793,9 @@ func (m queryMetricsStore) GetAuthorizedChatsByChatFileID(ctx context.Context, f return r0, r1 } -func (m queryMetricsStore) GetAuthorizedMCPServerConfigs(ctx context.Context, arg database.GetAuthorizedMCPServerConfigsParams) ([]database.MCPServerConfig, error) { +func (m queryMetricsStore) GetAuthorizedMCPServerConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) { start := time.Now() - r0, r1 := m.s.GetAuthorizedMCPServerConfigs(ctx, arg) + r0, r1 := m.s.GetAuthorizedMCPServerConfigs(ctx, organizationID, prepared) m.queryLatencies.WithLabelValues("GetAuthorizedMCPServerConfigs").Observe(time.Since(start).Seconds()) m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAuthorizedMCPServerConfigs").Inc() return r0, r1 diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 86bae961787af..44e47f653caba 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2506,18 +2506,18 @@ func (mr *MockStoreMockRecorder) GetAuthorizedConnectionLogsOffset(ctx, arg, pre } // GetAuthorizedMCPServerConfigs mocks base method. -func (m *MockStore) GetAuthorizedMCPServerConfigs(ctx context.Context, arg database.GetAuthorizedMCPServerConfigsParams) ([]database.MCPServerConfig, error) { +func (m *MockStore) GetAuthorizedMCPServerConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAuthorizedMCPServerConfigs", ctx, arg) + ret := m.ctrl.Call(m, "GetAuthorizedMCPServerConfigs", ctx, organizationID, prepared) ret0, _ := ret[0].([]database.MCPServerConfig) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAuthorizedMCPServerConfigs indicates an expected call of GetAuthorizedMCPServerConfigs. -func (mr *MockStoreMockRecorder) GetAuthorizedMCPServerConfigs(ctx, arg any) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAuthorizedMCPServerConfigs(ctx, organizationID, prepared any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedMCPServerConfigs", reflect.TypeOf((*MockStore)(nil).GetAuthorizedMCPServerConfigs), ctx, arg) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedMCPServerConfigs", reflect.TypeOf((*MockStore)(nil).GetAuthorizedMCPServerConfigs), ctx, organizationID, prepared) } // GetAuthorizedTemplates mocks base method. diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 1da24c391d69a..dbdaa647ea03e 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -1201,17 +1201,12 @@ func (q *sqlQuerier) UpdateUserLinkRawJSON(ctx context.Context, userID uuid.UUID return err } -type GetAuthorizedMCPServerConfigsParams struct { - OrganizationID uuid.UUID - Prepared rbac.PreparedAuthorized -} - type mcpServerConfigQuerier interface { - GetAuthorizedMCPServerConfigs(ctx context.Context, arg GetAuthorizedMCPServerConfigsParams) ([]MCPServerConfig, error) + GetAuthorizedMCPServerConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]MCPServerConfig, error) } -func (q *sqlQuerier) GetAuthorizedMCPServerConfigs(ctx context.Context, arg GetAuthorizedMCPServerConfigsParams) ([]MCPServerConfig, error) { - authorizedFilter, err := arg.Prepared.CompileToSQL(ctx, regosql.ConvertConfig{ +func (q *sqlQuerier) GetAuthorizedMCPServerConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]MCPServerConfig, error) { + authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{ VariableConverter: regosql.MCPServerConfigNoACLConverter(), }) if err != nil { @@ -1225,7 +1220,7 @@ func (q *sqlQuerier) GetAuthorizedMCPServerConfigs(ctx context.Context, arg GetA // The name comment is for metric tracking query := fmt.Sprintf("-- name: GetAuthorizedMCPServerConfigs :many\n%s", filtered) - rows, err := q.db.QueryContext(ctx, query, arg.OrganizationID) + rows, err := q.db.QueryContext(ctx, query, organizationID) if err != nil { return nil, err } diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 033f77d876f27..3a0bc2d137e1c 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -1061,18 +1061,20 @@ func TestExploreChatSendMessageCannotMutateMCPSnapshot(t *testing.T) { user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) parentConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "Runtime Parent MCP", - Slug: "runtime-parent-mcp", - Url: parentTS.URL, - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "Runtime Parent MCP", + Slug: "runtime-parent-mcp", + Url: parentTS.URL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) injectedConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "Runtime Injected MCP", - Slug: "runtime-injected-mcp", - Url: injectedTS.URL, - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "Runtime Injected MCP", + Slug: "runtime-injected-mcp", + Url: injectedTS.URL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) factory := chattest.NewMockAIBridgeTransport(t, openAIURL) diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index f9b1bbc03a0dc..a9a70c9ea65c3 100644 --- a/enterprise/dbcrypt/dbcrypt.go +++ b/enterprise/dbcrypt/dbcrypt.go @@ -11,6 +11,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/rbac" ) // testValue is the value that is stored in dbcrypt_keys.test. @@ -763,8 +764,8 @@ func (db *dbCrypt) GetEnabledMCPServerConfigsByOrganization(ctx context.Context, return cfgs, nil } -func (db *dbCrypt) GetAuthorizedMCPServerConfigs(ctx context.Context, arg database.GetAuthorizedMCPServerConfigsParams) ([]database.MCPServerConfig, error) { - cfgs, err := db.Store.GetAuthorizedMCPServerConfigs(ctx, arg) +func (db *dbCrypt) GetAuthorizedMCPServerConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) { + cfgs, err := db.Store.GetAuthorizedMCPServerConfigs(ctx, organizationID, prepared) if err != nil { return nil, err } diff --git a/enterprise/dbcrypt/dbcrypt_internal_test.go b/enterprise/dbcrypt/dbcrypt_internal_test.go index 9c48b34f46db6..4cdd73a955eb0 100644 --- a/enterprise/dbcrypt/dbcrypt_internal_test.go +++ b/enterprise/dbcrypt/dbcrypt_internal_test.go @@ -1012,10 +1012,7 @@ func TestMCPServerConfigs(t *testing.T) { db, crypt, ciphers := setup(t) cfg := insertConfig(t, crypt, ciphers) - cfgs, err := crypt.GetAuthorizedMCPServerConfigs(ctx, database.GetAuthorizedMCPServerConfigsParams{ - OrganizationID: cfg.OrganizationID, - Prepared: allowAllPreparedAuthorized{}, - }) + cfgs, err := crypt.GetAuthorizedMCPServerConfigs(ctx, cfg.OrganizationID, allowAllPreparedAuthorized{}) require.NoError(t, err) require.Len(t, cfgs, 1) require.Equal(t, cfg.ID, cfgs[0].ID) From 5076bb11989b5d4db2ec360681ae9c766a659611 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:11:24 +0000 Subject: [PATCH 09/59] docs(docs/ai-coder): describe org-scoped MCP servers --- .../agents/platform-controls/mcp-servers.md | 25 +++++++++++-------- docs/ai-coder/best-practices.md | 2 +- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/ai-coder/agents/platform-controls/mcp-servers.md b/docs/ai-coder/agents/platform-controls/mcp-servers.md index e957f09d2fc6d..a61f19a9b35cd 100644 --- a/docs/ai-coder/agents/platform-controls/mcp-servers.md +++ b/docs/ai-coder/agents/platform-controls/mcp-servers.md @@ -1,11 +1,14 @@ # MCP Servers -Administrators can register external MCP servers that provide additional tools -for agent chat sessions. Configured servers are injected into or offered to -users during chat depending on the availability policy. +Organization admins can register external MCP servers that provide additional +tools for agent chat sessions. Each organization has its own set of MCP +servers, and chats only offer servers from the chat's organization. Configured +servers are injected into or offered to users during chat depending on the +availability policy. This is an admin-only feature accessible at **AI Settings** > **Coder Agents** > **MCP servers** -(`/ai/settings/mcp-servers`). +(`/ai/settings/mcp-servers`). The settings page currently manages the default +organization's servers; other organizations are managed through the API. ## Add an MCP server @@ -165,11 +168,11 @@ wins. ## Permissions -| Action | Required role | -|-------------------------------|---------------------------| -| Create, update, or delete | Admin (deployment config) | -| View enabled servers | Any authenticated user | -| OAuth2 connect and disconnect | Any authenticated user | +| Action | Required role | +|-------------------------------|---------------------| +| Create, update, or delete | Organization admin | +| View enabled servers | Organization member | +| OAuth2 connect and disconnect | Organization member | -Non-admin users only see enabled servers. Sensitive fields such as API keys -and client secrets are redacted in API responses. +Members only see enabled servers in their own organizations. Sensitive fields +such as API keys and client secrets are redacted in API responses. diff --git a/docs/ai-coder/best-practices.md b/docs/ai-coder/best-practices.md index 5208c9c342a13..f9e7f363e094a 100644 --- a/docs/ai-coder/best-practices.md +++ b/docs/ai-coder/best-practices.md @@ -20,7 +20,7 @@ Below are common scenarios where AI coding agents provide the most impact, along While LLMs are trained on general knowledge, it's important to provide additional context to help agents understand your codebase and organization. -For [Coder Agents](./agents/index.md), context comes from a few complementary places. Platform admins configure a [system prompt](./agents/platform-controls/index.md) that applies to every chat and register [MCP servers](./agents/platform-controls/mcp-servers.md) once for the whole deployment. Repos and workspace templates can ship reusable [skills](./agents/extending-agents.md) under `.agents/skills/`, which the agent discovers automatically when it attaches to the workspace. Developers don't need to manage memory files or wire up tools themselves. +For [Coder Agents](./agents/index.md), context comes from a few complementary places. Platform admins configure a [system prompt](./agents/platform-controls/index.md) that applies to every chat and register [MCP servers](./agents/platform-controls/mcp-servers.md) per organization. Repos and workspace templates can ship reusable [skills](./agents/extending-agents.md) under `.agents/skills/`, which the agent discovers automatically when it attaches to the workspace. Developers don't need to manage memory files or wire up tools themselves. The rest of this section covers patterns for agents you run yourself inside a workspace, such as Claude Code or Codex. From dde0e7de86829ef7a6002b9761fe517a627f32e9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:36:39 +0000 Subject: [PATCH 10/59] chore: polish CODAGT-711 diff and drop default-org fallback remnants --- coderd/database/dbauthz/dbauthz.go | 5 - coderd/database/dbauthz/dbauthz_test.go | 12 +- ..._mcp_server_configs_organization_id.up.sql | 8 +- coderd/exp_chats.go | 29 +++-- coderd/exp_chats_test.go | 9 -- coderd/httpmw/mcpserverconfigparam.go | 7 +- coderd/mcp.go | 5 +- coderd/rbac/regosql/configs.go | 8 +- coderd/rbac/roles.go | 19 +-- coderd/rbac/roles_test.go | 3 - coderd/x/chatd/chatd_test.go | 61 ++++++---- coderd/x/chatd/generation_preparer.go | 12 +- .../generation_preparer_internal_test.go | 84 +++----------- coderd/x/chatd/subagent_internal_test.go | 58 +++++----- .../MCPServersPage/MCPServersPage.stories.tsx | 109 ++++++++++++++++++ site/src/pages/AgentsPage/AgentCreatePage.tsx | 17 --- .../components/AgentCreateForm.stories.tsx | 18 ++- .../AgentsPage/components/AgentCreateForm.tsx | 44 +++---- 18 files changed, 256 insertions(+), 252 deletions(-) create mode 100644 site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f5d5f58e1ac6d..5a1b6e1cbac89 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -796,11 +796,6 @@ var ( rbac.ResourceDeploymentConfig.Type: {policy.ActionRead}, rbac.ResourceMCPServerConfig.Type: {policy.ActionRead}, rbac.ResourceUser.Type: {policy.ActionReadPersonal}, - // TODO(mafredri): remove after CODAGT-711 B3 - // (org-scoping cutover). The chat-org-then-default-org - // fallback for MCP server configs resolves the default - // organization under the chatd subject. - rbac.ResourceOrganization.Type: {policy.ActionRead}, }), User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{}, diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 3ad8c44d5a3f6..1c1d05a90796d 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -7583,13 +7583,6 @@ func TestAsChatd(t *testing.T) { err = auth.Authorize(ctx, actor, policy.ActionUpdate, rbac.ResourceDeploymentConfig) require.Error(t, err, "deployment config update should not be allowed") - // Organization read (needed for the MCP server config - // chat-org-then-default-org fallback). - err = auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceOrganization) - require.NoError(t, err, "organization read should be allowed") - err = auth.Authorize(ctx, actor, policy.ActionUpdate, rbac.ResourceOrganization) - require.Error(t, err, "organization update should not be allowed") - // User read_personal (needed for GetUserChatCustomPrompt). err = auth.Authorize(ctx, actor, policy.ActionReadPersonal, rbac.ResourceUser) require.NoError(t, err, "user read_personal should be allowed") @@ -7613,6 +7606,11 @@ func TestAsChatd(t *testing.T) { // Cannot access provisioner daemons. err = auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceProvisionerDaemon) require.Error(t, err, "provisioner daemon read should be denied") + + // Cannot access organizations; MCP server config resolution is + // strictly org-scoped and needs no organization reads. + err = auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceOrganization) + require.Error(t, err, "organization read should be denied") }) } diff --git a/coderd/database/migrations/testdata/fixtures/000565_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/testdata/fixtures/000565_mcp_server_configs_organization_id.up.sql index ac5c3a05445a5..0b8795d7186a3 100644 --- a/coderd/database/migrations/testdata/fixtures/000565_mcp_server_configs_organization_id.up.sql +++ b/coderd/database/migrations/testdata/fixtures/000565_mcp_server_configs_organization_id.up.sql @@ -1,8 +1,6 @@ --- Exercises the 000561 org column: an MCP server config (with a user token --- and a chat referencing it) carries the new organization_id, keeping later --- migrations and the final down sweep honest about the FK. The row is --- inserted at 000561 with organization_id already set because fixtures run --- after the migration of the same version. +-- Keeps an MCP config, token, and chat to exercise organization_id and its +-- foreign keys through later migrations and the down sweep. Fixtures run +-- after their matching migration, so this row already has organization_id. INSERT INTO organizations ( id, diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index e2f1e6d98df9a..86a660b5b4d23 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -1286,6 +1286,17 @@ func validateChatMCPServerIDs( return unique, invalid, nil } +func invalidChatMCPServerIDsResponse(ids []uuid.UUID) codersdk.Response { + invalid := make([]string, 0, len(ids)) + for _, id := range ids { + invalid = append(invalid, id.String()) + } + return codersdk.Response{ + Message: "One or more MCP server IDs are invalid or disabled.", + Detail: fmt.Sprintf("Invalid IDs: %s", strings.Join(invalid, ", ")), + } +} + // EXPERIMENTAL: this endpoint is experimental and is subject to change. // // @Summary Create chat @@ -1389,14 +1400,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { } req.MCPServerIDs = normalizedMCPServerIDs if len(invalidMCPServerIDs) > 0 { - invalid := make([]string, 0, len(invalidMCPServerIDs)) - for _, id := range invalidMCPServerIDs { - invalid = append(invalid, id.String()) - } - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "One or more MCP server IDs are invalid or disabled.", - Detail: fmt.Sprintf("Invalid IDs: %s", strings.Join(invalid, ", ")), - }) + httpapi.Write(ctx, rw, http.StatusBadRequest, invalidChatMCPServerIDsResponse(invalidMCPServerIDs)) return } @@ -2779,14 +2783,7 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { } req.MCPServerIDs = &normalizedMCPServerIDs if len(invalidMCPServerIDs) > 0 { - invalid := make([]string, 0, len(invalidMCPServerIDs)) - for _, id := range invalidMCPServerIDs { - invalid = append(invalid, id.String()) - } - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "One or more MCP server IDs are invalid or disabled.", - Detail: fmt.Sprintf("Invalid IDs: %s", strings.Join(invalid, ", ")), - }) + httpapi.Write(ctx, rw, http.StatusBadRequest, invalidChatMCPServerIDsResponse(invalidMCPServerIDs)) return } } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 363c7984e25fb..59e327a1b6b0e 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -578,8 +578,6 @@ func TestPostChats(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, client.Client) _ = createChatModelConfig(t, client) - // The chat lives in a second organization; an enabled config in - // the default organization is out of scope for it. defaultOrgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ OrganizationID: firstUser.OrganizationID, Enabled: true, @@ -620,7 +618,6 @@ func TestPostChats(t *testing.T) { memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, secondOrg.ID, rbac.ScopedRoleAgentsAccess(secondOrg.ID)) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - // Duplicate valid IDs are deduplicated, not rejected. chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: secondOrg.ID, Content: []codersdk.ChatInputPart{ @@ -658,8 +655,6 @@ func TestPostChats(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, client.Client) _ = createChatModelConfig(t, client) - // A disabled config in the chat's own organization is not - // selectable at create or update time. enabledCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ OrganizationID: firstUser.OrganizationID, Enabled: true, @@ -686,7 +681,6 @@ func TestPostChats(t *testing.T) { require.Equal(t, "One or more MCP server IDs are invalid or disabled.", sdkErr.Message) require.Equal(t, "Invalid IDs: "+disabledCfg.ID.String(), sdkErr.Detail) - // The message-update validation path rejects it too. chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, Content: []codersdk.ChatInputPart{ @@ -720,9 +714,6 @@ func TestPostChats(t *testing.T) { coderdtest.CreateFirstUser(t, client.Client) _ = createChatModelConfig(t, client) - // The enabled config belongs to a third organization: neither the - // chat's organization nor the default organization, so the create - // must reject it. thirdOrg := dbgen.Organization(t, db, database.Organization{}) thirdOrgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ OrganizationID: thirdOrg.ID, diff --git a/coderd/httpmw/mcpserverconfigparam.go b/coderd/httpmw/mcpserverconfigparam.go index 2248eb87d5120..5ac107669bf6f 100644 --- a/coderd/httpmw/mcpserverconfigparam.go +++ b/coderd/httpmw/mcpserverconfigparam.go @@ -21,10 +21,9 @@ func MCPServerConfigParam(r *http.Request) database.MCPServerConfig { return config } -// ExtractMCPServerConfigParam grabs an MCP server config from the -// "mcpserverconfig" URL parameter. dbauthz conceals unauthorized reads -// as not-found, so read-denied callers receive the same 404 as a -// missing row. +// ExtractMCPServerConfigParam reads the "mcpserverconfig" URL parameter. +// Unauthorized reads are concealed as not found, so denied and missing rows +// both return 404. func ExtractMCPServerConfigParam(db database.Store) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { diff --git a/coderd/mcp.go b/coderd/mcp.go index 37dcbe15ffd44..6374c7dc6bdbd 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -551,10 +551,7 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, sdkConfig) } -// getMCPServerConfigForMutation returns the config resolved by the -// param middleware after checking the write action. The middleware -// already concealed read-denied as 404, so a write denial here is an -// explicit 403. +// Preserve the param middleware's 404 concealment. Write denial is a 403. func (api *API) getMCPServerConfigForMutation(rw http.ResponseWriter, r *http.Request, action policy.Action) (database.MCPServerConfig, bool) { config := httpmw.MCPServerConfigParam(r) if !api.Authorize(r, action, config) { diff --git a/coderd/rbac/regosql/configs.go b/coderd/rbac/regosql/configs.go index 6aa0da0ef2c1a..0414707fe6c70 100644 --- a/coderd/rbac/regosql/configs.go +++ b/coderd/rbac/regosql/configs.go @@ -74,15 +74,13 @@ func ChatNoACLConverter() *sqltypes.VariableConverter { return matcher } -// MCPServerConfigNoACLConverter converts MCP server config permissions to -// SQL. The table carries no ACL columns yet (sharing lands with them), so -// ACL matchers are always false and only org scoping filters rows. +// MCPServerConfigNoACLConverter converts MCP server config permissions to SQL. +// Until sharing adds ACL columns, ACL matchers stay false and only organization +// ownership filters rows. func MCPServerConfigNoACLConverter() *sqltypes.VariableConverter { matcher := sqltypes.NewVariableConverter().RegisterMatcher( resourceIDMatcher(), organizationOwnerMatcher(), - // MCP server configs have no user owner, only owner by an - // organization. sqltypes.AlwaysFalse(userOwnerMatcher()), ) matcher.RegisterMatcher( diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index a0f6b347b71d0..4798fefccfcd2 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -476,9 +476,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // Allow auditors to query deployment stats and insights. ResourceDeploymentStats.Type: {policy.ActionRead}, ResourceDeploymentConfig.Type: {policy.ActionRead}, - // Allow auditors to read MCP server configs (redacted through - // the HTTP convert layer), matching their deployment config read. - ResourceMCPServerConfig.Type: {policy.ActionRead}, + ResourceMCPServerConfig.Type: {policy.ActionRead}, // Allow auditors to query AI Bridge interceptions. ResourceAibridgeInterception.Type: {policy.ActionRead}, // Allow auditors to read boundary logs. @@ -614,9 +612,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ResourceGroupMember.Type: {policy.ActionRead}, ResourceOrganization.Type: {policy.ActionRead}, ResourceOrganizationMember.Type: {policy.ActionRead}, - // Organization auditors can read their organization's MCP - // server configs (redacted through the HTTP convert layer). - ResourceMCPServerConfig.Type: {policy.ActionRead}, + ResourceMCPServerConfig.Type: {policy.ActionRead}, }), Member: []Permission{}, }, @@ -1163,9 +1159,8 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions { ResourceOrganization.Type: {policy.ActionRead}, // Can read available roles. ResourceAssignOrgRole.Type: {policy.ActionRead}, - // TODO(mafredri): remove after CODAGT-711 B4 (org-scoping cutover). - // Members read MCP server configs so chats can attach them; B4 - // replaces this grant with the everyone-ACL on each config. + // TODO(mafredri): Remove once CODAGT-712 replaces this grant with + // per-config ACL evaluation. ResourceMCPServerConfig.Type: {policy.ActionRead}, } @@ -1244,10 +1239,8 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions { ResourceOrganization.Type: {policy.ActionRead}, // Can read available roles. ResourceAssignOrgRole.Type: {policy.ActionRead}, - // TODO(mafredri): remove after CODAGT-711 B4 (org-scoping cutover). - // Service accounts read MCP server configs so chats can attach - // them; B4 replaces this grant with the everyone-ACL on each - // config. + // TODO(mafredri): Remove once CODAGT-712 replaces this grant with + // per-config ACL evaluation. ResourceMCPServerConfig.Type: {policy.ActionRead}, } diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 4714c1fe10735..fae1db672a46f 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -825,9 +825,6 @@ func TestRolePermissions(t *testing.T) { }, }, { - // MCP server config read: owner, both auditors, and (during - // the staged window until B4 swaps in the everyone-ACL) org - // admins, org members, and service accounts. Name: "MCPServerConfigRead", Actions: []policy.Action{policy.ActionRead}, Resource: rbac.ResourceMCPServerConfig.WithID(uuid.New()).InOrg(orgID), diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 3a0bc2d137e1c..fcbe238f7bc9a 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -732,18 +732,20 @@ func TestExploreChatUsesPersistedMCPSnapshot(t *testing.T) { }, ) mcpConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "External Snapshot MCP", - Slug: "external-snapshot-mcp", - Url: externalMCPServer.URL, - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "External Snapshot MCP", + Slug: "external-snapshot-mcp", + Url: externalMCPServer.URL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "Second MCP", - Slug: "second-mcp", - Url: secondMCPServer.URL, - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "Second MCP", + Slug: "second-mcp", + Url: secondMCPServer.URL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) @@ -866,11 +868,12 @@ func TestRootExploreChatStaysBuiltinOnlyAtRuntime(t *testing.T) { user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) mcpConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "Root Explore Runtime MCP", - Slug: "root-explore-runtime-mcp", - Url: externalMCPServer.URL, - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "Root Explore Runtime MCP", + Slug: "root-explore-runtime-mcp", + Url: externalMCPServer.URL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) factory := chattest.NewMockAIBridgeTransport(t, openAIURL) @@ -1193,6 +1196,7 @@ func TestPlanModeRootChatAllowsApprovedExternalMCPTools(t *testing.T) { user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) approvedConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: org.ID, DisplayName: "Plan Approved MCP", Slug: "plan-approved-mcp", Url: echoTS.URL, @@ -1202,14 +1206,16 @@ func TestPlanModeRootChatAllowsApprovedExternalMCPTools(t *testing.T) { }) blockedConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "Plan Blocked MCP", - Slug: "plan-blocked-mcp", - Url: echoTS.URL, - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "Plan Blocked MCP", + Slug: "plan-blocked-mcp", + Url: echoTS.URL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) filteredConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: org.ID, DisplayName: "Plan Filtered MCP", Slug: "plan-filtered-mcp", Url: filteredTS.URL, @@ -10905,11 +10911,12 @@ func TestMCPServerToolInvocation(t *testing.T) { // happen after seedChatDependencies so user.ID exists for // the foreign key. mcpConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "Test MCP", - Slug: "test-mcp", - Url: mcpTS.URL, - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "Test MCP", + Slug: "test-mcp", + Url: mcpTS.URL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) @@ -11067,6 +11074,7 @@ func TestPlanModeRootChatApprovedExternalMCPToolInvocation(t *testing.T) { user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) mcpConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: org.ID, DisplayName: "Plan Mode MCP", Slug: "plan-mode-mcp", Url: mcpTS.URL, @@ -11166,6 +11174,7 @@ func TestPlanModeRootChatApprovedExternalMCPWorkflowCanReachProposePlan(t *testi user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) mcpConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: org.ID, DisplayName: "Plan Workflow MCP", Slug: "plan-workflow-mcp", Url: mcpTS.URL, @@ -11366,6 +11375,7 @@ func TestMCPServerOAuth2TokenRefresh(t *testing.T) { // Seed the MCP server config with OAuth2 auth pointing to our // mock token endpoint. mcpConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: org.ID, DisplayName: "Authed MCP", Slug: "authed-mcp", Url: mcpTS.URL, @@ -11494,6 +11504,7 @@ func TestMCPServerOAuth2TokenRefreshFailureGraceful(t *testing.T) { user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) mcpConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: org.ID, DisplayName: "Broken MCP", Slug: "broken-mcp", Url: "http://127.0.0.1:0/does-not-exist", diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index afbbc1a6ba689..0563a8d881f8c 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -911,22 +911,14 @@ func latestAssistantText(messages []database.ChatMessage) string { return "" } -// enabledMCPServerConfigsForChatOrg returns the requested MCP server -// configs that a chat in the given organization may use at generation time: -// those that belong to the chat's organization OR to the default -// organization and are enabled. The enabled filter is applied here in Go, -// mirroring the MCP client's connect-time skip of disabled configs -// (mcpclient.go), so the fetch shape matches pre-org-scoping behavior. +// Returns enabled requested configs visible to the chat organization. Filtering +// here preserves the pre-org-scoping behavior of skipping disabled configs. func enabledMCPServerConfigsForChatOrg( ctx context.Context, db database.Store, organizationID uuid.UUID, ids []uuid.UUID, ) ([]database.MCPServerConfig, error) { - if len(ids) == 0 { - return []database.MCPServerConfig{}, nil - } - configs, err := db.GetMCPServerConfigsByOrganizationAndIDs(ctx, database.GetMCPServerConfigsByOrganizationAndIDsParams{ OrganizationID: organizationID, IDs: ids, diff --git a/coderd/x/chatd/generation_preparer_internal_test.go b/coderd/x/chatd/generation_preparer_internal_test.go index 815da25d42603..0813bff6cc629 100644 --- a/coderd/x/chatd/generation_preparer_internal_test.go +++ b/coderd/x/chatd/generation_preparer_internal_test.go @@ -636,7 +636,7 @@ func TestEnabledMCPServerConfigsForChatOrg(t *testing.T) { return org, cfg } - t.Run("ChatOrgAndDefaultOrgFallback", func(t *testing.T) { + t.Run("DefaultOrgConfigExcluded", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitShort) @@ -644,10 +644,8 @@ func TestEnabledMCPServerConfigsForChatOrg(t *testing.T) { defaultOrg, err := db.GetDefaultOrganization(ctx) require.NoError(t, err) - // The chat lives in a non-default organization. Both of its MCP - // server configs live elsewhere: one in its own organization and - // one in the default organization. During the fallback window both - // must resolve. + // Configs resolve strictly against the chat's organization; the + // default organization gets no special treatment. chatOrg, chatOrgCfg := newOrgWithConfig(t, db, true) defaultOrgCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ OrganizationID: defaultOrg.ID, @@ -656,13 +654,8 @@ func TestEnabledMCPServerConfigsForChatOrg(t *testing.T) { configs, err := enabledMCPServerConfigsForChatOrg(ctx, db, chatOrg.ID, []uuid.UUID{chatOrgCfg.ID, defaultOrgCfg.ID}) require.NoError(t, err) - require.Len(t, configs, 2) - gotIDs := map[uuid.UUID]struct{}{} - for _, cfg := range configs { - gotIDs[cfg.ID] = struct{}{} - } - require.Contains(t, gotIDs, chatOrgCfg.ID) - require.Contains(t, gotIDs, defaultOrgCfg.ID) + require.Len(t, configs, 1) + require.Equal(t, chatOrgCfg.ID, configs[0].ID) }) t.Run("ThirdOrgConfigExcluded", func(t *testing.T) { @@ -673,8 +666,6 @@ func TestEnabledMCPServerConfigsForChatOrg(t *testing.T) { chatOrg, chatOrgCfg := newOrgWithConfig(t, db, true) _, foreignCfg := newOrgWithConfig(t, db, true) - // A config belonging to a third organization is not usable by the - // chat, while its own organization's config still resolves. configs, err := enabledMCPServerConfigsForChatOrg(ctx, db, chatOrg.ID, []uuid.UUID{chatOrgCfg.ID, foreignCfg.ID}) require.NoError(t, err) require.Len(t, configs, 1) @@ -716,36 +707,31 @@ func TestEnabledMCPServerConfigsForChatOrg(t *testing.T) { db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitShort) - defaultOrg, err := db.GetDefaultOrganization(ctx) - require.NoError(t, err) - - // A legacy/hostile chats.mcp_server_ids array can contain the - // same ID twice (the column has no uniqueness constraint). - // Pre-org-scoping the SQL returned one row per unique ID, in - // display_name order; the generation helper must preserve that shape. - chatOrg, chatOrgCfg := newOrgWithConfig(t, db, true) - defaultOrgCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - OrganizationID: defaultOrg.ID, + // The array has no uniqueness constraint. Preserve the legacy SQL shape: + // one row per unique ID, ordered by display_name. + chatOrg, cfgA := newOrgWithConfig(t, db, true) + cfgB := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: chatOrg.ID, Enabled: true, }) // The requested order is the reverse of display_name order to // prove the output ordering comes from the SQL, not the request. - requested := []uuid.UUID{defaultOrgCfg.ID, chatOrgCfg.ID, defaultOrgCfg.ID, chatOrgCfg.ID} + requested := []uuid.UUID{cfgB.ID, cfgA.ID, cfgB.ID, cfgA.ID} configs, err := enabledMCPServerConfigsForChatOrg(ctx, db, chatOrg.ID, requested) require.NoError(t, err) require.Len(t, configs, 2) gotIDs := []uuid.UUID{configs[0].ID, configs[1].ID} - require.ElementsMatch(t, []uuid.UUID{chatOrgCfg.ID, defaultOrgCfg.ID}, gotIDs) - wantOrder := []uuid.UUID{chatOrgCfg.ID, defaultOrgCfg.ID} - if chatOrgCfg.DisplayName > defaultOrgCfg.DisplayName { - wantOrder = []uuid.UUID{defaultOrgCfg.ID, chatOrgCfg.ID} + require.ElementsMatch(t, []uuid.UUID{cfgA.ID, cfgB.ID}, gotIDs) + wantOrder := []uuid.UUID{cfgA.ID, cfgB.ID} + if cfgA.DisplayName > cfgB.DisplayName { + wantOrder = []uuid.UUID{cfgB.ID, cfgA.ID} } require.Equal(t, wantOrder, gotIDs, "output must follow display_name order, not request order") }) - t.Run("ChatOrgWithNoConfigsFallsBackToDefaultOrg", func(t *testing.T) { + t.Run("ChatOrgWithNoConfigs", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitShort) @@ -753,8 +739,8 @@ func TestEnabledMCPServerConfigsForChatOrg(t *testing.T) { defaultOrg, err := db.GetDefaultOrganization(ctx) require.NoError(t, err) - // The chat's organization has no MCP configs at all, so the - // default organization's enabled configs serve it directly. + // A chat whose organization has no configs resolves nothing, + // even when the requested ID exists in the default organization. chatOrg := dbgen.Organization(t, db, database.Organization{}) defaultOrgCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ OrganizationID: defaultOrg.ID, @@ -763,40 +749,6 @@ func TestEnabledMCPServerConfigsForChatOrg(t *testing.T) { configs, err := enabledMCPServerConfigsForChatOrg(ctx, db, chatOrg.ID, []uuid.UUID{defaultOrgCfg.ID}) require.NoError(t, err) - require.Len(t, configs, 1) - require.Equal(t, defaultOrgCfg.ID, configs[0].ID) - }) - - t.Run("ChatOrgConfigsDoNotHideDefaultOrgConfigs", func(t *testing.T) { - t.Parallel() - db, _ := dbtestutil.NewDB(t) - ctx := testutil.Context(t, testutil.WaitShort) - - defaultOrg, err := db.GetDefaultOrganization(ctx) - require.NoError(t, err) - - // Even when the chat's organization has its own configs, a config - // living in the default organization still resolves (the fallback - // is an OR, not a preference). - chatOrg, _ := newOrgWithConfig(t, db, true) - defaultOrgCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - OrganizationID: defaultOrg.ID, - Enabled: true, - }) - - configs, err := enabledMCPServerConfigsForChatOrg(ctx, db, chatOrg.ID, []uuid.UUID{defaultOrgCfg.ID}) - require.NoError(t, err) - require.Len(t, configs, 1) - require.Equal(t, defaultOrgCfg.ID, configs[0].ID) - }) - - t.Run("EmptyIDs", func(t *testing.T) { - t.Parallel() - db, _ := dbtestutil.NewDB(t) - ctx := testutil.Context(t, testutil.WaitShort) - - configs, err := enabledMCPServerConfigsForChatOrg(ctx, db, uuid.New(), nil) - require.NoError(t, err) require.Empty(t, configs) }) } diff --git a/coderd/x/chatd/subagent_internal_test.go b/coderd/x/chatd/subagent_internal_test.go index 2774d90f9cba6..f8c5ffd678765 100644 --- a/coderd/x/chatd/subagent_internal_test.go +++ b/coderd/x/chatd/subagent_internal_test.go @@ -693,6 +693,7 @@ func insertInternalChatModelConfigWithOptions( func insertInternalMCPServerConfig( t *testing.T, db database.Store, + organizationID uuid.UUID, userID uuid.UUID, slug string, allowInPlanMode bool, @@ -700,6 +701,7 @@ func insertInternalMCPServerConfig( t.Helper() return dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: organizationID, DisplayName: slug, Slug: slug, Url: "https://" + slug + ".example.com", @@ -2379,28 +2381,30 @@ func TestResolveExploreToolSnapshot(t *testing.T) { db, ps := dbtestutil.NewDB(t) server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}) - user, _, _ := seedInternalChatDeps(t, db) + user, org, _ := seedInternalChatDeps(t, db) approvedMCP := insertInternalMCPServerConfig( - t, db, user.ID, "approved-"+uuid.NewString(), true, + t, db, org.ID, user.ID, "approved-"+uuid.NewString(), true, ) blockedMCP := insertInternalMCPServerConfig( - t, db, user.ID, "blocked-"+uuid.NewString(), false, + t, db, org.ID, user.ID, "blocked-"+uuid.NewString(), false, ) // Build parent chats in memory rather than via server.CreateChat. - // resolveExploreToolSnapshot only reads ID, MCPServerIDs, PlanMode, - // ParentChatID, and Mode from its parent argument, so persisting - // the chats is unnecessary. Skipping CreateChat avoids waking the - // background acquireLoop, which would otherwise try to dial the - // fake MCP URLs and call OpenAI with the dbgen test API key. Those - // side effects were the root cause of the flake tracked in - // CODAGT-367. + // resolveExploreToolSnapshot only reads ID, OrganizationID, + // MCPServerIDs, PlanMode, ParentChatID, and Mode from its parent + // argument, so persisting the chats is unnecessary. Skipping + // CreateChat avoids waking the background acquireLoop, which would + // otherwise try to dial the fake MCP URLs and call OpenAI with the + // dbgen test API key. Those side effects were the root cause of the + // flake tracked in CODAGT-367. askParent := database.Chat{ - ID: uuid.New(), - MCPServerIDs: []uuid.UUID{approvedMCP.ID, blockedMCP.ID}, + ID: uuid.New(), + OrganizationID: org.ID, + MCPServerIDs: []uuid.UUID{approvedMCP.ID, blockedMCP.ID}, } planParent := database.Chat{ - ID: uuid.New(), + ID: uuid.New(), + OrganizationID: org.ID, PlanMode: database.NullChatPlanMode{ ChatPlanMode: database.ChatPlanModePlan, Valid: true, @@ -2473,7 +2477,7 @@ func TestCreateChildSubagentChatWithOptions_ExplorePersistsMCPSnapshot(t *testin ctx, t, server, db, org.ID, user.ID, model.ID, "parent-explore-snapshot", ) mcpCfg := insertInternalMCPServerConfig( - t, db, user.ID, "snapshot-"+uuid.NewString(), false, + t, db, org.ID, user.ID, "snapshot-"+uuid.NewString(), false, ) child, err := server.createChildSubagentChatWithOptions( @@ -2505,10 +2509,10 @@ func TestSpawnAgent_ExploreSnapshotsTurnStateParentState(t *testing.T) { ctx := chatdTestContext(t) user, org, model := seedInternalChatDeps(t, db) turnStartConfig := insertInternalMCPServerConfig( - t, db, user.ID, "turn-start-"+uuid.NewString(), false, + t, db, org.ID, user.ID, "turn-start-"+uuid.NewString(), false, ) mutatedConfig := insertInternalMCPServerConfig( - t, db, user.ID, "mutated-"+uuid.NewString(), true, + t, db, org.ID, user.ID, "mutated-"+uuid.NewString(), true, ) parent, err := server.CreateChat(ctx, CreateOptions{ @@ -3410,19 +3414,21 @@ func TestCreateChildSubagentChat_InheritsMCPServerIDs(t *testing.T) { // Insert two MCP server configs so we can verify both are // inherited by the child chat. mcpA := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "MCP A", - Slug: "mcp-a", - Url: "https://mcp-a.example.com", - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "MCP A", + Slug: "mcp-a", + Url: "https://mcp-a.example.com", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) mcpB := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "MCP B", - Slug: "mcp-b", - Url: "https://mcp-b.example.com", - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "MCP B", + Slug: "mcp-b", + Url: "https://mcp-b.example.com", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) parentMCPIDs := []uuid.UUID{mcpA.ID, mcpB.ID} diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx new file mode 100644 index 0000000000000..b00413fa4f5ac --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -0,0 +1,109 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import { API } from "#/api/api"; +import { MockDefaultOrganization, MockUserOwner } from "#/testHelpers/entities"; +import { + withAuthProvider, + withDashboardProvider, +} from "#/testHelpers/storybook"; +import AddMCPServerPage from "./AddMCPServerPage/AddMCPServerPage"; +import MCPServersPage from "./MCPServersPage"; +import { MockCoderMCPServer } from "./testFixtures"; +import UpdateMCPServerPage from "./UpdateMCPServerPage/UpdateMCPServerPage"; + +const meta = { + title: "pages/AISettingsPage/MCPServersPage/MCPServersPage", + component: MCPServersPage, + decorators: [withAuthProvider, withDashboardProvider], + parameters: { + layout: "fullscreen", + user: MockUserOwner, + permissions: { editDeploymentConfig: true }, + organizations: [MockDefaultOrganization], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const ListUsesDefaultOrganization: Story = { + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers" }, + routing: { path: "/ai/settings/mcp-servers" }, + }), + }, + beforeEach: () => { + spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([ + MockCoderMCPServer, + ]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(API.experimental.getMCPServerConfigs).toHaveBeenCalledWith( + MockDefaultOrganization.id, + ); + }); + await expect(canvas.getByText("Coder")).toBeVisible(); + }, +}; + +export const AddUsesDefaultOrganization: Story = { + render: () => , + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers/add" }, + routing: { path: "/ai/settings/mcp-servers/add" }, + }), + }, + beforeEach: () => { + spyOn(API.experimental, "createMCPServerConfig").mockResolvedValue( + MockCoderMCPServer, + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByLabelText(/display name/i), "GitHub"); + await userEvent.type( + canvas.getByLabelText(/server url/i), + "https://api.githubcopilot.com/mcp/", + ); + await userEvent.click(canvas.getByRole("button", { name: "Add server" })); + await waitFor(() => { + expect(API.experimental.createMCPServerConfig).toHaveBeenCalledWith( + MockDefaultOrganization.id, + expect.objectContaining({ + display_name: "GitHub", + slug: "github", + url: "https://api.githubcopilot.com/mcp/", + }), + ); + }); + }, +}; + +export const UpdateLoadsDefaultOrganization: Story = { + render: () => , + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers/mcp-coder" }, + routing: { path: "/ai/settings/mcp-servers/:serverId" }, + }), + }, + beforeEach: () => { + spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([ + MockCoderMCPServer, + ]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(API.experimental.getMCPServerConfigs).toHaveBeenCalledWith( + MockDefaultOrganization.id, + ); + }); + await expect(canvas.getByLabelText(/display name/i)).toHaveValue("Coder"); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentCreatePage.tsx b/site/src/pages/AgentsPage/AgentCreatePage.tsx index 4373263e94eb3..38589b00da359 100644 --- a/site/src/pages/AgentsPage/AgentCreatePage.tsx +++ b/site/src/pages/AgentsPage/AgentCreatePage.tsx @@ -8,7 +8,6 @@ import { chatModelConfigs, chatModels, createChat, - mcpServerConfigs, userChatPersonalModelOverrides, userChatProviderConfigs, } from "#/api/queries/chats"; @@ -18,10 +17,6 @@ import type * as TypesGen from "#/api/typesGenerated"; import { useWebpushNotifications } from "#/contexts/useWebpushNotifications"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useAIGatewayEnabled } from "#/hooks/useEmbeddedMetadata"; -import { - getDefaultOrganizationId, - useDashboard, -} from "#/modules/dashboard/useDashboard"; import { AgentCreateForm, type CreateChatOptions, @@ -45,10 +40,6 @@ const AgentCreatePage: FC = () => { const location = useLocation(); const navigate = useNavigate(); const { permissions } = useAuthenticated(); - const { organizations } = useDashboard(); - const [organizationId, setOrganizationId] = useState( - () => getDefaultOrganizationId(organizations) || organizations[0]?.id || "", - ); const aiGatewayDisabled = !useAIGatewayEnabled(); const chatModelsQuery = useQuery(chatModels()); @@ -62,10 +53,6 @@ const AgentCreatePage: FC = () => { userChatPersonalModelOverrides(), ); const preferencesQuery = useQuery(preferenceSettings()); - const mcpServersQuery = useQuery({ - ...mcpServerConfigs(organizationId), - enabled: Boolean(organizationId), - }); const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 })); const createMutation = useMutation(createChat(queryClient)); const webPush = useWebpushNotifications(); @@ -171,7 +158,6 @@ const AgentCreatePage: FC = () => { { isModelConfigsLoading={chatModelConfigsQuery.isLoading} rootPersonalModelOverride={rootPersonalModelOverride} isPersonalModelOverridesLoading={personalModelOverridesQuery.isLoading} - mcpServersOrganizationId={organizationId} - mcpServers={mcpServersQuery.data ?? []} - onMCPAuthComplete={() => void mcpServersQuery.refetch()} workspaceCount={workspacesQuery.data?.count} workspaceOptions={workspacesQuery.data?.workspaces ?? []} workspacesError={workspacesQuery.error} diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index ab998aaa6ee88..8b678f82cb0fc 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -110,7 +110,6 @@ const meta: Meta = { component: AgentCreateForm, decorators: [withDashboardProvider], args: { - onOrganizationChange: fn(), onCreateChat: fn(), sendShortcut: "enter", isCreating: false, @@ -119,7 +118,6 @@ const meta: Meta = { modelCatalog: null, modelOptions: [...modelOptions], isModelCatalogLoading: false, - mcpServersOrganizationId: MockDefaultOrganization.id, modelConfigs: [], isModelConfigsLoading: false, workspaceCount: 0, @@ -129,6 +127,7 @@ const meta: Meta = { }, beforeEach: () => { localStorage.clear(); + spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([]); }, }; @@ -913,9 +912,14 @@ export const WithOrganizationPicker: Story = { }, ], }, - play: async ({ canvasElement, args }) => { + play: async ({ canvasElement }) => { const canvas = within(canvasElement); const body = within(canvasElement.ownerDocument.body); + await waitFor(() => { + expect(API.experimental.getMCPServerConfigs).toHaveBeenCalledWith( + MockDefaultOrganization.id, + ); + }); const organizationSelector = await canvas.findByRole("button", { name: `Organization: ${MockDefaultOrganization.display_name}`, }); @@ -923,9 +927,11 @@ export const WithOrganizationPicker: Story = { await userEvent.click( await body.findByRole("option", { name: MockOrganization2.display_name }), ); - await expect(args.onOrganizationChange).toHaveBeenCalledWith( - MockOrganization2.id, - ); + await waitFor(() => { + expect(API.experimental.getMCPServerConfigs).toHaveBeenCalledWith( + MockOrganization2.id, + ); + }); const input = canvas.getByRole("textbox", { name: "Chat message" }); await userEvent.click(input); await userEvent.keyboard("hello world"); diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 5e29a4cbf0870..1596d32ce32b1 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -2,6 +2,7 @@ import { type FC, useEffect, useRef, useState } from "react"; import { useQuery } from "react-query"; import { toast } from "sonner"; import { isApiError } from "#/api/errors"; +import { mcpServerConfigs } from "#/api/queries/chats"; import { permittedOrganizations } from "#/api/queries/organizations"; import type * as TypesGen from "#/api/typesGenerated"; import type { AgentChatSendShortcut } from "#/api/typesGenerated"; @@ -124,7 +125,6 @@ export function useEmptyStateDraft() { interface AgentCreateFormProps { onCreateChat: (options: CreateChatOptions) => Promise; - onOrganizationChange: (organizationId: string) => void; sendShortcut: AgentChatSendShortcut; isCreating: boolean; createError: unknown; @@ -141,9 +141,6 @@ interface AgentCreateFormProps { isModelConfigsLoading: boolean; rootPersonalModelOverride?: TypesGen.ChatPersonalModelOverride; isPersonalModelOverridesLoading?: boolean; - mcpServersOrganizationId: string; - mcpServers?: readonly TypesGen.MCPServerConfig[]; - onMCPAuthComplete?: (serverId: string) => void; workspaceCount: number | undefined; workspaceOptions: readonly TypesGen.Workspace[]; workspacesError: unknown; @@ -152,7 +149,6 @@ interface AgentCreateFormProps { export const AgentCreateForm: FC = ({ onCreateChat, - onOrganizationChange, sendShortcut, isCreating, createError, @@ -169,9 +165,6 @@ export const AgentCreateForm: FC = ({ isModelConfigsLoading, rootPersonalModelOverride, isPersonalModelOverridesLoading = false, - mcpServersOrganizationId, - mcpServers, - onMCPAuthComplete, workspaceCount: _workspaceCount, workspaceOptions, workspacesError, @@ -340,8 +333,11 @@ export const AgentCreateForm: FC = ({ initialOrg ?? null); const organizationId = effectiveOrg?.id ?? ""; - const scopedMCPServers = - mcpServersOrganizationId === organizationId ? (mcpServers ?? []) : []; + const mcpServersQuery = useQuery({ + ...mcpServerConfigs(organizationId), + enabled: Boolean(organizationId), + }); + const mcpServers = mcpServersQuery.data ?? []; // Adopt a permitted fallback so later refetches cannot switch the form to a // re-permitted default. The permission guard also avoids a render loop. if ( @@ -367,11 +363,6 @@ export const AgentCreateForm: FC = ({ setUserMCPServerIds(null); } } - useEffect(() => { - if (organizationId) { - onOrganizationChange(organizationId); - } - }, [organizationId, onOrganizationChange]); useEffect(() => { if (selectedWorkspaceId === null) { localStorage.removeItem(selectedWorkspaceIdStorageKey); @@ -417,27 +408,19 @@ export const AgentCreateForm: FC = ({ } const saved = getSavedMCPSelection( organizationId, - scopedMCPServers, + mcpServers, effectiveOrg?.is_default, ); if (saved !== null) { return saved; } - return getDefaultMCPSelection(scopedMCPServers); + return getDefaultMCPSelection(mcpServers); })(); useEffect(() => { - if ( - effectiveOrg?.is_default && - mcpServersOrganizationId === organizationId - ) { - migrateLegacyMCPSelection(organizationId, mcpServers ?? []); + if (effectiveOrg?.is_default) { + migrateLegacyMCPSelection(organizationId, mcpServers); } - }, [ - organizationId, - mcpServers, - mcpServersOrganizationId, - effectiveOrg?.is_default, - ]); + }, [organizationId, mcpServers, effectiveOrg?.is_default]); const handleWorkspaceChange = (value: string | null) => { if (value === null) { setSelectedWorkspaceId(null); @@ -451,7 +434,6 @@ export const AgentCreateForm: FC = ({ const selectOrganization = (organization: TypesGen.Organization) => { setUserMCPServerIds(null); setSelectedOrg(organization); - onOrganizationChange(organization.id); }; const handleModelChange = (value: string) => { @@ -661,13 +643,13 @@ export const AgentCreateForm: FC = ({ uploadStates={uploadStates} previewUrls={previewUrls} textContents={textContents} - mcpServers={scopedMCPServers} + mcpServers={mcpServers} selectedMCPServerIds={effectiveMCPServerIds} onMCPSelectionChange={(ids) => { setUserMCPServerIds(ids); saveMCPSelection(organizationId, ids); }} - onMCPAuthComplete={onMCPAuthComplete} + onMCPAuthComplete={() => void mcpServersQuery.refetch()} workspaceOptions={filteredWorkspaces} selectedWorkspaceId={effectiveWorkspaceId} // Do not persist a workspace until its organization is authorized. From 588c8306d0df8fa5805cbe05e61e68e104d9b741 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:17:26 +0000 Subject: [PATCH 11/59] fix(coderd): keep MCP OAuth2 disconnect available to removed org members --- coderd/coderd.go | 20 +++++++++++-------- coderd/mcp.go | 25 +++++++++++++++++++---- coderd/mcp_test.go | 37 +++++++++++++++++++++++++++++++++++ enterprise/coderd/mcp_test.go | 12 ++++++++++-- 4 files changed, 80 insertions(+), 14 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 2bda20e3285bb..c8c83564c1fbd 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1379,15 +1379,19 @@ func New(options *Options) *API { r.Get("/chats/files/{file}/download", api.downloadChatFile) }) r.Route("/mcp-servers/{mcpserverconfig}", func(r chi.Router) { - r.Use( - apiKeyMiddleware, - httpmw.ExtractMCPServerConfigParam(options.Database), - ) - r.Get("/", api.getMCPServerConfig) - r.Patch("/", api.updateMCPServerConfig) - r.Delete("/", api.deleteMCPServerConfig) - r.Get("/oauth2/connect", api.mcpServerOAuth2Connect) + r.Use(apiKeyMiddleware) + // Disconnect skips the read-gated param middleware so + // token owners who can no longer read the config, such + // as users removed from the organization, can still + // delete their token and revoke the provider grant. r.Delete("/oauth2/disconnect", api.mcpServerOAuth2Disconnect) + r.Group(func(r chi.Router) { + r.Use(httpmw.ExtractMCPServerConfigParam(options.Database)) + r.Get("/", api.getMCPServerConfig) + r.Patch("/", api.updateMCPServerConfig) + r.Delete("/", api.deleteMCPServerConfig) + r.Get("/oauth2/connect", api.mcpServerOAuth2Connect) + }) }) r.Route("/organizations/{organization}/mcp-servers", func(r chi.Router) { r.Use( diff --git a/coderd/mcp.go b/coderd/mcp.go index 6374c7dc6bdbd..2b3f3275a1259 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -1150,26 +1150,43 @@ func (api *API) mcpServerOAuth2Callback(rw http.ResponseWriter, r *http.Request) func (api *API) mcpServerOAuth2Disconnect(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) - config := httpmw.MCPServerConfigParam(r) + + configID, parsed := httpmw.ParseUUIDParam(rw, r, "mcpserverconfig") + if !parsed { + return + } //nolint:gocritic // Users manage their own tokens. systemCtx := dbauthz.AsSystemRestricted(ctx) - var token database.MCPServerUserToken + var ( + config database.MCPServerConfig + token database.MCPServerUserToken + ) // Serializable isolation keeps the revoked token aligned with the row deleted locally. err := api.Database.InTx(func(tx database.Store) error { dbToken, err := tx.GetMCPServerUserToken(systemCtx, database.GetMCPServerUserTokenParams{ - MCPServerConfigID: config.ID, + MCPServerConfigID: configID, UserID: apiKey.UserID, }) if err != nil { return err } + // Load the config only after the token is found so callers + // without a token cannot probe which config IDs exist. The + // system context keeps disconnect available to token owners + // who can no longer read the config, such as users removed + // from the organization. + dbConfig, err := tx.GetMCPServerConfigByID(systemCtx, configID) + if err != nil { + return err + } if err := tx.DeleteMCPServerUserToken(systemCtx, database.DeleteMCPServerUserTokenParams{ - MCPServerConfigID: config.ID, + MCPServerConfigID: configID, UserID: apiKey.UserID, }); err != nil { return err } + config = dbConfig token = dbToken return nil }, &database.TxOptions{Isolation: sql.LevelSerializable}) diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index d936d24222ea7..14e5b4463b758 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -23,6 +23,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -683,6 +684,42 @@ func TestMCPServerConfigsOAuth2Disconnect(t *testing.T) { require.Empty(t, resp.TokenRevocationError) }) + t.Run("RemovedOrgMemberCanDisconnect", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t) + adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), + ChatProviderAPIKeys: &providerKeys, + }) + coderdtest.CreateFirstUser(t, adminClient) + + secondOrg := dbgen.Organization(t, db, database.Organization{}) + memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, secondOrg.ID) + config := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: secondOrg.ID, + AuthType: "oauth2", + Enabled: true, + }) + seedToken(t, db, config.ID, member.ID) + + //nolint:gocritic // Seeding test state requires system access. + systemCtx := dbauthz.AsSystemRestricted(ctx) + err := db.DeleteOrganizationMember(systemCtx, database.DeleteOrganizationMemberParams{ + OrganizationID: secondOrg.ID, + UserID: member.ID, + }) + require.NoError(t, err) + + // A token owner removed from the organization can no longer + // read the config, but must still be able to delete the + // stored token. + _, err = memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, config.ID) + require.NoError(t, err) + requireTokenDeleted(t, db, config.ID, member.ID) + }) + t.Run("RevokesAtProvider", func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/mcp_test.go b/enterprise/coderd/mcp_test.go index 6e9248fec7847..155c7b5cd7de3 100644 --- a/enterprise/coderd/mcp_test.go +++ b/enterprise/coderd/mcp_test.go @@ -113,17 +113,25 @@ func TestMCPServerConfigItemCrossOrganizationConcealment(t *testing.T) { method string pathSuffix string body any + wantStatus int }{ {name: "Get", method: http.MethodGet}, {name: "Patch", method: http.MethodPatch, body: codersdk.UpdateMCPServerConfigRequest{DisplayName: ptr.Ref("cross-org")}}, {name: "Delete", method: http.MethodDelete}, {name: "OAuthConnect", method: http.MethodGet, pathSuffix: "/oauth2/connect"}, {name: "OAuthCallback", method: http.MethodGet, pathSuffix: "/oauth2/callback"}, - {name: "OAuthDisconnect", method: http.MethodDelete, pathSuffix: "/oauth2/disconnect"}, + // Disconnect returns 200 for every caller without a token, + // including nonexistent config IDs, so the response does not + // reveal whether the config exists. + {name: "OAuthDisconnect", method: http.MethodDelete, pathSuffix: "/oauth2/disconnect", wantStatus: http.StatusOK}, } { t.Run(test.name, func(t *testing.T) { t.Parallel() - requireMCPServerConfigRequestStatus(t, otherClient, test.method, config.ID, test.pathSuffix, test.body, http.StatusNotFound) + wantStatus := test.wantStatus + if wantStatus == 0 { + wantStatus = http.StatusNotFound + } + requireMCPServerConfigRequestStatus(t, otherClient, test.method, config.ID, test.pathSuffix, test.body, wantStatus) }) } } From 1c57159646f13502e2532019873cffb7594809ec Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:18:57 +0000 Subject: [PATCH 12/59] fix(site): gate chat creation on the organization MCP server list --- .../components/AgentCreateForm.stories.tsx | 36 +++++++++++++++++-- .../AgentsPage/components/AgentCreateForm.tsx | 9 +++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 8b678f82cb0fc..0ff501e006633 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -895,9 +895,12 @@ export const ForbiddenErrorWithRole: Story = { ).not.toBeInTheDocument(); // The generic ErrorAlert should surface the real backend message. await expect(canvas.getByText("Forbidden.")).toBeInTheDocument(); - // The textbox should remain enabled since the user has the role. + // The textbox should remain enabled since the user has the + // role. Enablement waits for the MCP server list to resolve. const textbox = canvas.getByRole("textbox"); - await expect(textbox).not.toHaveAttribute("aria-disabled", "true"); + await waitFor(() => + expect(textbox).not.toHaveAttribute("aria-disabled", "true"), + ); }, }; @@ -1641,3 +1644,32 @@ export const MemberScopedPermissionsShowOrgPicker: Story = { ).toBeInTheDocument(); }, }; + +export const MCPServersLoadingDisablesSend: Story = { + beforeEach: () => { + spyOn(API.experimental, "getMCPServerConfigs").mockImplementation( + () => new Promise(() => {}), + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByTestId("chat-message-input"); + await userEvent.click(input); + await userEvent.keyboard("send while MCP servers load"); + expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled(); + }, +}; + +export const MCPServersErrorShowsAlertAndDisablesSend: Story = { + beforeEach: () => { + spyOn(API.experimental, "getMCPServerConfigs").mockRejectedValue( + new Error("failed to load MCP servers"), + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const matches = await canvas.findAllByText(/failed to load mcp servers/i); + expect(matches.length).toBeGreaterThan(0); + expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 1596d32ce32b1..752c04b94df1c 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -338,6 +338,11 @@ export const AgentCreateForm: FC = ({ enabled: Boolean(organizationId), }); const mcpServers = mcpServersQuery.data ?? []; + // Sending before the organization's MCP list resolves would + // silently drop its default-on server selection, so the composer + // waits for this query. + const isMCPSelectionUnresolved = + Boolean(organizationId) && !mcpServersQuery.isSuccess; // Adopt a permitted fallback so later refetches cannot switch the form to a // re-permitted default. The permission guard also avoids a render loop. if ( @@ -583,6 +588,9 @@ export const AgentCreateForm: FC = ({ {permittedOrgsQuery.error != null && ( )} + {mcpServersQuery.error != null && ( + + )} {/* The pre-settlement list is the unfiltered dashboard fallback; selecting from it could destroy existing workspace state. */} {showOrganizations && @@ -618,6 +626,7 @@ export const AgentCreateForm: FC = ({ !organizationAdopted || workspaceValidationPending || isPersonalModelOverridesLoading || + isMCPSelectionUnresolved || !hasModelOptions || Boolean(aiGatewayDisabled) } From 58a59883315bc3943a57152a78d90592e7cc88bf Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:37:02 +0000 Subject: [PATCH 13/59] fix(coderd): stop copying MCP credentials across orgs and gate discovery scope --- ..._mcp_server_configs_organization_id.up.sql | 20 ++++++-- coderd/database/migrations/migrate_test.go | 16 ++++--- coderd/mcp.go | 13 ++++- coderd/mcp_test.go | 47 +++++++++++++++++++ 4 files changed, 84 insertions(+), 12 deletions(-) diff --git a/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql index d27a00246a1a1..88290f2c32cc8 100644 --- a/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql +++ b/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql @@ -63,14 +63,24 @@ SELECT config.oauth2_revocation_url, config.oauth2_scopes, config.api_key_header, - config.api_key_value, - config.api_key_value_key_id, - config.custom_headers, - config.custom_headers_key_id, + -- Never copy admin-entered credentials into other organizations: + -- the new organization's admins gain update access to the copy and + -- could repoint its URL while reusing the inherited secret. + '', + NULL, + '{}', + NULL, config.tool_allow_list, config.tool_deny_list, config.availability, - CASE WHEN config.auth_type = 'oauth2' THEN false ELSE config.enabled END, + -- Copies that lost required credentials start disabled so each + -- organization's admin re-enters them deliberately. + CASE + WHEN config.auth_type IN ('oauth2', 'api_key', 'custom_headers') + OR config.custom_headers NOT IN ('', '{}') + THEN false + ELSE config.enabled + END, config.model_intent, config.allow_in_plan_mode, config.forward_coder_headers, diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index fed6263035cc4..86b8b53fa7318 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -3232,13 +3232,15 @@ func TestMigration000565MCPServerConfigsOrganizationID(t *testing.T) { require.Equal(t, config.authType, authType) copiedIDs[orgID][config.id] = copiedID + // Copies never inherit admin-entered credentials, and + // copies of credentialed configs start disabled. + require.Empty(t, apiKeyValue) + require.False(t, apiKeyValueKeyID.Valid) + require.Equal(t, "{}", customHeaders) + require.False(t, customHeadersKeyID.Valid) switch config.authType { - case "api_key": - require.Equal(t, config.apiKeyValue, apiKeyValue) - require.Equal(t, config.apiKeyValueKeyID, apiKeyValueKeyID) - case "custom_headers": - require.Equal(t, config.customHeaders, customHeaders) - require.Equal(t, config.customHeadersKeyID, customHeadersKeyID) + case "none": + require.True(t, enabled) case "oauth2": require.Empty(t, oauth2ClientID) require.Empty(t, oauth2ClientSecret) @@ -3248,6 +3250,8 @@ func TestMigration000565MCPServerConfigsOrganizationID(t *testing.T) { require.Equal(t, config.oauth2RevocationURL, oauth2RevocationURL) require.Equal(t, config.oauth2Scopes, oauth2Scopes) require.False(t, enabled) + default: + require.False(t, enabled) } } } diff --git a/coderd/mcp.go b/coderd/mcp.go index 2b3f3275a1259..4263e1df7e966 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -259,7 +259,18 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { // Auto-discovery flow: we need the config ID first to // build the correct callback URL. Insert the record // with empty OAuth2 fields, perform discovery, then - // update. + // update. The flow also updates the row with discovered + // credentials and deletes it when discovery fails, so + // require those actions up front rather than inserting a + // row a create-only caller can neither finish nor remove. + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) || + !api.Authorize(r, policy.ActionDelete, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "OAuth2 auto-discovery requires permission to update and delete MCP server configs.", + Detail: "Provide oauth2_client_id, oauth2_auth_url, and oauth2_token_url manually, or use credentials with broader MCP server config permissions.", + }) + return + } customHeadersJSON, err := marshalCustomHeaders(req.CustomHeaders) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 14e5b4463b758..6bc87360074be 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -1039,6 +1039,53 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { // resource metadata are available, the path-aware URL takes // priority. Each points to a different auth server so we can // distinguish which one was actually used. + t.Run("CreateOnlyScopeRejectedUpFront", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), + ChatProviderAPIKeys: &providerKeys, + }) + firstUser := coderdtest.CreateFirstUser(t, client) + + // Discovery inserts, then updates and possibly deletes the + // row. A create-only caller must be rejected before the + // insert instead of leaving an orphaned row behind. MCP + // config scopes are not user-mintable, so seed the scoped + // key directly. + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: firstUser.UserID, + Scopes: database.APIKeyScopes{ + "mcp_server_config:create", + "organization:read", + }, + }) + scopedClient := codersdk.New(client.URL) + scopedClient.SetSessionToken(token) + + _, err := scopedClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "Create Only Discovery", + Slug: "create-only-discovery", + Transport: "streamable_http", + URL: "http://127.0.0.1:1", + AuthType: "oauth2", + Availability: "default_on", + Enabled: true, + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + + //nolint:gocritic // Verifying persisted state requires system access. + _, err = db.GetMCPServerConfigByOrganizationAndSlug(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerConfigByOrganizationAndSlugParams{ + OrganizationID: firstUser.OrganizationID, + Slug: "create-only-discovery", + }) + require.ErrorIs(t, err, sql.ErrNoRows) + }) + t.Run("PathAwareTakesPriority", func(t *testing.T) { t.Parallel() From 710016cc76aa760ed4eb893cb7d0c52185149596 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 10 Aug 2026 05:22:14 +0000 Subject: [PATCH 14/59] fix(coderd/database/migrations): clear copied OAuth client identity for every auth type The API stores OAuth client fields regardless of auth_type and preserves them when a config is later switched to oauth2, so migration 000565 must clear the OAuth client identity on every copied row, not only rows whose auth_type is oauth2. Otherwise a non-oauth2 config with leftover OAuth fields copies its encrypted client secret into every organization, where an organization admin could switch the copy to oauth2, repoint the token URL, and recover the deployment-entered secret. --- ..._mcp_server_configs_organization_id.up.sql | 15 ++++++----- coderd/database/migrations/migrate_test.go | 25 ++++++++++--------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql index 88290f2c32cc8..977a97f6fed55 100644 --- a/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql +++ b/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql @@ -55,17 +55,20 @@ SELECT config.transport, config.url, config.auth_type, - CASE WHEN config.auth_type = 'oauth2' THEN '' ELSE config.oauth2_client_id END, - CASE WHEN config.auth_type = 'oauth2' THEN '' ELSE config.oauth2_client_secret END, - CASE WHEN config.auth_type = 'oauth2' THEN NULL ELSE config.oauth2_client_secret_key_id END, + -- Never copy admin-entered credentials into other organizations: + -- the new organization's admins gain update access to the copy and + -- could repoint its URL while reusing the inherited secret. OAuth + -- client identity is cleared for every auth type because the API + -- stores OAuth fields regardless of auth_type and preserves them + -- when a copy is later switched to oauth2. + '', + '', + NULL, config.oauth2_auth_url, config.oauth2_token_url, config.oauth2_revocation_url, config.oauth2_scopes, config.api_key_header, - -- Never copy admin-entered credentials into other organizations: - -- the new organization's admins gain update access to the copy and - -- could repoint its URL while reusing the inherited secret. '', NULL, '{}', diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 86b8b53fa7318..6c923798fb005 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -3084,7 +3084,9 @@ func TestMigration000565MCPServerConfigsOrganizationID(t *testing.T) { keyID := sql.NullString{String: keyDigest, Valid: true} configs := []configSeed{ {id: uuid.New(), slug: "migration-565-none", authType: "none", apiKeyHeader: "Authorization", customHeaders: "{}"}, - {id: uuid.New(), slug: "migration-565-api-key", authType: "api_key", apiKeyHeader: "X-API-Key", apiKeyValue: "api-key-ciphertext", apiKeyValueKeyID: keyID, customHeaders: "{}"}, + // Leftover OAuth fields on a non-oauth2 config: the API stores + // them for any auth type, so copies must clear them too. + {id: uuid.New(), slug: "migration-565-api-key", authType: "api_key", apiKeyHeader: "X-API-Key", apiKeyValue: "api-key-ciphertext", apiKeyValueKeyID: keyID, customHeaders: "{}", oauth2ClientID: "leftover-client-id", oauth2ClientSecret: "leftover-secret-ciphertext", oauth2ClientSecretKeyID: keyID, oauth2TokenURL: "https://oauth.example.com/leftover-token"}, {id: uuid.New(), slug: "migration-565-custom-headers", authType: "custom_headers", apiKeyHeader: "Authorization", customHeaders: "custom-headers-ciphertext", customHeadersKeyID: keyID}, { id: uuid.New(), @@ -3232,24 +3234,23 @@ func TestMigration000565MCPServerConfigsOrganizationID(t *testing.T) { require.Equal(t, config.authType, authType) copiedIDs[orgID][config.id] = copiedID - // Copies never inherit admin-entered credentials, and - // copies of credentialed configs start disabled. + // Copies never inherit admin-entered credentials, whatever + // the auth type, and copies of credentialed configs start + // disabled. require.Empty(t, apiKeyValue) require.False(t, apiKeyValueKeyID.Valid) require.Equal(t, "{}", customHeaders) require.False(t, customHeadersKeyID.Valid) + require.Empty(t, oauth2ClientID) + require.Empty(t, oauth2ClientSecret) + require.False(t, oauth2ClientSecretKeyID.Valid) + require.Equal(t, config.oauth2AuthURL, oauth2AuthURL) + require.Equal(t, config.oauth2TokenURL, oauth2TokenURL) + require.Equal(t, config.oauth2RevocationURL, oauth2RevocationURL) + require.Equal(t, config.oauth2Scopes, oauth2Scopes) switch config.authType { case "none": require.True(t, enabled) - case "oauth2": - require.Empty(t, oauth2ClientID) - require.Empty(t, oauth2ClientSecret) - require.False(t, oauth2ClientSecretKeyID.Valid) - require.Equal(t, config.oauth2AuthURL, oauth2AuthURL) - require.Equal(t, config.oauth2TokenURL, oauth2TokenURL) - require.Equal(t, config.oauth2RevocationURL, oauth2RevocationURL) - require.Equal(t, config.oauth2Scopes, oauth2Scopes) - require.False(t, enabled) default: require.False(t, enabled) } From d3dcc5129b50ee833be9bbdd1768ec245258d3a3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:06:44 +0000 Subject: [PATCH 15/59] docs(coderd/database/migrations): document default-org credential handover --- .../000565_mcp_server_configs_organization_id.up.sql | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql index 977a97f6fed55..2220fff556db3 100644 --- a/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql +++ b/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql @@ -14,6 +14,11 @@ BEGIN END IF; END $$; +-- Pre-existing deployment-wide configs move to the default organization +-- with credentials intact. The default organization succeeds the +-- deployment scope, so its operator-appointed admins take over the +-- inherited secrets; copies for other organizations (below) never +-- receive credentials. UPDATE mcp_server_configs SET organization_id = (SELECT id FROM organizations WHERE is_default = true LIMIT 1); From 9502b5523edeb99c9600de4db009cba3b9f3b50c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:30:06 +0000 Subject: [PATCH 16/59] chore: apply cleanup gate round to MCP org-scope diff --- ...65_mcp_server_configs_organization_id.up.sql | 17 ++++++----------- coderd/database/migrations/migrate_test.go | 4 +--- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql index 2220fff556db3..ce57a6211311a 100644 --- a/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql +++ b/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql @@ -14,11 +14,9 @@ BEGIN END IF; END $$; --- Pre-existing deployment-wide configs move to the default organization --- with credentials intact. The default organization succeeds the --- deployment scope, so its operator-appointed admins take over the --- inherited secrets; copies for other organizations (below) never --- receive credentials. +-- Originals move to the default organization with credentials intact: +-- it succeeds the deployment scope, so its operator-appointed admins +-- take over the inherited secrets. Copies below never get credentials. UPDATE mcp_server_configs SET organization_id = (SELECT id FROM organizations WHERE is_default = true LIMIT 1); @@ -60,12 +58,9 @@ SELECT config.transport, config.url, config.auth_type, - -- Never copy admin-entered credentials into other organizations: - -- the new organization's admins gain update access to the copy and - -- could repoint its URL while reusing the inherited secret. OAuth - -- client identity is cleared for every auth type because the API - -- stores OAuth fields regardless of auth_type and preserves them - -- when a copy is later switched to oauth2. + -- Never copy admin-entered credentials: the copy's admins could repoint + -- its URL and reuse the inherited secret. OAuth identity is cleared for + -- every auth type because the API stores it regardless of auth_type. '', '', NULL, diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 6c923798fb005..431788975930e 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -3234,9 +3234,7 @@ func TestMigration000565MCPServerConfigsOrganizationID(t *testing.T) { require.Equal(t, config.authType, authType) copiedIDs[orgID][config.id] = copiedID - // Copies never inherit admin-entered credentials, whatever - // the auth type, and copies of credentialed configs start - // disabled. + // Each organization's admin must re-enter credentials deliberately. require.Empty(t, apiKeyValue) require.False(t, apiKeyValueKeyID.Valid) require.Equal(t, "{}", customHeaders) From a3ca43099b4a1f23a04bde4babdfb0d11d645965 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:18:38 +0000 Subject: [PATCH 17/59] chore(coderd/database/migrations): renumber migration 000565 to 000567 Main added 000565_oauth2_client_type_constraint and 000566_oauth2_auth_method_backfill, which collide with this branch's migration number. No SQL changes. --- ...p_server_configs_organization_id.down.sql} | 0 ...mcp_server_configs_organization_id.up.sql} | 0 coderd/database/migrations/migrate_test.go | 34 +++++++++---------- ...mcp_server_configs_organization_id.up.sql} | 0 4 files changed, 17 insertions(+), 17 deletions(-) rename coderd/database/migrations/{000565_mcp_server_configs_organization_id.down.sql => 000567_mcp_server_configs_organization_id.down.sql} (100%) rename coderd/database/migrations/{000565_mcp_server_configs_organization_id.up.sql => 000567_mcp_server_configs_organization_id.up.sql} (100%) rename coderd/database/migrations/testdata/fixtures/{000565_mcp_server_configs_organization_id.up.sql => 000567_mcp_server_configs_organization_id.up.sql} (100%) diff --git a/coderd/database/migrations/000565_mcp_server_configs_organization_id.down.sql b/coderd/database/migrations/000567_mcp_server_configs_organization_id.down.sql similarity index 100% rename from coderd/database/migrations/000565_mcp_server_configs_organization_id.down.sql rename to coderd/database/migrations/000567_mcp_server_configs_organization_id.down.sql diff --git a/coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000567_mcp_server_configs_organization_id.up.sql similarity index 100% rename from coderd/database/migrations/000565_mcp_server_configs_organization_id.up.sql rename to coderd/database/migrations/000567_mcp_server_configs_organization_id.up.sql diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 431788975930e..84d914f20e864 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2993,10 +2993,10 @@ func TestMigration000566OAuth2AuthMethodBackfill(t *testing.T) { "the backfill aligns the declaration to what is enforced, so the enforced value must be unchanged") } -func TestMigration000565MCPServerConfigsOrganizationID(t *testing.T) { +func TestMigration000567MCPServerConfigsOrganizationID(t *testing.T) { t.Parallel() - const priorMigrationVersion = 564 + const priorMigrationVersion = 566 sqlDB := testSQLDB(t) next, err := migrations.Stepper(sqlDB) @@ -3028,7 +3028,7 @@ func TestMigration000565MCPServerConfigsOrganizationID(t *testing.T) { id, name, display_name, description, icon, created_at, updated_at, is_default, deleted, default_org_member_roles ) VALUES ($1, $2, $3, '', '', $4, $4, false, $5, '{}') - `, orgID, fmt.Sprintf("migration-565-org-%d", i), fmt.Sprintf("Migration 565 Org %d", i), now, orgID == deletedOrgID) + `, orgID, fmt.Sprintf("migration-567-org-%d", i), fmt.Sprintf("Migration 567 Org %d", i), now, orgID == deletedOrgID) require.NoError(t, err) } @@ -3037,14 +3037,14 @@ func TestMigration000565MCPServerConfigsOrganizationID(t *testing.T) { INSERT INTO users ( id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type - ) VALUES ($1, 'migration-565-user', 'migration-565@example.com', ''::bytea, $2, $2, 'active', '{}', 'password') + ) VALUES ($1, 'migration-567-user', 'migration-567@example.com', ''::bytea, $2, $2, 'active', '{}', 'password') `, userID, now) require.NoError(t, err) - const keyDigest = "migration-565-key" + const keyDigest = "migration-567-key" _, err = sqlDB.ExecContext(ctx, ` INSERT INTO dbcrypt_keys (number, active_key_digest, test) - VALUES (565000, $1, 'migration-565-test') + VALUES (567000, $1, 'migration-567-test') `, keyDigest) require.NoError(t, err) @@ -3053,14 +3053,14 @@ func TestMigration000565MCPServerConfigsOrganizationID(t *testing.T) { _, err = sqlDB.ExecContext(ctx, ` INSERT INTO ai_providers ( id, type, name, display_name, enabled, base_url, created_at, updated_at - ) VALUES ($1, 'openai', 'migration-565-provider', 'Migration 565 Provider', true, 'https://provider.example.com', $2, $2) + ) VALUES ($1, 'openai', 'migration-567-provider', 'Migration 567 Provider', true, 'https://provider.example.com', $2, $2) `, providerID, now) require.NoError(t, err) _, err = sqlDB.ExecContext(ctx, ` INSERT INTO chat_model_configs ( id, model, display_name, ai_provider_id, context_limit, compression_threshold, created_at, updated_at - ) VALUES ($1, 'migration-565-model', 'Migration 565 Model', $2, 128000, 70, $3, $3) + ) VALUES ($1, 'migration-567-model', 'Migration 567 Model', $2, 128000, 70, $3, $3) `, modelConfigID, providerID, now) require.NoError(t, err) @@ -3083,14 +3083,14 @@ func TestMigration000565MCPServerConfigsOrganizationID(t *testing.T) { } keyID := sql.NullString{String: keyDigest, Valid: true} configs := []configSeed{ - {id: uuid.New(), slug: "migration-565-none", authType: "none", apiKeyHeader: "Authorization", customHeaders: "{}"}, + {id: uuid.New(), slug: "migration-567-none", authType: "none", apiKeyHeader: "Authorization", customHeaders: "{}"}, // Leftover OAuth fields on a non-oauth2 config: the API stores // them for any auth type, so copies must clear them too. - {id: uuid.New(), slug: "migration-565-api-key", authType: "api_key", apiKeyHeader: "X-API-Key", apiKeyValue: "api-key-ciphertext", apiKeyValueKeyID: keyID, customHeaders: "{}", oauth2ClientID: "leftover-client-id", oauth2ClientSecret: "leftover-secret-ciphertext", oauth2ClientSecretKeyID: keyID, oauth2TokenURL: "https://oauth.example.com/leftover-token"}, - {id: uuid.New(), slug: "migration-565-custom-headers", authType: "custom_headers", apiKeyHeader: "Authorization", customHeaders: "custom-headers-ciphertext", customHeadersKeyID: keyID}, + {id: uuid.New(), slug: "migration-567-api-key", authType: "api_key", apiKeyHeader: "X-API-Key", apiKeyValue: "api-key-ciphertext", apiKeyValueKeyID: keyID, customHeaders: "{}", oauth2ClientID: "leftover-client-id", oauth2ClientSecret: "leftover-secret-ciphertext", oauth2ClientSecretKeyID: keyID, oauth2TokenURL: "https://oauth.example.com/leftover-token"}, + {id: uuid.New(), slug: "migration-567-custom-headers", authType: "custom_headers", apiKeyHeader: "Authorization", customHeaders: "custom-headers-ciphertext", customHeadersKeyID: keyID}, { id: uuid.New(), - slug: "migration-565-oauth2", + slug: "migration-567-oauth2", authType: "oauth2", oauth2ClientID: "oauth-client-id", oauth2ClientSecret: "oauth-secret-ciphertext", @@ -3121,7 +3121,7 @@ func TestMigration000565MCPServerConfigsOrganizationID(t *testing.T) { $19, $19, $20, $20 ) `, - config.id, "Migration 565 "+config.authType, config.slug, "migration 565 config", "https://mcp.example.com/"+config.slug, config.authType, + config.id, "Migration 567 "+config.authType, config.slug, "migration 567 config", "https://mcp.example.com/"+config.slug, config.authType, config.oauth2ClientID, config.oauth2ClientSecret, config.oauth2ClientSecretKeyID, config.oauth2AuthURL, config.oauth2TokenURL, config.oauth2RevocationURL, config.oauth2Scopes, config.apiKeyHeader, config.apiKeyValue, config.apiKeyValueKeyID, @@ -3160,13 +3160,13 @@ func TestMigration000565MCPServerConfigsOrganizationID(t *testing.T) { id, owner_id, organization_id, last_model_config_id, title, mcp_server_ids, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $7) - `, chat.id, userID, chat.organizationID, modelConfigID, fmt.Sprintf("Migration 565 Chat %d", i), pq.Array(chat.configIDs), now) + `, chat.id, userID, chat.organizationID, modelConfigID, fmt.Sprintf("Migration 567 Chat %d", i), pq.Array(chat.configIDs), now) require.NoError(t, err) } version, _, err := next() require.NoError(t, err) - require.EqualValues(t, 565, version) + require.EqualValues(t, 567, version) var totalConfigs int err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_server_configs`).Scan(&totalConfigs) @@ -3296,7 +3296,7 @@ func TestMigration000565MCPServerConfigsOrganizationID(t *testing.T) { _, err = sqlDB.ExecContext(ctx, ` INSERT INTO mcp_server_configs ( id, organization_id, display_name, slug, url, auth_type - ) VALUES ($1, $2, 'Org-only config', 'migration-565-org-only', 'https://mcp.example.com/org-only', 'none') + ) VALUES ($1, $2, 'Org-only config', 'migration-567-org-only', 'https://mcp.example.com/org-only', 'none') `, orgOnlyConfigID, liveOrgIDs[0]) require.NoError(t, err) _, err = sqlDB.ExecContext(ctx, ` @@ -3304,7 +3304,7 @@ func TestMigration000565MCPServerConfigsOrganizationID(t *testing.T) { `, chats[1].id, orgOnlyConfigID) require.NoError(t, err) - downSQL, err := os.ReadFile("000565_mcp_server_configs_organization_id.down.sql") + downSQL, err := os.ReadFile("000567_mcp_server_configs_organization_id.down.sql") require.NoError(t, err) _, err = sqlDB.ExecContext(ctx, string(downSQL)) require.NoError(t, err) diff --git a/coderd/database/migrations/testdata/fixtures/000565_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/testdata/fixtures/000567_mcp_server_configs_organization_id.up.sql similarity index 100% rename from coderd/database/migrations/testdata/fixtures/000565_mcp_server_configs_organization_id.up.sql rename to coderd/database/migrations/testdata/fixtures/000567_mcp_server_configs_organization_id.up.sql From 4f1ea85b148ccefb131a3feac8f5ed0f3b176a9c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:48:38 +0000 Subject: [PATCH 18/59] fix(coderd/database): remap chat MCP config IDs written by pre-upgrade replicas During a rolling upgrade, a replica still running pre-organization-scoping code resolves MCP configs globally and can persist a default-organization config ID into another organization's chat after the one-time migration remap has committed. New code then filters configs by chat organization and silently drops the selection. Add a temporary trigger that remaps such writes to the chat organization's same-slug config, dropping IDs with no counterpart. The down migration removes the trigger before restoring pre-migration IDs. --- coderd/database/dump.sql | 37 ++++++++++++ ...cp_server_configs_organization_id.down.sql | 5 ++ ..._mcp_server_configs_organization_id.up.sql | 44 ++++++++++++++ coderd/database/migrations/migrate_test.go | 59 +++++++++++++++++++ 4 files changed, 145 insertions(+) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 63487a1a9a083..66a7fd977fac7 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1357,6 +1357,39 @@ BEGIN END; $$; +CREATE FUNCTION remap_chat_mcp_server_ids_to_chat_org() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF NEW.mcp_server_ids IS NULL OR cardinality(NEW.mcp_server_ids) = 0 THEN + RETURN NEW; + END IF; + SELECT COALESCE( + array_agg( + CASE + WHEN config.id IS NULL OR config.organization_id = NEW.organization_id + THEN item.config_id + ELSE same_org_config.id + END + ORDER BY item.position + ) FILTER ( + WHERE config.id IS NULL + OR config.organization_id = NEW.organization_id + OR same_org_config.id IS NOT NULL + ), + '{}'::uuid[] + ) + INTO NEW.mcp_server_ids + FROM unnest(NEW.mcp_server_ids) WITH ORDINALITY AS item(config_id, position) + LEFT JOIN mcp_server_configs AS config ON config.id = item.config_id + LEFT JOIN mcp_server_configs AS same_org_config + ON config.organization_id != NEW.organization_id + AND same_org_config.organization_id = NEW.organization_id + AND same_org_config.slug = config.slug; + RETURN NEW; +END; +$$; + CREATE FUNCTION remove_mcp_server_config_id_from_chats() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -5078,6 +5111,10 @@ CREATE TRIGGER inhibit_enqueue_if_disabled BEFORE INSERT ON notification_message CREATE TRIGGER protect_deleting_organizations BEFORE UPDATE ON organizations FOR EACH ROW WHEN (((new.deleted = true) AND (old.deleted = false))) EXECUTE FUNCTION protect_deleting_organizations(); +CREATE TRIGGER remap_chat_mcp_server_ids BEFORE INSERT OR UPDATE OF mcp_server_ids ON chats FOR EACH ROW EXECUTE FUNCTION remap_chat_mcp_server_ids_to_chat_org(); + +COMMENT ON TRIGGER remap_chat_mcp_server_ids ON chats IS 'Rolling-upgrade compatibility: remaps config IDs written by pre-organization-scoping replicas to the chat organization''s same-slug config.'; + CREATE TRIGGER remove_chat_mcp_server_config_id BEFORE DELETE ON mcp_server_configs FOR EACH ROW EXECUTE FUNCTION remove_mcp_server_config_id_from_chats(); COMMENT ON TRIGGER remove_chat_mcp_server_config_id ON mcp_server_configs IS 'When an MCP server config is deleted, this trigger removes its ID from all chats.'; diff --git a/coderd/database/migrations/000567_mcp_server_configs_organization_id.down.sql b/coderd/database/migrations/000567_mcp_server_configs_organization_id.down.sql index eceefc90071fb..f86a623dfafee 100644 --- a/coderd/database/migrations/000567_mcp_server_configs_organization_id.down.sql +++ b/coderd/database/migrations/000567_mcp_server_configs_organization_id.down.sql @@ -1,3 +1,8 @@ +-- Drop the remap trigger before the chat update below: it would rewrite the +-- restored default-organization IDs back to the per-organization copies. +DROP TRIGGER IF EXISTS remap_chat_mcp_server_ids ON chats; +DROP FUNCTION IF EXISTS remap_chat_mcp_server_ids_to_chat_org(); + CREATE TEMP TABLE mcp_server_config_restore_map ( config_id UUID PRIMARY KEY, default_config_id UUID NOT NULL diff --git a/coderd/database/migrations/000567_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000567_mcp_server_configs_organization_id.up.sql index ce57a6211311a..b22c8414f9a42 100644 --- a/coderd/database/migrations/000567_mcp_server_configs_organization_id.up.sql +++ b/coderd/database/migrations/000567_mcp_server_configs_organization_id.up.sql @@ -123,3 +123,47 @@ ALTER TABLE mcp_server_configs CREATE INDEX idx_mcp_server_configs_organization_id ON mcp_server_configs (organization_id); + +-- Rolling-upgrade compatibility: replicas running pre-organization-scoping +-- code resolve configs globally and can persist another organization's config +-- ID into a chat. Remap such writes to the chat organization's same-slug +-- config, dropping IDs with no counterpart. Remove once those replicas are gone. +CREATE FUNCTION remap_chat_mcp_server_ids_to_chat_org() + RETURNS TRIGGER AS +$$ +BEGIN + IF NEW.mcp_server_ids IS NULL OR cardinality(NEW.mcp_server_ids) = 0 THEN + RETURN NEW; + END IF; + SELECT COALESCE( + array_agg( + CASE + WHEN config.id IS NULL OR config.organization_id = NEW.organization_id + THEN item.config_id + ELSE same_org_config.id + END + ORDER BY item.position + ) FILTER ( + WHERE config.id IS NULL + OR config.organization_id = NEW.organization_id + OR same_org_config.id IS NOT NULL + ), + '{}'::uuid[] + ) + INTO NEW.mcp_server_ids + FROM unnest(NEW.mcp_server_ids) WITH ORDINALITY AS item(config_id, position) + LEFT JOIN mcp_server_configs AS config ON config.id = item.config_id + LEFT JOIN mcp_server_configs AS same_org_config + ON config.organization_id != NEW.organization_id + AND same_org_config.organization_id = NEW.organization_id + AND same_org_config.slug = config.slug; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER remap_chat_mcp_server_ids + BEFORE INSERT OR UPDATE OF mcp_server_ids ON chats FOR EACH ROW + EXECUTE PROCEDURE remap_chat_mcp_server_ids_to_chat_org(); + +COMMENT ON TRIGGER remap_chat_mcp_server_ids ON chats IS + 'Rolling-upgrade compatibility: remaps config IDs written by pre-organization-scoping replicas to the chat organization''s same-slug config.'; diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 84d914f20e864..02999b89b29ac 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -3292,6 +3292,63 @@ func TestMigration000567MCPServerConfigsOrganizationID(t *testing.T) { require.Equal(t, remap(chat.organizationID, chat.configIDs), getChatIDs(t, chat.id)) } + remapTriggerExists := func(t *testing.T) bool { + t.Helper() + var exists bool + err := sqlDB.QueryRowContext(ctx, ` + SELECT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'remap_chat_mcp_server_ids') + `).Scan(&exists) + require.NoError(t, err) + return exists + } + require.True(t, remapTriggerExists(t)) + + // Simulate a rolling upgrade: a replica still running pre-migration code + // resolves configs globally and writes default-organization IDs into + // another organization's chat. The trigger remaps them to the same-slug + // copies in the chat's organization. + staleWriteChatID := uuid.New() + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO chats ( + id, owner_id, organization_id, last_model_config_id, title, + mcp_server_ids, created_at, updated_at + ) VALUES ($1, $2, $3, $4, 'Migration 567 stale write', $5, $6, $6) + `, staleWriteChatID, userID, liveOrgIDs[0], modelConfigID, pq.Array([]uuid.UUID{configs[1].id, configs[0].id}), now) + require.NoError(t, err) + require.Equal(t, + []uuid.UUID{copiedIDs[liveOrgIDs[0]][configs[1].id], copiedIDs[liveOrgIDs[0]][configs[0].id]}, + getChatIDs(t, staleWriteChatID)) + + // Same for updates. A default-organization config with no same-slug + // counterpart in the chat's organization is dropped instead. + defaultOnlyConfigID := uuid.New() + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO mcp_server_configs ( + id, organization_id, display_name, slug, url, auth_type + ) VALUES ($1, $2, 'Default-only config', 'migration-567-default-only', 'https://mcp.example.com/default-only', 'none') + `, defaultOnlyConfigID, defaultOrgID) + require.NoError(t, err) + _, err = sqlDB.ExecContext(ctx, ` + UPDATE chats SET mcp_server_ids = $2 WHERE id = $1 + `, staleWriteChatID, pq.Array([]uuid.UUID{configs[2].id, defaultOnlyConfigID})) + require.NoError(t, err) + require.Equal(t, []uuid.UUID{copiedIDs[liveOrgIDs[0]][configs[2].id]}, getChatIDs(t, staleWriteChatID)) + + // Writes that already reference the chat organization's configs pass + // through unchanged. + _, err = sqlDB.ExecContext(ctx, ` + UPDATE chats SET mcp_server_ids = $2 WHERE id = $1 + `, chats[0].id, pq.Array(chats[0].configIDs)) + require.NoError(t, err) + require.Equal(t, chats[0].configIDs, getChatIDs(t, chats[0].id)) + + // Remove the simulation rows so the down-migration assertions below see + // the original state. + _, err = sqlDB.ExecContext(ctx, `DELETE FROM chats WHERE id = $1`, staleWriteChatID) + require.NoError(t, err) + _, err = sqlDB.ExecContext(ctx, `DELETE FROM mcp_server_configs WHERE id = $1`, defaultOnlyConfigID) + require.NoError(t, err) + orgOnlyConfigID := uuid.New() _, err = sqlDB.ExecContext(ctx, ` INSERT INTO mcp_server_configs ( @@ -3321,4 +3378,6 @@ func TestMigration000567MCPServerConfigsOrganizationID(t *testing.T) { `).Scan(&danglingIDs) require.NoError(t, err) require.Zero(t, danglingIDs) + + require.False(t, remapTriggerExists(t)) } From 87224c7a4848413eed0c602965569ec81eeaac2c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:28:15 +0000 Subject: [PATCH 19/59] chore: apply cleanup gate round to upgrade-compat diff --- coderd/database/dump.sql | 8 +++----- ...0567_mcp_server_configs_organization_id.up.sql | 15 ++++++--------- coderd/database/migrations/migrate_test.go | 12 ++++-------- 3 files changed, 13 insertions(+), 22 deletions(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 66a7fd977fac7..fc6da0f45a7cd 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1364,13 +1364,11 @@ BEGIN IF NEW.mcp_server_ids IS NULL OR cardinality(NEW.mcp_server_ids) = 0 THEN RETURN NEW; END IF; + -- same_org_config.id is non-NULL only for the foreign, remappable case, + -- so COALESCE keeps missing and same-org IDs as written. SELECT COALESCE( array_agg( - CASE - WHEN config.id IS NULL OR config.organization_id = NEW.organization_id - THEN item.config_id - ELSE same_org_config.id - END + COALESCE(same_org_config.id, item.config_id) ORDER BY item.position ) FILTER ( WHERE config.id IS NULL diff --git a/coderd/database/migrations/000567_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000567_mcp_server_configs_organization_id.up.sql index b22c8414f9a42..6ec728588e3ed 100644 --- a/coderd/database/migrations/000567_mcp_server_configs_organization_id.up.sql +++ b/coderd/database/migrations/000567_mcp_server_configs_organization_id.up.sql @@ -124,10 +124,9 @@ ALTER TABLE mcp_server_configs CREATE INDEX idx_mcp_server_configs_organization_id ON mcp_server_configs (organization_id); --- Rolling-upgrade compatibility: replicas running pre-organization-scoping --- code resolve configs globally and can persist another organization's config --- ID into a chat. Remap such writes to the chat organization's same-slug --- config, dropping IDs with no counterpart. Remove once those replicas are gone. +-- Pre-scoping replicas resolve configs globally and can write another org's +-- config ID into a chat during a rolling upgrade. Remap to the same-slug +-- local config (dropping unmappable IDs); remove once those replicas are gone. CREATE FUNCTION remap_chat_mcp_server_ids_to_chat_org() RETURNS TRIGGER AS $$ @@ -135,13 +134,11 @@ BEGIN IF NEW.mcp_server_ids IS NULL OR cardinality(NEW.mcp_server_ids) = 0 THEN RETURN NEW; END IF; + -- same_org_config.id is non-NULL only for the foreign, remappable case, + -- so COALESCE keeps missing and same-org IDs as written. SELECT COALESCE( array_agg( - CASE - WHEN config.id IS NULL OR config.organization_id = NEW.organization_id - THEN item.config_id - ELSE same_org_config.id - END + COALESCE(same_org_config.id, item.config_id) ORDER BY item.position ) FILTER ( WHERE config.id IS NULL diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 02999b89b29ac..2c3bb247e3fc4 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -3303,10 +3303,8 @@ func TestMigration000567MCPServerConfigsOrganizationID(t *testing.T) { } require.True(t, remapTriggerExists(t)) - // Simulate a rolling upgrade: a replica still running pre-migration code - // resolves configs globally and writes default-organization IDs into - // another organization's chat. The trigger remaps them to the same-slug - // copies in the chat's organization. + // Verify the compatibility trigger remaps stale cross-organization + // writes to same-slug configs in the chat's organization. staleWriteChatID := uuid.New() _, err = sqlDB.ExecContext(ctx, ` INSERT INTO chats ( @@ -3319,8 +3317,8 @@ func TestMigration000567MCPServerConfigsOrganizationID(t *testing.T) { []uuid.UUID{copiedIDs[liveOrgIDs[0]][configs[1].id], copiedIDs[liveOrgIDs[0]][configs[0].id]}, getChatIDs(t, staleWriteChatID)) - // Same for updates. A default-organization config with no same-slug - // counterpart in the chat's organization is dropped instead. + // A config with no same-slug counterpart in the chat's organization is + // dropped instead of remapped. defaultOnlyConfigID := uuid.New() _, err = sqlDB.ExecContext(ctx, ` INSERT INTO mcp_server_configs ( @@ -3334,8 +3332,6 @@ func TestMigration000567MCPServerConfigsOrganizationID(t *testing.T) { require.NoError(t, err) require.Equal(t, []uuid.UUID{copiedIDs[liveOrgIDs[0]][configs[2].id]}, getChatIDs(t, staleWriteChatID)) - // Writes that already reference the chat organization's configs pass - // through unchanged. _, err = sqlDB.ExecContext(ctx, ` UPDATE chats SET mcp_server_ids = $2 WHERE id = $1 `, chats[0].id, pq.Array(chats[0].configIDs)) From c3b60a706ad9cca97a1102efac5c82ef0090f921 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:56:33 +0000 Subject: [PATCH 20/59] chore(coderd/database/migrations): renumber migration 000567 to 000568 Main claimed 000567 for chat file purge indexes. Also adapt main's new Force On enforcement and SSRF discovery tests to organization-scoped MCP configs: the forced set is now read per organization, so fixtures seed forced configs in the chat's organization and a new assertion pins that another organization's force_on server does not attach. --- ...p_server_configs_organization_id.down.sql} | 0 ...mcp_server_configs_organization_id.up.sql} | 0 coderd/database/migrations/migrate_test.go | 38 +++++------ ...mcp_server_configs_organization_id.up.sql} | 0 coderd/exp_chats_test.go | 2 +- coderd/mcp_test.go | 6 +- coderd/x/chatd/forced_mcp_test.go | 64 ++++++++++++------- 7 files changed, 64 insertions(+), 46 deletions(-) rename coderd/database/migrations/{000567_mcp_server_configs_organization_id.down.sql => 000568_mcp_server_configs_organization_id.down.sql} (100%) rename coderd/database/migrations/{000567_mcp_server_configs_organization_id.up.sql => 000568_mcp_server_configs_organization_id.up.sql} (100%) rename coderd/database/migrations/testdata/fixtures/{000567_mcp_server_configs_organization_id.up.sql => 000568_mcp_server_configs_organization_id.up.sql} (100%) diff --git a/coderd/database/migrations/000567_mcp_server_configs_organization_id.down.sql b/coderd/database/migrations/000568_mcp_server_configs_organization_id.down.sql similarity index 100% rename from coderd/database/migrations/000567_mcp_server_configs_organization_id.down.sql rename to coderd/database/migrations/000568_mcp_server_configs_organization_id.down.sql diff --git a/coderd/database/migrations/000567_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000568_mcp_server_configs_organization_id.up.sql similarity index 100% rename from coderd/database/migrations/000567_mcp_server_configs_organization_id.up.sql rename to coderd/database/migrations/000568_mcp_server_configs_organization_id.up.sql diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 2c3bb247e3fc4..73f72c3f35009 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2993,10 +2993,10 @@ func TestMigration000566OAuth2AuthMethodBackfill(t *testing.T) { "the backfill aligns the declaration to what is enforced, so the enforced value must be unchanged") } -func TestMigration000567MCPServerConfigsOrganizationID(t *testing.T) { +func TestMigration000568MCPServerConfigsOrganizationID(t *testing.T) { t.Parallel() - const priorMigrationVersion = 566 + const priorMigrationVersion = 567 sqlDB := testSQLDB(t) next, err := migrations.Stepper(sqlDB) @@ -3028,7 +3028,7 @@ func TestMigration000567MCPServerConfigsOrganizationID(t *testing.T) { id, name, display_name, description, icon, created_at, updated_at, is_default, deleted, default_org_member_roles ) VALUES ($1, $2, $3, '', '', $4, $4, false, $5, '{}') - `, orgID, fmt.Sprintf("migration-567-org-%d", i), fmt.Sprintf("Migration 567 Org %d", i), now, orgID == deletedOrgID) + `, orgID, fmt.Sprintf("migration-568-org-%d", i), fmt.Sprintf("Migration 568 Org %d", i), now, orgID == deletedOrgID) require.NoError(t, err) } @@ -3037,14 +3037,14 @@ func TestMigration000567MCPServerConfigsOrganizationID(t *testing.T) { INSERT INTO users ( id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type - ) VALUES ($1, 'migration-567-user', 'migration-567@example.com', ''::bytea, $2, $2, 'active', '{}', 'password') + ) VALUES ($1, 'migration-568-user', 'migration-568@example.com', ''::bytea, $2, $2, 'active', '{}', 'password') `, userID, now) require.NoError(t, err) - const keyDigest = "migration-567-key" + const keyDigest = "migration-568-key" _, err = sqlDB.ExecContext(ctx, ` INSERT INTO dbcrypt_keys (number, active_key_digest, test) - VALUES (567000, $1, 'migration-567-test') + VALUES (568000, $1, 'migration-568-test') `, keyDigest) require.NoError(t, err) @@ -3053,14 +3053,14 @@ func TestMigration000567MCPServerConfigsOrganizationID(t *testing.T) { _, err = sqlDB.ExecContext(ctx, ` INSERT INTO ai_providers ( id, type, name, display_name, enabled, base_url, created_at, updated_at - ) VALUES ($1, 'openai', 'migration-567-provider', 'Migration 567 Provider', true, 'https://provider.example.com', $2, $2) + ) VALUES ($1, 'openai', 'migration-568-provider', 'Migration 568 Provider', true, 'https://provider.example.com', $2, $2) `, providerID, now) require.NoError(t, err) _, err = sqlDB.ExecContext(ctx, ` INSERT INTO chat_model_configs ( id, model, display_name, ai_provider_id, context_limit, compression_threshold, created_at, updated_at - ) VALUES ($1, 'migration-567-model', 'Migration 567 Model', $2, 128000, 70, $3, $3) + ) VALUES ($1, 'migration-568-model', 'Migration 568 Model', $2, 128000, 70, $3, $3) `, modelConfigID, providerID, now) require.NoError(t, err) @@ -3083,14 +3083,14 @@ func TestMigration000567MCPServerConfigsOrganizationID(t *testing.T) { } keyID := sql.NullString{String: keyDigest, Valid: true} configs := []configSeed{ - {id: uuid.New(), slug: "migration-567-none", authType: "none", apiKeyHeader: "Authorization", customHeaders: "{}"}, + {id: uuid.New(), slug: "migration-568-none", authType: "none", apiKeyHeader: "Authorization", customHeaders: "{}"}, // Leftover OAuth fields on a non-oauth2 config: the API stores // them for any auth type, so copies must clear them too. - {id: uuid.New(), slug: "migration-567-api-key", authType: "api_key", apiKeyHeader: "X-API-Key", apiKeyValue: "api-key-ciphertext", apiKeyValueKeyID: keyID, customHeaders: "{}", oauth2ClientID: "leftover-client-id", oauth2ClientSecret: "leftover-secret-ciphertext", oauth2ClientSecretKeyID: keyID, oauth2TokenURL: "https://oauth.example.com/leftover-token"}, - {id: uuid.New(), slug: "migration-567-custom-headers", authType: "custom_headers", apiKeyHeader: "Authorization", customHeaders: "custom-headers-ciphertext", customHeadersKeyID: keyID}, + {id: uuid.New(), slug: "migration-568-api-key", authType: "api_key", apiKeyHeader: "X-API-Key", apiKeyValue: "api-key-ciphertext", apiKeyValueKeyID: keyID, customHeaders: "{}", oauth2ClientID: "leftover-client-id", oauth2ClientSecret: "leftover-secret-ciphertext", oauth2ClientSecretKeyID: keyID, oauth2TokenURL: "https://oauth.example.com/leftover-token"}, + {id: uuid.New(), slug: "migration-568-custom-headers", authType: "custom_headers", apiKeyHeader: "Authorization", customHeaders: "custom-headers-ciphertext", customHeadersKeyID: keyID}, { id: uuid.New(), - slug: "migration-567-oauth2", + slug: "migration-568-oauth2", authType: "oauth2", oauth2ClientID: "oauth-client-id", oauth2ClientSecret: "oauth-secret-ciphertext", @@ -3121,7 +3121,7 @@ func TestMigration000567MCPServerConfigsOrganizationID(t *testing.T) { $19, $19, $20, $20 ) `, - config.id, "Migration 567 "+config.authType, config.slug, "migration 567 config", "https://mcp.example.com/"+config.slug, config.authType, + config.id, "Migration 568 "+config.authType, config.slug, "migration 568 config", "https://mcp.example.com/"+config.slug, config.authType, config.oauth2ClientID, config.oauth2ClientSecret, config.oauth2ClientSecretKeyID, config.oauth2AuthURL, config.oauth2TokenURL, config.oauth2RevocationURL, config.oauth2Scopes, config.apiKeyHeader, config.apiKeyValue, config.apiKeyValueKeyID, @@ -3160,13 +3160,13 @@ func TestMigration000567MCPServerConfigsOrganizationID(t *testing.T) { id, owner_id, organization_id, last_model_config_id, title, mcp_server_ids, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $7) - `, chat.id, userID, chat.organizationID, modelConfigID, fmt.Sprintf("Migration 567 Chat %d", i), pq.Array(chat.configIDs), now) + `, chat.id, userID, chat.organizationID, modelConfigID, fmt.Sprintf("Migration 568 Chat %d", i), pq.Array(chat.configIDs), now) require.NoError(t, err) } version, _, err := next() require.NoError(t, err) - require.EqualValues(t, 567, version) + require.EqualValues(t, 568, version) var totalConfigs int err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_server_configs`).Scan(&totalConfigs) @@ -3310,7 +3310,7 @@ func TestMigration000567MCPServerConfigsOrganizationID(t *testing.T) { INSERT INTO chats ( id, owner_id, organization_id, last_model_config_id, title, mcp_server_ids, created_at, updated_at - ) VALUES ($1, $2, $3, $4, 'Migration 567 stale write', $5, $6, $6) + ) VALUES ($1, $2, $3, $4, 'Migration 568 stale write', $5, $6, $6) `, staleWriteChatID, userID, liveOrgIDs[0], modelConfigID, pq.Array([]uuid.UUID{configs[1].id, configs[0].id}), now) require.NoError(t, err) require.Equal(t, @@ -3323,7 +3323,7 @@ func TestMigration000567MCPServerConfigsOrganizationID(t *testing.T) { _, err = sqlDB.ExecContext(ctx, ` INSERT INTO mcp_server_configs ( id, organization_id, display_name, slug, url, auth_type - ) VALUES ($1, $2, 'Default-only config', 'migration-567-default-only', 'https://mcp.example.com/default-only', 'none') + ) VALUES ($1, $2, 'Default-only config', 'migration-568-default-only', 'https://mcp.example.com/default-only', 'none') `, defaultOnlyConfigID, defaultOrgID) require.NoError(t, err) _, err = sqlDB.ExecContext(ctx, ` @@ -3349,7 +3349,7 @@ func TestMigration000567MCPServerConfigsOrganizationID(t *testing.T) { _, err = sqlDB.ExecContext(ctx, ` INSERT INTO mcp_server_configs ( id, organization_id, display_name, slug, url, auth_type - ) VALUES ($1, $2, 'Org-only config', 'migration-567-org-only', 'https://mcp.example.com/org-only', 'none') + ) VALUES ($1, $2, 'Org-only config', 'migration-568-org-only', 'https://mcp.example.com/org-only', 'none') `, orgOnlyConfigID, liveOrgIDs[0]) require.NoError(t, err) _, err = sqlDB.ExecContext(ctx, ` @@ -3357,7 +3357,7 @@ func TestMigration000567MCPServerConfigsOrganizationID(t *testing.T) { `, chats[1].id, orgOnlyConfigID) require.NoError(t, err) - downSQL, err := os.ReadFile("000567_mcp_server_configs_organization_id.down.sql") + downSQL, err := os.ReadFile("000568_mcp_server_configs_organization_id.down.sql") require.NoError(t, err) _, err = sqlDB.ExecContext(ctx, string(downSQL)) require.NoError(t, err) diff --git a/coderd/database/migrations/testdata/fixtures/000567_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/testdata/fixtures/000568_mcp_server_configs_organization_id.up.sql similarity index 100% rename from coderd/database/migrations/testdata/fixtures/000567_mcp_server_configs_organization_id.up.sql rename to coderd/database/migrations/testdata/fixtures/000568_mcp_server_configs_organization_id.up.sql diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 59e327a1b6b0e..55b7fdbb32d55 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -1311,7 +1311,7 @@ func TestChats_ForceOnMCPServerEnforced(t *testing.T) { _ = createChatModelConfig(t, client) // An admin marks an MCP server as Force On. - forced, err := client.Client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + forced, err := client.Client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Forced Server", Slug: "forced-server", Transport: "streamable_http", diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 6bc87360074be..cb1e9e7047018 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -1621,9 +1621,9 @@ func TestMCPServerConfigsOAuth2AutoDiscoverySSRF(t *testing.T) { netip.MustParsePrefix("127.0.0.1/32"), }, }) - _ = coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) - _, err = client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + _, err = client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "SSRF Attacker", Slug: "ssrf-attacker", Transport: "streamable_http", @@ -1642,7 +1642,7 @@ func TestMCPServerConfigsOAuth2AutoDiscoverySSRF(t *testing.T) { require.EqualValues(t, 0, canaryHits.Load(), "internal canary must never be contacted via attacker redirect") // The partially created config must have been cleaned up. - configs, err := client.MCPServerConfigs(ctx) + configs, err := client.MCPServerConfigs(ctx, firstUser.OrganizationID) require.NoError(t, err) for _, config := range configs { require.NotEqual(t, "ssrf-attacker", config.Slug) diff --git a/coderd/x/chatd/forced_mcp_test.go b/coderd/x/chatd/forced_mcp_test.go index 7ef075ac563a3..01aba71d2e871 100644 --- a/coderd/x/chatd/forced_mcp_test.go +++ b/coderd/x/chatd/forced_mcp_test.go @@ -81,12 +81,13 @@ func TestCreateChat_ForceOnMCPServerEnforced(t *testing.T) { user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) forcedConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "Forced MCP", - Slug: "forced-mcp", - Url: forcedURL, - Availability: "force_on", - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "Forced MCP", + Slug: "forced-mcp", + Url: forcedURL, + Availability: "force_on", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { @@ -144,19 +145,21 @@ func TestSendMessage_ForceOnMCPServerEnforced(t *testing.T) { user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) forcedConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "Forced MCP", - Slug: "forced-mcp", - Url: forcedURL, - Availability: "force_on", - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "Forced MCP", + Slug: "forced-mcp", + Url: forcedURL, + Availability: "force_on", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) optionalConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "Optional MCP", - Slug: "optional-mcp", - Url: optionalURL, - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "Optional MCP", + Slug: "optional-mcp", + Url: optionalURL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { @@ -254,12 +257,25 @@ func TestGeneration_ForceOnMCPServerEnforcedForExistingChats(t *testing.T) { // An admin marks a server force_on after the chat already exists. dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "Forced MCP", - Slug: "forced-mcp", - Url: forcedURL, - Availability: "force_on", - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "Forced MCP", + Slug: "forced-mcp", + Url: forcedURL, + Availability: "force_on", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + }) + + // A force_on server in another organization must not attach: the + // forced set is scoped to the chat's organization. + dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: dbgen.Organization(t, db, database.Organization{}).ID, + DisplayName: "Foreign Forced MCP", + Slug: "foreign-forced-mcp", + Url: forcedURL, + Availability: "force_on", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) // A send that does not touch mcp_server_ids must still pick up @@ -287,4 +303,6 @@ func TestGeneration_ForceOnMCPServerEnforcedForExistingChats(t *testing.T) { "no force_on server existed during the first turn") require.Contains(t, calls[len(calls)-1], "forced-mcp__echo", "force_on MCP tools must reach generation for chats created before the policy") + require.NotContains(t, calls[len(calls)-1], "foreign-forced-mcp__echo", + "another organization's force_on server must not attach") } From 55065a7e250192d3db3d719ff7136227e11285a0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:38:26 +0000 Subject: [PATCH 21/59] fix(coderd/x/chatd): seed computer-use MCP inheritance test in the chat org --- coderd/x/chatd/subagent_internal_test.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/coderd/x/chatd/subagent_internal_test.go b/coderd/x/chatd/subagent_internal_test.go index f8c5ffd678765..b46f62cd5f42a 100644 --- a/coderd/x/chatd/subagent_internal_test.go +++ b/coderd/x/chatd/subagent_internal_test.go @@ -3363,11 +3363,12 @@ func TestSpawnAgent_ComputerUseInheritsMCPServerIDs(t *testing.T) { insertEnabledAnthropicProvider(t, db, user.ID) mcpCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "MCP Test", - Slug: "mcp-test", - Url: "https://mcp.example.com", - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "MCP Test", + Slug: "mcp-test", + Url: "https://mcp.example.com", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) parentMCPIDs := []uuid.UUID{mcpCfg.ID} From ca6d748b1df51b3e6edf04fb3a84c38637ab4de5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:03:48 +0000 Subject: [PATCH 22/59] chore(coderd/database/migrations): renumber migration 000568 to 000569 Main claimed 000568 for service account notifications. --- ...=> 000569_mcp_server_configs_organization_id.down.sql} | 0 ...l => 000569_mcp_server_configs_organization_id.up.sql} | 0 coderd/database/migrations/migrate_test.go | 8 ++++---- ...l => 000569_mcp_server_configs_organization_id.up.sql} | 0 4 files changed, 4 insertions(+), 4 deletions(-) rename coderd/database/migrations/{000568_mcp_server_configs_organization_id.down.sql => 000569_mcp_server_configs_organization_id.down.sql} (100%) rename coderd/database/migrations/{000568_mcp_server_configs_organization_id.up.sql => 000569_mcp_server_configs_organization_id.up.sql} (100%) rename coderd/database/migrations/testdata/fixtures/{000568_mcp_server_configs_organization_id.up.sql => 000569_mcp_server_configs_organization_id.up.sql} (100%) diff --git a/coderd/database/migrations/000568_mcp_server_configs_organization_id.down.sql b/coderd/database/migrations/000569_mcp_server_configs_organization_id.down.sql similarity index 100% rename from coderd/database/migrations/000568_mcp_server_configs_organization_id.down.sql rename to coderd/database/migrations/000569_mcp_server_configs_organization_id.down.sql diff --git a/coderd/database/migrations/000568_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000569_mcp_server_configs_organization_id.up.sql similarity index 100% rename from coderd/database/migrations/000568_mcp_server_configs_organization_id.up.sql rename to coderd/database/migrations/000569_mcp_server_configs_organization_id.up.sql diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 73f72c3f35009..5388d9f6cbf02 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2993,10 +2993,10 @@ func TestMigration000566OAuth2AuthMethodBackfill(t *testing.T) { "the backfill aligns the declaration to what is enforced, so the enforced value must be unchanged") } -func TestMigration000568MCPServerConfigsOrganizationID(t *testing.T) { +func TestMigration000569MCPServerConfigsOrganizationID(t *testing.T) { t.Parallel() - const priorMigrationVersion = 567 + const priorMigrationVersion = 568 sqlDB := testSQLDB(t) next, err := migrations.Stepper(sqlDB) @@ -3166,7 +3166,7 @@ func TestMigration000568MCPServerConfigsOrganizationID(t *testing.T) { version, _, err := next() require.NoError(t, err) - require.EqualValues(t, 568, version) + require.EqualValues(t, 569, version) var totalConfigs int err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_server_configs`).Scan(&totalConfigs) @@ -3357,7 +3357,7 @@ func TestMigration000568MCPServerConfigsOrganizationID(t *testing.T) { `, chats[1].id, orgOnlyConfigID) require.NoError(t, err) - downSQL, err := os.ReadFile("000568_mcp_server_configs_organization_id.down.sql") + downSQL, err := os.ReadFile("000569_mcp_server_configs_organization_id.down.sql") require.NoError(t, err) _, err = sqlDB.ExecContext(ctx, string(downSQL)) require.NoError(t, err) diff --git a/coderd/database/migrations/testdata/fixtures/000568_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/testdata/fixtures/000569_mcp_server_configs_organization_id.up.sql similarity index 100% rename from coderd/database/migrations/testdata/fixtures/000568_mcp_server_configs_organization_id.up.sql rename to coderd/database/migrations/testdata/fixtures/000569_mcp_server_configs_organization_id.up.sql From b1b2f02444ad7387d91bf96e56cd7f1a1ea30322 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:31:46 +0000 Subject: [PATCH 23/59] fix(coderd/database): renumber MCP org migration to 000570 Main gained 000569_oauth2_scope_columns, so the organization-scoping migration moves to the next free version. --- ...=> 000570_mcp_server_configs_organization_id.down.sql} | 0 ...l => 000570_mcp_server_configs_organization_id.up.sql} | 0 coderd/database/migrations/migrate_test.go | 8 ++++---- ...l => 000570_mcp_server_configs_organization_id.up.sql} | 0 4 files changed, 4 insertions(+), 4 deletions(-) rename coderd/database/migrations/{000569_mcp_server_configs_organization_id.down.sql => 000570_mcp_server_configs_organization_id.down.sql} (100%) rename coderd/database/migrations/{000569_mcp_server_configs_organization_id.up.sql => 000570_mcp_server_configs_organization_id.up.sql} (100%) rename coderd/database/migrations/testdata/fixtures/{000569_mcp_server_configs_organization_id.up.sql => 000570_mcp_server_configs_organization_id.up.sql} (100%) diff --git a/coderd/database/migrations/000569_mcp_server_configs_organization_id.down.sql b/coderd/database/migrations/000570_mcp_server_configs_organization_id.down.sql similarity index 100% rename from coderd/database/migrations/000569_mcp_server_configs_organization_id.down.sql rename to coderd/database/migrations/000570_mcp_server_configs_organization_id.down.sql diff --git a/coderd/database/migrations/000569_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql similarity index 100% rename from coderd/database/migrations/000569_mcp_server_configs_organization_id.up.sql rename to coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 5388d9f6cbf02..c47ed0f93bda1 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2993,10 +2993,10 @@ func TestMigration000566OAuth2AuthMethodBackfill(t *testing.T) { "the backfill aligns the declaration to what is enforced, so the enforced value must be unchanged") } -func TestMigration000569MCPServerConfigsOrganizationID(t *testing.T) { +func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { t.Parallel() - const priorMigrationVersion = 568 + const priorMigrationVersion = 569 sqlDB := testSQLDB(t) next, err := migrations.Stepper(sqlDB) @@ -3166,7 +3166,7 @@ func TestMigration000569MCPServerConfigsOrganizationID(t *testing.T) { version, _, err := next() require.NoError(t, err) - require.EqualValues(t, 569, version) + require.EqualValues(t, 570, version) var totalConfigs int err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_server_configs`).Scan(&totalConfigs) @@ -3357,7 +3357,7 @@ func TestMigration000569MCPServerConfigsOrganizationID(t *testing.T) { `, chats[1].id, orgOnlyConfigID) require.NoError(t, err) - downSQL, err := os.ReadFile("000569_mcp_server_configs_organization_id.down.sql") + downSQL, err := os.ReadFile("000570_mcp_server_configs_organization_id.down.sql") require.NoError(t, err) _, err = sqlDB.ExecContext(ctx, string(downSQL)) require.NoError(t, err) diff --git a/coderd/database/migrations/testdata/fixtures/000569_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/testdata/fixtures/000570_mcp_server_configs_organization_id.up.sql similarity index 100% rename from coderd/database/migrations/testdata/fixtures/000569_mcp_server_configs_organization_id.up.sql rename to coderd/database/migrations/testdata/fixtures/000570_mcp_server_configs_organization_id.up.sql From 0dc154df2e1a1587be7264a13a840577df63efb1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:22:39 +0000 Subject: [PATCH 24/59] fix(coderd): require deployment perms for user_oidc MCP configs Organization scoping moved MCP config management from deployment admins to organization admins, but user_oidc configs forward each chat owner's upstream OIDC access token to the configured URL with no per-user consent. Restore the pre-scoping deployment-level boundary for creating user_oidc configs and for any update that touches one, so organization admins cannot point member tokens at URLs they control. --- coderd/mcp.go | 27 ++++++++++++++++++++ coderd/mcp_test.go | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/coderd/mcp.go b/coderd/mcp.go index 4263e1df7e966..816f9a3adc8c4 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -236,6 +236,10 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { return } + if req.AuthType == "user_oidc" && !api.authorizeUserOIDCMCPServerConfig(rw, r) { + return + } + if trimmed := strings.TrimSpace(req.OAuth2RevocationURL); trimmed != "" { if err := mcpclient.ValidateRevocationEndpoint(trimmed); err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -562,6 +566,23 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, sdkConfig) } +// authorizeUserOIDCMCPServerConfig requires deployment-level +// configuration permission for user_oidc MCP server configs. +// user_oidc forwards each chat owner's upstream OIDC access token to +// the configured URL with no per-user consent step, so organization +// admins must not be able to create such configs or repoint their +// URLs. Before organization scoping this was the boundary for every +// config mutation. +func (api *API) authorizeUserOIDCMCPServerConfig(rw http.ResponseWriter, r *http.Request) bool { + if api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { + return true + } + httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{ + Message: "Managing user_oidc MCP server configs requires deployment-level permissions.", + }) + return false +} + // Preserve the param middleware's 404 concealment. Write denial is a 403. func (api *API) getMCPServerConfigForMutation(rw http.ResponseWriter, r *http.Request, action policy.Action) (database.MCPServerConfig, bool) { config := httpmw.MCPServerConfigParam(r) @@ -590,6 +611,12 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { return } + touchesUserOIDC := existing.AuthType == "user_oidc" || + (req.AuthType != nil && *req.AuthType == "user_oidc") + if touchesUserOIDC && !api.authorizeUserOIDCMCPServerConfig(rw, r) { + return + } + // Validated here rather than via a struct tag because an empty // string is a valid value that clears the stored URL. if req.OAuth2RevocationURL != nil { diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index cb1e9e7047018..6649bf511e70e 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -24,6 +24,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -522,6 +523,66 @@ func TestMCPServerConfigsUserOIDCDirect(t *testing.T) { require.False(t, created.HasCustomHeaders) } +func TestMCPServerConfigsUserOIDCRequiresDeploymentPerms(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient := newMCPClient(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + orgAdminClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID, + rbac.ScopedRoleOrgAdmin(firstUser.OrganizationID)) + + newRequest := func(slug, authType string) codersdk.CreateMCPServerConfigRequest { + return codersdk.CreateMCPServerConfigRequest{ + DisplayName: "OIDC Gate " + slug, + Slug: slug, + Transport: "streamable_http", + URL: "https://mcp.example.com/" + slug, + AuthType: authType, + Availability: "default_off", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, + } + } + + // Org admins keep full control of non-user_oidc configs. + orgAdminOwned, err := orgAdminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, newRequest("org-admin-none", "none")) + require.NoError(t, err) + + var sdkErr *codersdk.Error + _, err = orgAdminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, newRequest("org-admin-oidc", "user_oidc")) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + + userOIDC := "user_oidc" + _, err = orgAdminClient.UpdateMCPServerConfig(ctx, orgAdminOwned.ID, codersdk.UpdateMCPServerConfigRequest{ + AuthType: &userOIDC, + }) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + + // Deployment admins retain the pre-org-scoping boundary. + deploymentOwned, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, newRequest("deployment-oidc", "user_oidc")) + require.NoError(t, err) + + // Org admins cannot repoint an existing user_oidc config either; + // the URL controls where member tokens are sent. + newURL := "https://attacker.example.com/exfil" + _, err = orgAdminClient.UpdateMCPServerConfig(ctx, deploymentOwned.ID, codersdk.UpdateMCPServerConfigRequest{ + URL: &newURL, + }) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + + updatedURL := "https://mcp.example.com/deployment-oidc-v2" + updated, err := adminClient.UpdateMCPServerConfig(ctx, deploymentOwned.ID, codersdk.UpdateMCPServerConfigRequest{ + URL: &updatedURL, + }) + require.NoError(t, err) + require.Equal(t, updatedURL, updated.URL) +} + func TestMCPServerConfigsAvailability(t *testing.T) { t.Parallel() From d7f54b0c8b14b31b2f0a24804d692113db5a966c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:08:19 +0000 Subject: [PATCH 25/59] fix(coderd/database): keep dbcrypt no-auth MCP config copies enabled dbcrypt stores an empty custom-header map as ciphertext with a key ID, so the migration's plaintext credential check disabled every copied no-auth config on encrypted deployments. Apply the plaintext check only to unencrypted rows; encrypted rows fall back to the auth_type decision. --- .../000570_mcp_server_configs_organization_id.up.sql | 7 ++++++- coderd/database/migrations/migrate_test.go | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql index 6ec728588e3ed..468c824ce939e 100644 --- a/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql +++ b/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql @@ -80,7 +80,12 @@ SELECT -- organization's admin re-enters them deliberately. CASE WHEN config.auth_type IN ('oauth2', 'api_key', 'custom_headers') - OR config.custom_headers NOT IN ('', '{}') + -- dbcrypt stores every nonblank value as ciphertext, so this + -- plaintext check only applies to unencrypted rows. Encrypted + -- rows fall back to the auth_type decision above; header + -- values are only sent for auth_type custom_headers anyway. + OR (config.custom_headers_key_id IS NULL + AND config.custom_headers NOT IN ('', '{}')) THEN false ELSE config.enabled END, diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index c47ed0f93bda1..b7f2a54ba609e 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -3102,6 +3102,9 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { apiKeyHeader: "Authorization", customHeaders: "{}", }, + // Under dbcrypt an empty header map is stored as ciphertext, so + // the copy decision must not read it as real credentials. + {id: uuid.New(), slug: "migration-568-none-dbcrypt", authType: "none", apiKeyHeader: "Authorization", customHeaders: "empty-map-ciphertext", customHeadersKeyID: keyID}, } originalJSON := make(map[uuid.UUID]string, len(configs)) From 6ee6f380049694b038a7fd52c829ab20b8ddac0e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:14:39 +0000 Subject: [PATCH 26/59] fix(coderd): invalidate MCP user grants when config destinations change Stored oauth2 user tokens are consented for a specific endpoint, so an organization admin repointing a config's URL would replay members' tokens against the new destination. Delete a config's user tokens inside the update transaction whenever its URL or auth type changes. Also evaluate the user_oidc deployment gate inside the transaction so it always sees the current row instead of the middleware snapshot, closing the race with a concurrent conversion to user_oidc. --- coderd/database/dbauthz/dbauthz.go | 11 ++ coderd/database/dbauthz/dbauthz_test.go | 6 ++ coderd/database/dbmetrics/querymetrics.go | 8 ++ coderd/database/dbmock/dbmock.go | 14 +++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 12 +++ coderd/database/queries/mcpserverconfigs.sql | 6 ++ coderd/mcp.go | 32 ++++-- coderd/mcp_test.go | 104 +++++++++++++++++++ 9 files changed, 188 insertions(+), 6 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 5a1b6e1cbac89..4c6ffaea63a0b 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2301,6 +2301,17 @@ func (q *querier) DeleteMCPServerUserToken(ctx context.Context, arg database.Del return q.db.DeleteMCPServerUserToken(ctx, arg) } +func (q *querier) DeleteMCPServerUserTokensByConfigID(ctx context.Context, mcpServerConfigID uuid.UUID) error { + config, err := q.db.GetMCPServerConfigByID(ctx, mcpServerConfigID) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, config); err != nil { + return err + } + return q.db.DeleteMCPServerUserTokensByConfigID(ctx, mcpServerConfigID) +} + func (q *querier) DeleteOAuth2ProviderAppByClientID(ctx context.Context, id uuid.UUID) error { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceOauth2App); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 1c1d05a90796d..d6f8619836e43 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1646,6 +1646,12 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().DeleteMCPServerUserToken(gomock.Any(), arg).Return(nil).AnyTimes() check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) + s.Run("DeleteMCPServerUserTokensByConfigID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + config := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) + dbm.EXPECT().GetMCPServerConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes() + dbm.EXPECT().DeleteMCPServerUserTokensByConfigID(gomock.Any(), config.ID).Return(nil).AnyTimes() + check.Args(config.ID).Asserts(config, policy.ActionUpdate) + })) s.Run("GetEnabledMCPServerConfigsByOrganization", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { orgID := uuid.New() configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{OrganizationID: orgID, Enabled: true}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index fddcbb38dabb2..cb59792564d1a 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -625,6 +625,14 @@ func (m queryMetricsStore) DeleteMCPServerUserToken(ctx context.Context, arg dat return r0 } +func (m queryMetricsStore) DeleteMCPServerUserTokensByConfigID(ctx context.Context, mcpServerConfigID uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteMCPServerUserTokensByConfigID(ctx, mcpServerConfigID) + m.queryLatencies.WithLabelValues("DeleteMCPServerUserTokensByConfigID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteMCPServerUserTokensByConfigID").Inc() + return r0 +} + func (m queryMetricsStore) DeleteOAuth2ProviderAppByClientID(ctx context.Context, id uuid.UUID) error { start := time.Now() r0 := m.s.DeleteOAuth2ProviderAppByClientID(ctx, id) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 44e47f653caba..1aff26fe53c73 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1035,6 +1035,20 @@ func (mr *MockStoreMockRecorder) DeleteMCPServerUserToken(ctx, arg any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMCPServerUserToken", reflect.TypeOf((*MockStore)(nil).DeleteMCPServerUserToken), ctx, arg) } +// DeleteMCPServerUserTokensByConfigID mocks base method. +func (m *MockStore) DeleteMCPServerUserTokensByConfigID(ctx context.Context, mcpServerConfigID uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteMCPServerUserTokensByConfigID", ctx, mcpServerConfigID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteMCPServerUserTokensByConfigID indicates an expected call of DeleteMCPServerUserTokensByConfigID. +func (mr *MockStoreMockRecorder) DeleteMCPServerUserTokensByConfigID(ctx, mcpServerConfigID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMCPServerUserTokensByConfigID", reflect.TypeOf((*MockStore)(nil).DeleteMCPServerUserTokensByConfigID), ctx, mcpServerConfigID) +} + // DeleteOAuth2ProviderAppByClientID mocks base method. func (m *MockStore) DeleteOAuth2ProviderAppByClientID(ctx context.Context, id uuid.UUID) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 330a9e1f0e7d0..8b5cd67f84eda 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -161,6 +161,7 @@ type sqlcQuerier interface { DeleteLicense(ctx context.Context, id int32) (int32, error) DeleteMCPServerConfigByID(ctx context.Context, id uuid.UUID) error DeleteMCPServerUserToken(ctx context.Context, arg DeleteMCPServerUserTokenParams) error + DeleteMCPServerUserTokensByConfigID(ctx context.Context, mcpServerConfigID uuid.UUID) error DeleteOAuth2ProviderAppByClientID(ctx context.Context, id uuid.UUID) error DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error DeleteOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 962790bb5e73f..4c61e404ddfbc 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -17133,6 +17133,18 @@ func (q *sqlQuerier) DeleteMCPServerUserToken(ctx context.Context, arg DeleteMCP return err } +const deleteMCPServerUserTokensByConfigID = `-- name: DeleteMCPServerUserTokensByConfigID :exec +DELETE FROM + mcp_server_user_tokens +WHERE + mcp_server_config_id = $1::uuid +` + +func (q *sqlQuerier) DeleteMCPServerUserTokensByConfigID(ctx context.Context, mcpServerConfigID uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteMCPServerUserTokensByConfigID, mcpServerConfigID) + return err +} + const getEnabledMCPServerConfigsByOrganization = `-- name: GetEnabledMCPServerConfigsByOrganization :many SELECT id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id diff --git a/coderd/database/queries/mcpserverconfigs.sql b/coderd/database/queries/mcpserverconfigs.sql index 27f8bc352cf21..8afee31b8b487 100644 --- a/coderd/database/queries/mcpserverconfigs.sql +++ b/coderd/database/queries/mcpserverconfigs.sql @@ -267,6 +267,12 @@ WHERE mcp_server_config_id = @mcp_server_config_id::uuid AND user_id = @user_id::uuid; +-- name: DeleteMCPServerUserTokensByConfigID :exec +DELETE FROM + mcp_server_user_tokens +WHERE + mcp_server_config_id = @mcp_server_config_id::uuid; + -- name: CleanupDeletedMCPServerIDsFromChats :exec UPDATE chats SET mcp_server_ids = ( diff --git a/coderd/mcp.go b/coderd/mcp.go index 816f9a3adc8c4..53b4efc1a2a44 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -566,6 +566,11 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, sdkConfig) } +// errUserOIDCRequiresDeploymentPerms marks an update refused by the +// user_oidc gate inside the transaction, after the row is current, +// so the handler can map it to a 403. +var errUserOIDCRequiresDeploymentPerms = xerrors.New("managing user_oidc MCP server configs requires deployment-level permissions") + // authorizeUserOIDCMCPServerConfig requires deployment-level // configuration permission for user_oidc MCP server configs. // user_oidc forwards each chat owner's upstream OIDC access token to @@ -611,12 +616,6 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { return } - touchesUserOIDC := existing.AuthType == "user_oidc" || - (req.AuthType != nil && *req.AuthType == "user_oidc") - if touchesUserOIDC && !api.authorizeUserOIDCMCPServerConfig(rw, r) { - return - } - // Validated here rather than via a struct tag because an empty // string is a valid value that clears the stored URL. if req.OAuth2RevocationURL != nil { @@ -656,6 +655,12 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { var updated database.MCPServerConfig err := api.Database.InTx(func(tx database.Store) error { + touchesUserOIDC := existing.AuthType == "user_oidc" || + (req.AuthType != nil && *req.AuthType == "user_oidc") + if touchesUserOIDC && !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { + return errUserOIDCRequiresDeploymentPerms + } + displayName := existing.DisplayName if req.DisplayName != nil { displayName = strings.TrimSpace(*req.DisplayName) @@ -843,6 +848,16 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { } } + // Stored user grants are bound to the destination and auth + // flow they were consented for. Invalidate them when either + // changes so an updated config cannot replay members' tokens + // against a different endpoint. + if serverURL != existing.Url || authType != existing.AuthType { + if err := tx.DeleteMCPServerUserTokensByConfigID(ctx, existing.ID); err != nil { + return xerrors.Errorf("invalidate MCP server user tokens: %w", err) + } + } + updatedConfig, err := tx.UpdateMCPServerConfig(ctx, database.UpdateMCPServerConfigParams{ DisplayName: displayName, Slug: slug, @@ -881,6 +896,11 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { }, nil) if err != nil { switch { + case errors.Is(err, errUserOIDCRequiresDeploymentPerms): + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "Managing user_oidc MCP server configs requires deployment-level permissions.", + }) + return case httpapi.Is404Error(err): httpapi.ResourceNotFound(rw) return diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 6649bf511e70e..80265fa986826 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -1,10 +1,12 @@ package coderd_test import ( + "context" "crypto/sha256" "database/sql" "encoding/base64" "encoding/json" + "errors" "net" "net/http" "net/http/httptest" @@ -523,6 +525,108 @@ func TestMCPServerConfigsUserOIDCDirect(t *testing.T) { require.False(t, created.HasCustomHeaders) } +func TestMCPServerConfigsUpdateInvalidatesUserGrants(t *testing.T) { + t.Parallel() + + providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t) + adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), + ChatProviderAPIKeys: &providerKeys, + }) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + _, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + newConfig := func(ctx context.Context, t *testing.T, slug string) codersdk.MCPServerConfig { + t.Helper() + created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "Grant Invalidation " + slug, + Slug: slug, + Transport: "streamable_http", + URL: "https://mcp.example.com/" + slug, + AuthType: "oauth2", + OAuth2ClientID: "cid", + OAuth2AuthURL: "https://auth.example.com/authorize", + OAuth2TokenURL: "https://auth.example.com/token", + Availability: "default_on", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, + }) + require.NoError(t, err) + return created + } + + seedToken := func(ctx context.Context, t *testing.T, configID uuid.UUID) { + t.Helper() + //nolint:gocritic // Seeding a member grant requires system access. + _, err := db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{ + MCPServerConfigID: configID, + UserID: member.ID, + AccessToken: "access-token", + RefreshToken: "refresh-token", + TokenType: "Bearer", + Expiry: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true}, + }) + require.NoError(t, err) + } + + tokenExists := func(ctx context.Context, t *testing.T, configID uuid.UUID) bool { + t.Helper() + //nolint:gocritic // Verifying persisted state requires system access. + _, err := db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{ + MCPServerConfigID: configID, + UserID: member.ID, + }) + if errors.Is(err, sql.ErrNoRows) { + return false + } + require.NoError(t, err) + return true + } + + t.Run("URLChangeDeletesGrants", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + config := newConfig(ctx, t, "grant-url-change") + seedToken(ctx, t, config.ID) + + newURL := "https://mcp.example.com/grant-url-change-moved" + _, err := adminClient.UpdateMCPServerConfig(ctx, config.ID, codersdk.UpdateMCPServerConfigRequest{ + URL: &newURL, + }) + require.NoError(t, err) + require.False(t, tokenExists(ctx, t, config.ID)) + }) + + t.Run("AuthTypeChangeDeletesGrants", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + config := newConfig(ctx, t, "grant-auth-change") + seedToken(ctx, t, config.ID) + + authType := "none" + _, err := adminClient.UpdateMCPServerConfig(ctx, config.ID, codersdk.UpdateMCPServerConfigRequest{ + AuthType: &authType, + }) + require.NoError(t, err) + require.False(t, tokenExists(ctx, t, config.ID)) + }) + + t.Run("UnrelatedChangeKeepsGrants", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + config := newConfig(ctx, t, "grant-unrelated") + seedToken(ctx, t, config.ID) + + displayName := "Grant Invalidation renamed" + _, err := adminClient.UpdateMCPServerConfig(ctx, config.ID, codersdk.UpdateMCPServerConfigRequest{ + DisplayName: &displayName, + }) + require.NoError(t, err) + require.True(t, tokenExists(ctx, t, config.ID)) + }) +} + func TestMCPServerConfigsUserOIDCRequiresDeploymentPerms(t *testing.T) { t.Parallel() From 3ba9242ccd8d562fc7262a436e4e309dae57d1ca Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:51:44 +0000 Subject: [PATCH 27/59] chore: apply cleanup gate findings to the MCP org scoping layer Tighten or drop comments that narrated history or restated tests, remove an always-true test helper parameter, and drop an assertion subsumed by the ordered equality check. --- ..._mcp_server_configs_organization_id.up.sql | 12 +++++----- coderd/mcp.go | 19 +++++----------- coderd/mcp_test.go | 5 +---- .../generation_preparer_internal_test.go | 22 ++++++++----------- .../components/AgentCreateForm.stories.tsx | 3 +-- 5 files changed, 21 insertions(+), 40 deletions(-) diff --git a/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql index 468c824ce939e..f62e588587e00 100644 --- a/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql +++ b/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql @@ -14,9 +14,9 @@ BEGIN END IF; END $$; --- Originals move to the default organization with credentials intact: --- it succeeds the deployment scope, so its operator-appointed admins --- take over the inherited secrets. Copies below never get credentials. +-- The deployment-wide originals move to the default organization with +-- credentials intact, where operator-appointed admins inherit their +-- secrets. Copies below never receive credentials. UPDATE mcp_server_configs SET organization_id = (SELECT id FROM organizations WHERE is_default = true LIMIT 1); @@ -80,10 +80,8 @@ SELECT -- organization's admin re-enters them deliberately. CASE WHEN config.auth_type IN ('oauth2', 'api_key', 'custom_headers') - -- dbcrypt stores every nonblank value as ciphertext, so this - -- plaintext check only applies to unencrypted rows. Encrypted - -- rows fall back to the auth_type decision above; header - -- values are only sent for auth_type custom_headers anyway. + -- dbcrypt encrypts every nonblank value, including '{}'. Only treat + -- unencrypted, nonempty header maps as credentials here. OR (config.custom_headers_key_id IS NULL AND config.custom_headers NOT IN ('', '{}')) THEN false diff --git a/coderd/mcp.go b/coderd/mcp.go index 53b4efc1a2a44..adc33e48df43a 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -566,18 +566,11 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, sdkConfig) } -// errUserOIDCRequiresDeploymentPerms marks an update refused by the -// user_oidc gate inside the transaction, after the row is current, -// so the handler can map it to a 403. var errUserOIDCRequiresDeploymentPerms = xerrors.New("managing user_oidc MCP server configs requires deployment-level permissions") -// authorizeUserOIDCMCPServerConfig requires deployment-level -// configuration permission for user_oidc MCP server configs. -// user_oidc forwards each chat owner's upstream OIDC access token to -// the configured URL with no per-user consent step, so organization -// admins must not be able to create such configs or repoint their -// URLs. Before organization scoping this was the boundary for every -// config mutation. +// authorizeUserOIDCMCPServerConfig requires deployment-level permission because +// user_oidc sends each chat owner's upstream OIDC access token to the configured +// URL without a per-user consent step. func (api *API) authorizeUserOIDCMCPServerConfig(rw http.ResponseWriter, r *http.Request) bool { if api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { return true @@ -848,10 +841,8 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { } } - // Stored user grants are bound to the destination and auth - // flow they were consented for. Invalidate them when either - // changes so an updated config cannot replay members' tokens - // against a different endpoint. + // User grants are bound to the destination and auth flow authorized by the user. + // Invalidate them when either changes to prevent reuse against another endpoint. if serverURL != existing.Url || authType != existing.AuthType { if err := tx.DeleteMCPServerUserTokensByConfigID(ctx, existing.ID); err != nil { return xerrors.Errorf("invalidate MCP server user tokens: %w", err) diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 80265fa986826..d3a3aa9248f29 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -650,7 +650,6 @@ func TestMCPServerConfigsUserOIDCRequiresDeploymentPerms(t *testing.T) { } } - // Org admins keep full control of non-user_oidc configs. orgAdminOwned, err := orgAdminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, newRequest("org-admin-none", "none")) require.NoError(t, err) @@ -666,12 +665,10 @@ func TestMCPServerConfigsUserOIDCRequiresDeploymentPerms(t *testing.T) { require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) - // Deployment admins retain the pre-org-scoping boundary. deploymentOwned, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, newRequest("deployment-oidc", "user_oidc")) require.NoError(t, err) - // Org admins cannot repoint an existing user_oidc config either; - // the URL controls where member tokens are sent. + // The URL determines where chat owners' OIDC tokens are sent. newURL := "https://attacker.example.com/exfil" _, err = orgAdminClient.UpdateMCPServerConfig(ctx, deploymentOwned.ID, codersdk.UpdateMCPServerConfigRequest{ URL: &newURL, diff --git a/coderd/x/chatd/generation_preparer_internal_test.go b/coderd/x/chatd/generation_preparer_internal_test.go index 0813bff6cc629..6698949d4b86b 100644 --- a/coderd/x/chatd/generation_preparer_internal_test.go +++ b/coderd/x/chatd/generation_preparer_internal_test.go @@ -626,12 +626,12 @@ func TestShouldCompactPromptUsage(t *testing.T) { func TestEnabledMCPServerConfigsForChatOrg(t *testing.T) { t.Parallel() - newOrgWithConfig := func(t *testing.T, db database.Store, enabled bool) (database.Organization, database.MCPServerConfig) { + newOrgWithConfig := func(t *testing.T, db database.Store) (database.Organization, database.MCPServerConfig) { t.Helper() org := dbgen.Organization(t, db, database.Organization{}) cfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ OrganizationID: org.ID, - Enabled: enabled, + Enabled: true, }) return org, cfg } @@ -644,9 +644,8 @@ func TestEnabledMCPServerConfigsForChatOrg(t *testing.T) { defaultOrg, err := db.GetDefaultOrganization(ctx) require.NoError(t, err) - // Configs resolve strictly against the chat's organization; the - // default organization gets no special treatment. - chatOrg, chatOrgCfg := newOrgWithConfig(t, db, true) + // Configs resolve only within the chat's organization. + chatOrg, chatOrgCfg := newOrgWithConfig(t, db) defaultOrgCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ OrganizationID: defaultOrg.ID, Enabled: true, @@ -663,8 +662,8 @@ func TestEnabledMCPServerConfigsForChatOrg(t *testing.T) { db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitShort) - chatOrg, chatOrgCfg := newOrgWithConfig(t, db, true) - _, foreignCfg := newOrgWithConfig(t, db, true) + chatOrg, chatOrgCfg := newOrgWithConfig(t, db) + _, foreignCfg := newOrgWithConfig(t, db) configs, err := enabledMCPServerConfigsForChatOrg(ctx, db, chatOrg.ID, []uuid.UUID{chatOrgCfg.ID, foreignCfg.ID}) require.NoError(t, err) @@ -707,9 +706,9 @@ func TestEnabledMCPServerConfigsForChatOrg(t *testing.T) { db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitShort) - // The array has no uniqueness constraint. Preserve the legacy SQL shape: - // one row per unique ID, ordered by display_name. - chatOrg, cfgA := newOrgWithConfig(t, db, true) + // The ID array may contain duplicates, but the query returns one row + // per unique ID ordered by display_name. + chatOrg, cfgA := newOrgWithConfig(t, db) cfgB := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ OrganizationID: chatOrg.ID, Enabled: true, @@ -723,7 +722,6 @@ func TestEnabledMCPServerConfigsForChatOrg(t *testing.T) { require.NoError(t, err) require.Len(t, configs, 2) gotIDs := []uuid.UUID{configs[0].ID, configs[1].ID} - require.ElementsMatch(t, []uuid.UUID{cfgA.ID, cfgB.ID}, gotIDs) wantOrder := []uuid.UUID{cfgA.ID, cfgB.ID} if cfgA.DisplayName > cfgB.DisplayName { wantOrder = []uuid.UUID{cfgB.ID, cfgA.ID} @@ -739,8 +737,6 @@ func TestEnabledMCPServerConfigsForChatOrg(t *testing.T) { defaultOrg, err := db.GetDefaultOrganization(ctx) require.NoError(t, err) - // A chat whose organization has no configs resolves nothing, - // even when the requested ID exists in the default organization. chatOrg := dbgen.Organization(t, db, database.Organization{}) defaultOrgCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ OrganizationID: defaultOrg.ID, diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 0ff501e006633..17e5591eaae88 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -895,8 +895,7 @@ export const ForbiddenErrorWithRole: Story = { ).not.toBeInTheDocument(); // The generic ErrorAlert should surface the real backend message. await expect(canvas.getByText("Forbidden.")).toBeInTheDocument(); - // The textbox should remain enabled since the user has the - // role. Enablement waits for the MCP server list to resolve. + // The textbox should remain enabled since the user has the role. const textbox = canvas.getByRole("textbox"); await waitFor(() => expect(textbox).not.toHaveAttribute("aria-disabled", "true"), From 7f7e70628840720a1303b34ad9a429ae3207e752 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:24:48 +0000 Subject: [PATCH 28/59] fix(coderd): keep chats sendable after a selected MCP server is disabled --- coderd/exp_chats.go | 19 +++++++++-- coderd/exp_chats_test.go | 74 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 86a660b5b4d23..9bca7310b5326 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -2782,8 +2782,23 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { return } req.MCPServerIDs = &normalizedMCPServerIDs - if len(invalidMCPServerIDs) > 0 { - httpapi.Write(ctx, rw, http.StatusBadRequest, invalidChatMCPServerIDsResponse(invalidMCPServerIDs)) + // IDs already persisted on the chat are exempt: a server that + // is disabled or revoked after selection must not block sends. + // The generation path skips servers the chat can no longer use, + // and keeping the ID preserves the selection if the server is + // re-enabled. + persisted := make(map[uuid.UUID]struct{}, len(chat.MCPServerIDs)) + for _, id := range chat.MCPServerIDs { + persisted[id] = struct{}{} + } + newlyInvalid := make([]uuid.UUID, 0, len(invalidMCPServerIDs)) + for _, id := range invalidMCPServerIDs { + if _, ok := persisted[id]; !ok { + newlyInvalid = append(newlyInvalid, id) + } + } + if len(newlyInvalid) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, invalidChatMCPServerIDsResponse(newlyInvalid)) return } } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 55b7fdbb32d55..1b07984fe65e3 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -706,6 +706,80 @@ func TestPostChats(t *testing.T) { require.Equal(t, "Invalid IDs: "+disabledCfg.ID.String(), sdkErr.Detail) }) + t.Run("MCPServerIDsPersistedDisabledStillSendable", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + cfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: firstUser.OrganizationID, + Enabled: true, + }) + + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "chat with a server that is disabled later", + }, + }, + MCPServerIDs: []uuid.UUID{cfg.ID}, + }) + require.NoError(t, err) + + _, err = client.Client.UpdateMCPServerConfig(ctx, cfg.ID, codersdk.UpdateMCPServerConfigRequest{ + Enabled: ptr.Ref(false), + }) + require.NoError(t, err) + + // The frontend resubmits the persisted selection on every + // send, so a server disabled after selection must not block + // the send. + _, err = memberClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "still sendable after the server was disabled", + }, + }, + MCPServerIDs: &[]uuid.UUID{cfg.ID}, + }) + require.NoError(t, err) + + storedChat, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + require.Equal(t, []uuid.UUID{cfg.ID}, storedChat.MCPServerIDs) + + secondCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: firstUser.OrganizationID, + Enabled: true, + }) + _, err = client.Client.UpdateMCPServerConfig(ctx, secondCfg.ID, codersdk.UpdateMCPServerConfigRequest{ + Enabled: ptr.Ref(false), + }) + require.NoError(t, err) + + _, err = memberClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "adding another disabled server is rejected", + }, + }, + MCPServerIDs: &[]uuid.UUID{cfg.ID, secondCfg.ID}, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "One or more MCP server IDs are invalid or disabled.", sdkErr.Message) + require.Equal(t, "Invalid IDs: "+secondCfg.ID.String(), sdkErr.Detail) + }) + t.Run("MCPServerIDsThirdOrgRejected", func(t *testing.T) { t.Parallel() From 924b2c4cd8802dfdb265c1f14fe5638537082def Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 10 Aug 2026 05:34:40 +0000 Subject: [PATCH 29/59] fix(coderd): merge MCP config updates onto the current row updateMCPServerConfig merged request fields onto the param middleware's snapshot, taken before the request body was parsed, so an overlapping update could have its changes to omitted fields silently reverted. Re-fetch the row on the transaction handle before constructing the merged update, restoring the pre-existing behavior. --- coderd/mcp.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/coderd/mcp.go b/coderd/mcp.go index adc33e48df43a..890a5e01c6595 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -648,6 +648,14 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { var updated database.MCPServerConfig err := api.Database.InTx(func(tx database.Store) error { + // Re-fetch on the transaction handle so omitted fields come from the latest + // row visible to this transaction, not the middleware snapshot. + current, err := tx.GetMCPServerConfigByID(ctx, existing.ID) + if err != nil { + return err + } + existing = current + touchesUserOIDC := existing.AuthType == "user_oidc" || (req.AuthType != nil && *req.AuthType == "user_oidc") if touchesUserOIDC && !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { From 7301f3a5bab5c42281025a54474cf9d7621d0150 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 10 Aug 2026 05:51:54 +0000 Subject: [PATCH 30/59] fix(site/src/pages/AgentsPage): keep cached MCP selections usable after refetch errors A failed background refetch flips the MCP query's isSuccess off while TanStack Query v5 retains the cached data, so gating the composer on isSuccess blocked sends despite a usable organization MCP list. Gate on missing data instead; the error alert still surfaces refetch failures. --- .../components/AgentCreateForm.stories.tsx | 35 ++++++++++++++++++- .../AgentsPage/components/AgentCreateForm.tsx | 8 ++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 17e5591eaae88..a91d85ab229ea 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1,7 +1,7 @@ import type { Decorator, Meta, StoryObj } from "@storybook/react-vite"; import { delay } from "msw"; import { useState } from "react"; -import { QueryClient, QueryClientProvider } from "react-query"; +import { QueryClient, QueryClientProvider, useQueryClient } from "react-query"; import { expect, fn, @@ -29,6 +29,8 @@ import { } from "../utils/reasoningEffort"; import { AgentCreateForm, emptyInputStorageKey } from "./AgentCreateForm"; +let capturedQueryClient: QueryClient | undefined; + const permittedOrgsKey = permittedOrganizationsKey({ object: { resource_type: "chat", owner_id: "me" }, action: "create", @@ -1672,3 +1674,34 @@ export const MCPServersErrorShowsAlertAndDisablesSend: Story = { expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled(); }, }; + +export const MCPServersRefetchErrorKeepsSendEnabled: Story = { + decorators: [ + (Story) => { + capturedQueryClient = useQueryClient(); + return ; + }, + ], + beforeEach: () => { + spyOn(API.experimental, "getMCPServerConfigs") + .mockResolvedValueOnce([]) + .mockRejectedValue(new Error("failed to refresh MCP servers")); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByTestId("chat-message-input"); + await userEvent.click(input); + await userEvent.keyboard("send after a failed refetch"); + const send = canvas.getByRole("button", { name: "Send" }); + await waitFor(() => expect(send).toBeEnabled()); + if (!capturedQueryClient) { + throw new Error("query client was not captured by the story decorator"); + } + await capturedQueryClient.refetchQueries(); + const matches = await canvas.findAllByText( + /failed to refresh mcp servers/i, + ); + expect(matches.length).toBeGreaterThan(0); + expect(send).toBeEnabled(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 752c04b94df1c..4540633005c36 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -338,11 +338,11 @@ export const AgentCreateForm: FC = ({ enabled: Boolean(organizationId), }); const mcpServers = mcpServersQuery.data ?? []; - // Sending before the organization's MCP list resolves would - // silently drop its default-on server selection, so the composer - // waits for this query. + // Sending before the MCP list resolves would silently drop default-on + // selections. Gate on missing data, not isSuccess: a failed background + // refetch flips isSuccess off while cached data stays usable. const isMCPSelectionUnresolved = - Boolean(organizationId) && !mcpServersQuery.isSuccess; + Boolean(organizationId) && mcpServersQuery.data === undefined; // Adopt a permitted fallback so later refetches cannot switch the form to a // re-permitted default. The permission guard also avoids a render loop. if ( From 4919aaed7dff0cacf2b9580c9058dd55df1b2b4c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 10 Aug 2026 06:08:51 +0000 Subject: [PATCH 31/59] fix(coderd): preserve auditors' full MCP config visibility The auditor roles' MCP config read grant exists so auditors can inspect the resources audit logs reflect, but the full-view gate only checked update permission, leaving auditors with the member view: redacted management fields and 404s for disabled configs. Before org scoping the site auditor's deployment-config read selected the full view. Gate the full view on org audit-log read as well, which site and org auditors hold and ordinary members do not. --- coderd/mcp.go | 19 ++++++++++++------- coderd/mcp_test.go | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/coderd/mcp.go b/coderd/mcp.go index 890a5e01c6595..70c8ab1200caf 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -153,13 +153,16 @@ func (api *API) listMCPServerConfigs(rw http.ResponseWriter, r *http.Request) { apiKey := httpmw.APIKey(r) organization := httpmw.OrganizationParam(r) - // Organization admins can see disabled configs and management fields. + // Full view: disabled configs included, management fields unredacted. + // Auditors get it to inspect audit-logged resources; their MCP config + // read grant cannot select it because members hold the same read. // Other members see enabled configs with management fields redacted. - isAdmin := api.Authorize(r, policy.ActionUpdate, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) + hasFullView := api.Authorize(r, policy.ActionUpdate, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) || + api.Authorize(r, policy.ActionRead, rbac.ResourceAuditLog.InOrg(organization.ID)) var configs []database.MCPServerConfig var err error - if isAdmin { + if hasFullView { configs, err = api.Database.GetMCPServerConfigsByOrganization(ctx, organization.ID) } else { configs, err = api.Database.GetEnabledMCPServerConfigsByOrganization(ctx, organization.ID) @@ -203,7 +206,7 @@ func (api *API) listMCPServerConfigs(rw http.ResponseWriter, r *http.Request) { resp := make([]codersdk.MCPServerConfig, 0, len(configs)) for _, config := range configs { var sdkConfig codersdk.MCPServerConfig - if isAdmin { + if hasFullView { sdkConfig = convertMCPServerConfig(config) } else { sdkConfig = convertMCPServerConfigRedacted(config) @@ -531,14 +534,16 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) { apiKey := httpmw.APIKey(r) config := httpmw.MCPServerConfigParam(r) - isAdmin := api.Authorize(r, policy.ActionUpdate, config) - if !isAdmin && !config.Enabled { + // Same full-view rule as listMCPServerConfigs: admins and auditors. + hasFullView := api.Authorize(r, policy.ActionUpdate, config) || + api.Authorize(r, policy.ActionRead, rbac.ResourceAuditLog.InOrg(config.OrganizationID)) + if !hasFullView && !config.Enabled { httpapi.ResourceNotFound(rw) return } var sdkConfig codersdk.MCPServerConfig - if isAdmin { + if hasFullView { sdkConfig = convertMCPServerConfig(config) } else { sdkConfig = convertMCPServerConfigRedacted(config) diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index d3a3aa9248f29..4e883b705f4c3 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -220,6 +220,26 @@ func TestMCPServerConfigsNonAdmin(t *testing.T) { require.NoError(t, err) require.Len(t, memberConfigs, 1) require.Equal(t, "enabled-server", memberConfigs[0].Slug) + + // Auditors need the full management view of the MCP configs their + // audit logs reference. + for name, roles := range map[string][]rbac.RoleIdentifier{ + "SiteAuditor": {rbac.RoleAuditor()}, + "OrgAuditor": {rbac.ScopedRoleOrgAuditor(firstUser.OrganizationID)}, + } { + auditorClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID, roles...) + auditorConfigs, err := auditorClient.MCPServerConfigs(ctx, firstUser.OrganizationID) + require.NoError(t, err, name) + require.Len(t, auditorConfigs, 2, name) + for _, config := range auditorConfigs { + require.NotEmpty(t, config.URL, "%s: %s", name, config.Slug) + if !config.Enabled { + fetched, err := auditorClient.MCPServerConfigByID(ctx, config.ID) + require.NoError(t, err, name) + require.NotEmpty(t, fetched.URL, name) + } + } + } } // TestMCPServerConfigsSecretsNeverLeaked is a load-bearing test that From b8a1268140e5106e81d4712c68cec7064fe12388 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:52:31 +0000 Subject: [PATCH 32/59] fix(site): use semantic textbox queries in MCP loading stories --- .../pages/AgentsPage/components/AgentCreateForm.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index a91d85ab229ea..254111d06bf6a 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1654,7 +1654,7 @@ export const MCPServersLoadingDisablesSend: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const input = canvas.getByTestId("chat-message-input"); + const input = canvas.getByRole("textbox"); await userEvent.click(input); await userEvent.keyboard("send while MCP servers load"); expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled(); @@ -1689,7 +1689,7 @@ export const MCPServersRefetchErrorKeepsSendEnabled: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const input = canvas.getByTestId("chat-message-input"); + const input = canvas.getByRole("textbox"); await userEvent.click(input); await userEvent.keyboard("send after a failed refetch"); const send = canvas.getByRole("button", { name: "Send" }); From 277f365fc2409f919ba4efc933d44d6038810934 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:31:21 +0000 Subject: [PATCH 33/59] fix(coderd): invalidate MCP user grants when OAuth endpoints change Extending the invalidation condition to the OAuth token URL and client ID closes a replay path: an org admin could repoint only oauth2_token_url at an endpoint they control and receive members' refresh tokens on the next refresh. --- coderd/mcp.go | 9 ++++++--- coderd/mcp_test.go | 32 +++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/coderd/mcp.go b/coderd/mcp.go index 70c8ab1200caf..244df6c46278a 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -854,9 +854,12 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { } } - // User grants are bound to the destination and auth flow authorized by the user. - // Invalidate them when either changes to prevent reuse against another endpoint. - if serverURL != existing.Url || authType != existing.AuthType { + // User grants are bound to the destination, auth flow, token endpoint, + // and OAuth client the user authorized. Invalidate them when any of + // these change so stored tokens cannot be replayed against another + // endpoint or client (refresh posts the refresh token to token_url). + if serverURL != existing.Url || authType != existing.AuthType || + oauth2TokenURL != existing.OAuth2TokenURL || oauth2ClientID != existing.OAuth2ClientID { if err := tx.DeleteMCPServerUserTokensByConfigID(ctx, existing.ID); err != nil { return xerrors.Errorf("invalidate MCP server user tokens: %w", err) } diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 4e883b705f4c3..1b82bfea5dfe0 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -632,6 +632,34 @@ func TestMCPServerConfigsUpdateInvalidatesUserGrants(t *testing.T) { require.False(t, tokenExists(ctx, t, config.ID)) }) + t.Run("TokenURLChangeDeletesGrants", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + config := newConfig(ctx, t, "grant-token-url-change") + seedToken(ctx, t, config.ID) + + movedEndpoint := "https://auth.example.com/other-endpoint" + _, err := adminClient.UpdateMCPServerConfig(ctx, config.ID, codersdk.UpdateMCPServerConfigRequest{ + OAuth2TokenURL: &movedEndpoint, + }) + require.NoError(t, err) + require.False(t, tokenExists(ctx, t, config.ID)) + }) + + t.Run("ClientIDChangeDeletesGrants", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + config := newConfig(ctx, t, "grant-client-id-change") + seedToken(ctx, t, config.ID) + + newClientID := "cid-2" + _, err := adminClient.UpdateMCPServerConfig(ctx, config.ID, codersdk.UpdateMCPServerConfigRequest{ + OAuth2ClientID: &newClientID, + }) + require.NoError(t, err) + require.False(t, tokenExists(ctx, t, config.ID)) + }) + t.Run("UnrelatedChangeKeepsGrants", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -639,8 +667,10 @@ func TestMCPServerConfigsUpdateInvalidatesUserGrants(t *testing.T) { seedToken(ctx, t, config.ID) displayName := "Grant Invalidation renamed" + newSecret := "rotated-secret" _, err := adminClient.UpdateMCPServerConfig(ctx, config.ID, codersdk.UpdateMCPServerConfigRequest{ - DisplayName: &displayName, + DisplayName: &displayName, + OAuth2ClientSecret: &newSecret, }) require.NoError(t, err) require.True(t, tokenExists(ctx, t, config.ID)) From 500d08a315d12afb9618bfe23ea4fe28784d4c24 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:59:15 +0000 Subject: [PATCH 34/59] fix(coderd): reject MCP OAuth callbacks against superseded configs The callback stored its token after an unguarded exchange, so an admin update committing between the exchange and the store could recreate a grant obtained under the old destination or OAuth identity. Store the token in a transaction that locks the config row and rejects with 409 when the URL, auth type, token endpoint, or client ID changed. --- coderd/database/dbauthz/dbauthz.go | 4 ++ coderd/database/dbauthz/dbauthz_test.go | 5 ++ coderd/database/dbmetrics/querymetrics.go | 8 +++ coderd/database/dbmock/dbmock.go | 15 ++++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 50 +++++++++++++ coderd/database/queries/mcpserverconfigs.sql | 9 +++ coderd/mcp.go | 48 ++++++++++--- coderd/mcp_test.go | 75 ++++++++++++++++++++ 9 files changed, 204 insertions(+), 11 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 4c6ffaea63a0b..899f10f11d610 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -4061,6 +4061,10 @@ func (q *querier) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (dat return fetch(q.log, q.auth, q.db.GetMCPServerConfigByID)(ctx, id) } +func (q *querier) GetMCPServerConfigByIDForUpdate(ctx context.Context, id uuid.UUID) (database.MCPServerConfig, error) { + return fetch(q.log, q.auth, q.db.GetMCPServerConfigByIDForUpdate)(ctx, id) +} + func (q *querier) GetMCPServerConfigByOrganizationAndSlug(ctx context.Context, arg database.GetMCPServerConfigByOrganizationAndSlugParams) (database.MCPServerConfig, error) { return fetch(q.log, q.auth, q.db.GetMCPServerConfigByOrganizationAndSlug)(ctx, arg) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index d6f8619836e43..8ceaf42f5792c 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1671,6 +1671,11 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetMCPServerConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes() check.Args(config.ID).Asserts(config, policy.ActionRead).Returns(config) })) + s.Run("GetMCPServerConfigByIDForUpdate", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + config := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) + dbm.EXPECT().GetMCPServerConfigByIDForUpdate(gomock.Any(), config.ID).Return(config, nil).AnyTimes() + check.Args(config.ID).Asserts(config, policy.ActionRead).Returns(config) + })) s.Run("GetMCPServerConfigByOrganizationAndSlug", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { arg := database.GetMCPServerConfigByOrganizationAndSlugParams{ OrganizationID: uuid.New(), diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index cb59792564d1a..f248aade43b2b 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2329,6 +2329,14 @@ func (m queryMetricsStore) GetMCPServerConfigByID(ctx context.Context, id uuid.U return r0, r1 } +func (m queryMetricsStore) GetMCPServerConfigByIDForUpdate(ctx context.Context, id uuid.UUID) (database.MCPServerConfig, error) { + start := time.Now() + r0, r1 := m.s.GetMCPServerConfigByIDForUpdate(ctx, id) + m.queryLatencies.WithLabelValues("GetMCPServerConfigByIDForUpdate").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetMCPServerConfigByIDForUpdate").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetMCPServerConfigByOrganizationAndSlug(ctx context.Context, arg database.GetMCPServerConfigByOrganizationAndSlugParams) (database.MCPServerConfig, error) { start := time.Now() r0, r1 := m.s.GetMCPServerConfigByOrganizationAndSlug(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 1aff26fe53c73..2cb4cb7593a81 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -4349,6 +4349,21 @@ func (mr *MockStoreMockRecorder) GetMCPServerConfigByID(ctx, id any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigByID", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigByID), ctx, id) } +// GetMCPServerConfigByIDForUpdate mocks base method. +func (m *MockStore) GetMCPServerConfigByIDForUpdate(ctx context.Context, id uuid.UUID) (database.MCPServerConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMCPServerConfigByIDForUpdate", ctx, id) + ret0, _ := ret[0].(database.MCPServerConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetMCPServerConfigByIDForUpdate indicates an expected call of GetMCPServerConfigByIDForUpdate. +func (mr *MockStoreMockRecorder) GetMCPServerConfigByIDForUpdate(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigByIDForUpdate", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigByIDForUpdate), ctx, id) +} + // GetMCPServerConfigByOrganizationAndSlug mocks base method. func (m *MockStore) GetMCPServerConfigByOrganizationAndSlug(ctx context.Context, arg database.GetMCPServerConfigByOrganizationAndSlugParams) (database.MCPServerConfig, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 8b5cd67f84eda..c7d1f74645e78 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -646,6 +646,7 @@ type sqlcQuerier interface { GetLicenses(ctx context.Context) ([]License, error) GetLogoURL(ctx context.Context) (string, error) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (MCPServerConfig, error) + GetMCPServerConfigByIDForUpdate(ctx context.Context, id uuid.UUID) (MCPServerConfig, error) GetMCPServerConfigByOrganizationAndSlug(ctx context.Context, arg GetMCPServerConfigByOrganizationAndSlugParams) (MCPServerConfig, error) GetMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]MCPServerConfig, error) GetMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg GetMCPServerConfigsByOrganizationAndIDsParams) ([]MCPServerConfig, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4c61e404ddfbc..b07254b102ccf 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -17331,6 +17331,56 @@ func (q *sqlQuerier) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) ( return i, err } +const getMCPServerConfigByIDForUpdate = `-- name: GetMCPServerConfigByIDForUpdate :one +SELECT + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id +FROM + mcp_server_configs +WHERE + id = $1::uuid +FOR UPDATE +` + +func (q *sqlQuerier) GetMCPServerConfigByIDForUpdate(ctx context.Context, id uuid.UUID) (MCPServerConfig, error) { + row := q.db.QueryRowContext(ctx, getMCPServerConfigByIDForUpdate, id) + var i MCPServerConfig + err := row.Scan( + &i.ID, + &i.DisplayName, + &i.Slug, + &i.Description, + &i.IconURL, + &i.Transport, + &i.Url, + &i.AuthType, + &i.OAuth2ClientID, + &i.OAuth2ClientSecret, + &i.OAuth2ClientSecretKeyID, + &i.OAuth2AuthURL, + &i.OAuth2TokenURL, + &i.OAuth2Scopes, + &i.APIKeyHeader, + &i.APIKeyValue, + &i.APIKeyValueKeyID, + &i.CustomHeaders, + &i.CustomHeadersKeyID, + pq.Array(&i.ToolAllowList), + pq.Array(&i.ToolDenyList), + &i.Availability, + &i.Enabled, + &i.CreatedBy, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.ModelIntent, + &i.AllowInPlanMode, + &i.ForwardCoderHeaders, + &i.OAuth2RevocationURL, + &i.OrganizationID, + ) + return i, err +} + const getMCPServerConfigByOrganizationAndSlug = `-- name: GetMCPServerConfigByOrganizationAndSlug :one SELECT id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id diff --git a/coderd/database/queries/mcpserverconfigs.sql b/coderd/database/queries/mcpserverconfigs.sql index 8afee31b8b487..47fde2e3c2b04 100644 --- a/coderd/database/queries/mcpserverconfigs.sql +++ b/coderd/database/queries/mcpserverconfigs.sql @@ -6,6 +6,15 @@ FROM WHERE id = @id::uuid; +-- name: GetMCPServerConfigByIDForUpdate :one +SELECT + * +FROM + mcp_server_configs +WHERE + id = @id::uuid +FOR UPDATE; + -- name: GetMCPServerConfigByOrganizationAndSlug :one SELECT * diff --git a/coderd/mcp.go b/coderd/mcp.go index 244df6c46278a..35042e830cc13 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -573,6 +573,10 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) { var errUserOIDCRequiresDeploymentPerms = xerrors.New("managing user_oidc MCP server configs requires deployment-level permissions") +// errMCPConfigSupersededDuringAuth rejects OAuth callbacks whose config +// changed destination or OAuth identity while the exchange was in flight. +var errMCPConfigSupersededDuringAuth = xerrors.New("MCP server config superseded during authorization") + // authorizeUserOIDCMCPServerConfig requires deployment-level permission because // user_oidc sends each chat owner's upstream OIDC access token to the configured // URL without a per-user consent step. @@ -1174,17 +1178,39 @@ func (api *API) mcpServerOAuth2Callback(rw http.ResponseWriter, r *http.Request) expiry = sql.NullTime{Time: token.Expiry, Valid: true} } - //nolint:gocritic // Users store their own tokens. - _, err = api.Database.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{ - MCPServerConfigID: config.ID, - UserID: apiKey.UserID, - AccessToken: token.AccessToken, - AccessTokenKeyID: sql.NullString{}, - RefreshToken: refreshToken, - RefreshTokenKeyID: sql.NullString{}, - TokenType: token.TokenType, - Expiry: expiry, - }) + err = api.Database.InTx(func(tx database.Store) error { + // Lock and re-read the config so a concurrent update cannot commit + // its grant invalidation between the token exchange and this store, + // which would recreate a grant obtained under the old destination + // or OAuth identity. + current, err := tx.GetMCPServerConfigByIDForUpdate(ctx, config.ID) + if err != nil { + return xerrors.Errorf("re-read MCP server config: %w", err) + } + if current.Url != config.Url || current.AuthType != config.AuthType || + current.OAuth2TokenURL != config.OAuth2TokenURL || current.OAuth2ClientID != config.OAuth2ClientID { + return errMCPConfigSupersededDuringAuth + } + //nolint:gocritic // Users store their own tokens. + _, err = tx.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{ + MCPServerConfigID: config.ID, + UserID: apiKey.UserID, + AccessToken: token.AccessToken, + AccessTokenKeyID: sql.NullString{}, + RefreshToken: refreshToken, + RefreshTokenKeyID: sql.NullString{}, + TokenType: token.TokenType, + Expiry: expiry, + }) + return err + }, nil) + if errors.Is(err, errMCPConfigSupersededDuringAuth) { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "MCP server configuration changed during authorization.", + Detail: "The server's destination or OAuth settings were updated while the connection was in progress. Reconnect to authorize against the current configuration.", + }) + return + } if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to store OAuth2 token.", diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 1b82bfea5dfe0..47b56044f6ead 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -677,6 +677,81 @@ func TestMCPServerConfigsUpdateInvalidatesUserGrants(t *testing.T) { }) } +func TestMCPServerConfigsOAuth2CallbackRejectsSupersededConfig(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t) + adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), + ChatProviderAPIKeys: &providerKeys, + }) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + // The token endpoint repoints the config's URL before returning the + // token, simulating an admin update committing while the exchange is + // in flight. The callback must reject the grant it obtained under the + // superseded configuration. + var configID atomic.Pointer[uuid.UUID] + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if id := configID.Load(); id != nil { + movedURL := "https://attacker.example.com/superseded" + _, err := adminClient.UpdateMCPServerConfig(ctx, *id, codersdk.UpdateMCPServerConfigRequest{ + URL: &movedURL, + }) + assert.NoError(t, err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"superseded-access-token","token_type":"Bearer","expires_in":3600}`)) + })) + t.Cleanup(tokenServer.Close) + + created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "Superseded Callback", + Slug: "superseded-callback", + Transport: "streamable_http", + URL: "https://mcp.example.com/superseded-callback", + AuthType: "oauth2", + OAuth2ClientID: "cid", + OAuth2AuthURL: "https://auth.example.com/authorize", + OAuth2TokenURL: tokenServer.URL + "/token", + Availability: "default_on", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, + }) + require.NoError(t, err) + configID.Store(&created.ID) + + state := "superseded-state" + callbackURL, err := memberClient.URL.Parse( + "/api/experimental/mcp/servers/" + created.ID.String() + "/oauth2/callback", + ) + require.NoError(t, err) + q := callbackURL.Query() + q.Set("code", "superseded-auth-code") + q.Set("state", state) + callbackURL.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, callbackURL.String(), nil) + require.NoError(t, err) + req.AddCookie(&http.Cookie{Name: codersdk.SessionTokenCookie, Value: memberClient.SessionToken()}) + req.AddCookie(&http.Cookie{Name: "mcp_oauth2_state_" + created.ID.String(), Value: state}) + res, err := memberClient.HTTPClient.Do(req) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusConflict, res.StatusCode) + + //nolint:gocritic // Verifying persisted state requires system access. + _, err = db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{ + MCPServerConfigID: created.ID, + UserID: member.ID, + }) + require.ErrorIs(t, err, sql.ErrNoRows, + "no grant may be stored for a callback completed against a superseded config") +} + func TestMCPServerConfigsUserOIDCRequiresDeploymentPerms(t *testing.T) { t.Parallel() From 7809d6cfecd8f70aea4252d73a57152c1827aaca Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:33:21 +0000 Subject: [PATCH 35/59] fix(coderd): lock MCP config row before invalidating OAuth grants --- coderd/mcp.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/coderd/mcp.go b/coderd/mcp.go index 35042e830cc13..da98e4f298840 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -657,9 +657,10 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { var updated database.MCPServerConfig err := api.Database.InTx(func(tx database.Store) error { - // Re-fetch on the transaction handle so omitted fields come from the latest - // row visible to this transaction, not the middleware snapshot. - current, err := tx.GetMCPServerConfigByID(ctx, existing.ID) + // Lock and re-fetch the row so omitted fields come from the latest + // version and grant invalidation serializes with in-flight OAuth + // callbacks verifying the same config. + current, err := tx.GetMCPServerConfigByIDForUpdate(ctx, existing.ID) if err != nil { return err } From 985a7251ffdbb52dbf9b8abcd311106b62429c16 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:01:17 +0000 Subject: [PATCH 36/59] chore(coderd): apply cleanup gate round to callback race fix --- coderd/mcp.go | 9 +++------ coderd/mcp_test.go | 4 ---- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/coderd/mcp.go b/coderd/mcp.go index da98e4f298840..e4b30e7d90128 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -573,8 +573,6 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) { var errUserOIDCRequiresDeploymentPerms = xerrors.New("managing user_oidc MCP server configs requires deployment-level permissions") -// errMCPConfigSupersededDuringAuth rejects OAuth callbacks whose config -// changed destination or OAuth identity while the exchange was in flight. var errMCPConfigSupersededDuringAuth = xerrors.New("MCP server config superseded during authorization") // authorizeUserOIDCMCPServerConfig requires deployment-level permission because @@ -1180,10 +1178,9 @@ func (api *API) mcpServerOAuth2Callback(rw http.ResponseWriter, r *http.Request) } err = api.Database.InTx(func(tx database.Store) error { - // Lock and re-read the config so a concurrent update cannot commit - // its grant invalidation between the token exchange and this store, - // which would recreate a grant obtained under the old destination - // or OAuth identity. + // Hold the config lock through the grant write so a concurrent update + // cannot invalidate grants and then have this callback recreate one + // for the old config. current, err := tx.GetMCPServerConfigByIDForUpdate(ctx, config.ID) if err != nil { return xerrors.Errorf("re-read MCP server config: %w", err) diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 47b56044f6ead..78d8c5fef78a7 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -689,10 +689,6 @@ func TestMCPServerConfigsOAuth2CallbackRejectsSupersededConfig(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, adminClient) memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) - // The token endpoint repoints the config's URL before returning the - // token, simulating an admin update committing while the exchange is - // in flight. The callback must reject the grant it obtained under the - // superseded configuration. var configID atomic.Pointer[uuid.UUID] tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { if id := configID.Load(); id != nil { From d6b21eff32fab08c23eb02f9a99261f5afbb55ca Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:24:56 +0000 Subject: [PATCH 37/59] fix(enterprise/dbcrypt): decrypt MCP configs from the locking accessor --- enterprise/dbcrypt/dbcrypt.go | 11 +++++++++++ enterprise/dbcrypt/dbcrypt_internal_test.go | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index a9a70c9ea65c3..d2f81d3995357 100644 --- a/enterprise/dbcrypt/dbcrypt.go +++ b/enterprise/dbcrypt/dbcrypt.go @@ -714,6 +714,17 @@ func (db *dbCrypt) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (da return cfg, nil } +func (db *dbCrypt) GetMCPServerConfigByIDForUpdate(ctx context.Context, id uuid.UUID) (database.MCPServerConfig, error) { + cfg, err := db.Store.GetMCPServerConfigByIDForUpdate(ctx, id) + if err != nil { + return database.MCPServerConfig{}, err + } + if err := db.decryptMCPServerConfig(&cfg); err != nil { + return database.MCPServerConfig{}, err + } + return cfg, nil +} + func (db *dbCrypt) GetMCPServerConfigByOrganizationAndSlug(ctx context.Context, arg database.GetMCPServerConfigByOrganizationAndSlugParams) (database.MCPServerConfig, error) { cfg, err := db.Store.GetMCPServerConfigByOrganizationAndSlug(ctx, arg) if err != nil { diff --git a/enterprise/dbcrypt/dbcrypt_internal_test.go b/enterprise/dbcrypt/dbcrypt_internal_test.go index 4cdd73a955eb0..a696376b9fe65 100644 --- a/enterprise/dbcrypt/dbcrypt_internal_test.go +++ b/enterprise/dbcrypt/dbcrypt_internal_test.go @@ -973,6 +973,17 @@ func TestMCPServerConfigs(t *testing.T) { requireMCPServerConfigRawEncrypted(ctx, t, db, cfg.ID, ciphers, oauthSecret, apiKeyValue, customHeaders) }) + t.Run("GetMCPServerConfigByIDForUpdate", func(t *testing.T) { + t.Parallel() + db, crypt, ciphers := setup(t) + cfg := insertConfig(t, crypt, ciphers) + + got, err := crypt.GetMCPServerConfigByIDForUpdate(ctx, cfg.ID) + require.NoError(t, err) + requireMCPServerConfigDecrypted(t, got, ciphers, oauthSecret, apiKeyValue, customHeaders) + requireMCPServerConfigRawEncrypted(ctx, t, db, cfg.ID, ciphers, oauthSecret, apiKeyValue, customHeaders) + }) + t.Run("GetMCPServerConfigByOrganizationAndSlug", func(t *testing.T) { t.Parallel() db, crypt, ciphers := setup(t) From a93b579d2d1ffb71da3daaefdb2c842168b15ceb Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:46:10 +0000 Subject: [PATCH 38/59] fix(coderd/database): keep existing MCP configs in the default organization only The organization scoping migration no longer copies existing MCP server configs into every live organization. The deployment-wide originals move to the default organization and other organizations start with no MCP servers. Chat references outside the default organization are dropped, and the rolling-upgrade trigger now drops cross-organization config IDs instead of remapping them to same-slug copies. --- coderd/database/dump.sql | 60 +++--- ...cp_server_configs_organization_id.down.sql | 43 +--- ..._mcp_server_configs_organization_id.up.sql | 140 ++----------- coderd/database/migrations/migrate_test.go | 197 +++++------------- 4 files changed, 101 insertions(+), 339 deletions(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index fc6da0f45a7cd..c7c9f7dbc0163 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -962,6 +962,27 @@ BEGIN END; $$; +CREATE FUNCTION drop_cross_org_chat_mcp_server_ids() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF NEW.mcp_server_ids IS NULL OR cardinality(NEW.mcp_server_ids) = 0 THEN + RETURN NEW; + END IF; + SELECT COALESCE( + array_agg(item.config_id ORDER BY item.position) FILTER ( + WHERE config.id IS NULL + OR config.organization_id = NEW.organization_id + ), + '{}'::uuid[] + ) + INTO NEW.mcp_server_ids + FROM unnest(NEW.mcp_server_ids) WITH ORDINALITY AS item(config_id, position) + LEFT JOIN mcp_server_configs AS config ON config.id = item.config_id; + RETURN NEW; +END; +$$; + CREATE FUNCTION enforce_user_ai_budget_override_membership() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -1357,37 +1378,6 @@ BEGIN END; $$; -CREATE FUNCTION remap_chat_mcp_server_ids_to_chat_org() RETURNS trigger - LANGUAGE plpgsql - AS $$ -BEGIN - IF NEW.mcp_server_ids IS NULL OR cardinality(NEW.mcp_server_ids) = 0 THEN - RETURN NEW; - END IF; - -- same_org_config.id is non-NULL only for the foreign, remappable case, - -- so COALESCE keeps missing and same-org IDs as written. - SELECT COALESCE( - array_agg( - COALESCE(same_org_config.id, item.config_id) - ORDER BY item.position - ) FILTER ( - WHERE config.id IS NULL - OR config.organization_id = NEW.organization_id - OR same_org_config.id IS NOT NULL - ), - '{}'::uuid[] - ) - INTO NEW.mcp_server_ids - FROM unnest(NEW.mcp_server_ids) WITH ORDINALITY AS item(config_id, position) - LEFT JOIN mcp_server_configs AS config ON config.id = item.config_id - LEFT JOIN mcp_server_configs AS same_org_config - ON config.organization_id != NEW.organization_id - AND same_org_config.organization_id = NEW.organization_id - AND same_org_config.slug = config.slug; - RETURN NEW; -END; -$$; - CREATE FUNCTION remove_mcp_server_config_id_from_chats() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -5105,13 +5095,13 @@ CREATE OR REPLACE VIEW provisioner_job_stats AS LEFT JOIN provisioner_job_timings pjt ON ((pjt.job_id = pj.id))) GROUP BY pj.id, wb.workspace_id; -CREATE TRIGGER inhibit_enqueue_if_disabled BEFORE INSERT ON notification_messages FOR EACH ROW EXECUTE FUNCTION inhibit_enqueue_if_disabled(); +CREATE TRIGGER drop_cross_org_chat_mcp_server_ids BEFORE INSERT OR UPDATE OF mcp_server_ids ON chats FOR EACH ROW EXECUTE FUNCTION drop_cross_org_chat_mcp_server_ids(); -CREATE TRIGGER protect_deleting_organizations BEFORE UPDATE ON organizations FOR EACH ROW WHEN (((new.deleted = true) AND (old.deleted = false))) EXECUTE FUNCTION protect_deleting_organizations(); +COMMENT ON TRIGGER drop_cross_org_chat_mcp_server_ids ON chats IS 'Rolling-upgrade compatibility: drops config IDs written by pre-organization-scoping replicas that resolve to another organization''s config.'; -CREATE TRIGGER remap_chat_mcp_server_ids BEFORE INSERT OR UPDATE OF mcp_server_ids ON chats FOR EACH ROW EXECUTE FUNCTION remap_chat_mcp_server_ids_to_chat_org(); +CREATE TRIGGER inhibit_enqueue_if_disabled BEFORE INSERT ON notification_messages FOR EACH ROW EXECUTE FUNCTION inhibit_enqueue_if_disabled(); -COMMENT ON TRIGGER remap_chat_mcp_server_ids ON chats IS 'Rolling-upgrade compatibility: remaps config IDs written by pre-organization-scoping replicas to the chat organization''s same-slug config.'; +CREATE TRIGGER protect_deleting_organizations BEFORE UPDATE ON organizations FOR EACH ROW WHEN (((new.deleted = true) AND (old.deleted = false))) EXECUTE FUNCTION protect_deleting_organizations(); CREATE TRIGGER remove_chat_mcp_server_config_id BEFORE DELETE ON mcp_server_configs FOR EACH ROW EXECUTE FUNCTION remove_mcp_server_config_id_from_chats(); diff --git a/coderd/database/migrations/000570_mcp_server_configs_organization_id.down.sql b/coderd/database/migrations/000570_mcp_server_configs_organization_id.down.sql index f86a623dfafee..92dc9b3c87dac 100644 --- a/coderd/database/migrations/000570_mcp_server_configs_organization_id.down.sql +++ b/coderd/database/migrations/000570_mcp_server_configs_organization_id.down.sql @@ -1,42 +1,9 @@ --- Drop the remap trigger before the chat update below: it would rewrite the --- restored default-organization IDs back to the per-organization copies. -DROP TRIGGER IF EXISTS remap_chat_mcp_server_ids ON chats; -DROP FUNCTION IF EXISTS remap_chat_mcp_server_ids_to_chat_org(); - -CREATE TEMP TABLE mcp_server_config_restore_map ( - config_id UUID PRIMARY KEY, - default_config_id UUID NOT NULL -) ON COMMIT DROP; - -INSERT INTO mcp_server_config_restore_map (config_id, default_config_id) -SELECT config.id, default_config.id -FROM mcp_server_configs AS config -JOIN mcp_server_configs AS default_config - ON default_config.slug = config.slug - AND default_config.organization_id = ( - SELECT id FROM organizations WHERE is_default = true LIMIT 1 - ) -WHERE config.organization_id != default_config.organization_id; - -UPDATE chats AS chat -SET mcp_server_ids = remapped.ids -FROM ( - SELECT - source.id, - COALESCE( - array_agg(COALESCE(mapping.default_config_id, item.config_id) ORDER BY item.position) - FILTER (WHERE item.config_id IS NOT NULL), - '{}'::UUID[] - ) AS ids - FROM chats AS source - LEFT JOIN LATERAL unnest(source.mcp_server_ids) WITH ORDINALITY - AS item(config_id, position) ON true - LEFT JOIN mcp_server_config_restore_map AS mapping - ON mapping.config_id = item.config_id - GROUP BY source.id -) AS remapped -WHERE remapped.id = chat.id; +DROP TRIGGER IF EXISTS drop_cross_org_chat_mcp_server_ids ON chats; +DROP FUNCTION IF EXISTS drop_cross_org_chat_mcp_server_ids(); +-- Configs created outside the default organization cannot move to the +-- deployment-wide table because slugs may collide across organizations. +-- Delete them; the delete trigger from 000510 removes their IDs from chats. DELETE FROM mcp_server_configs WHERE organization_id != ( SELECT id FROM organizations WHERE is_default = true LIMIT 1 diff --git a/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql index f62e588587e00..04900ab351e2f 100644 --- a/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql +++ b/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql @@ -14,156 +14,56 @@ BEGIN END IF; END $$; --- The deployment-wide originals move to the default organization with --- credentials intact, where operator-appointed admins inherit their --- secrets. Copies below never receive credentials. +-- The deployment-wide originals become the default organization's servers, +-- credentials intact. Other organizations start with no MCP servers. UPDATE mcp_server_configs SET organization_id = (SELECT id FROM organizations WHERE is_default = true LIMIT 1); -ALTER TABLE mcp_server_configs - DROP CONSTRAINT mcp_server_configs_slug_key; - -CREATE TEMP TABLE mcp_server_config_org_map ( - old_id UUID NOT NULL, - organization_id UUID NOT NULL, - new_id UUID NOT NULL, - PRIMARY KEY (old_id, organization_id), - UNIQUE (new_id) -) ON COMMIT DROP; - -INSERT INTO mcp_server_config_org_map (old_id, organization_id, new_id) -SELECT config.id, organization.id, gen_random_uuid() -FROM mcp_server_configs AS config -CROSS JOIN organizations AS organization -WHERE organization.deleted = false - AND organization.is_default = false; - -INSERT INTO mcp_server_configs ( - id, organization_id, display_name, slug, description, icon_url, transport, - url, auth_type, oauth2_client_id, oauth2_client_secret, - oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, - oauth2_revocation_url, oauth2_scopes, api_key_header, api_key_value, - api_key_value_key_id, custom_headers, custom_headers_key_id, - tool_allow_list, tool_deny_list, availability, enabled, model_intent, - allow_in_plan_mode, forward_coder_headers, created_by, updated_by, - created_at, updated_at +-- Chats outside the default organization referenced deployment-wide configs +-- that now belong to the default organization and no longer resolve in the +-- chat's organization. +UPDATE chats +SET mcp_server_ids = '{}' +WHERE organization_id != ( + SELECT id FROM organizations WHERE is_default = true LIMIT 1 ) -SELECT - mapping.new_id, - mapping.organization_id, - config.display_name, - config.slug, - config.description, - config.icon_url, - config.transport, - config.url, - config.auth_type, - -- Never copy admin-entered credentials: the copy's admins could repoint - -- its URL and reuse the inherited secret. OAuth identity is cleared for - -- every auth type because the API stores it regardless of auth_type. - '', - '', - NULL, - config.oauth2_auth_url, - config.oauth2_token_url, - config.oauth2_revocation_url, - config.oauth2_scopes, - config.api_key_header, - '', - NULL, - '{}', - NULL, - config.tool_allow_list, - config.tool_deny_list, - config.availability, - -- Copies that lost required credentials start disabled so each - -- organization's admin re-enters them deliberately. - CASE - WHEN config.auth_type IN ('oauth2', 'api_key', 'custom_headers') - -- dbcrypt encrypts every nonblank value, including '{}'. Only treat - -- unencrypted, nonempty header maps as credentials here. - OR (config.custom_headers_key_id IS NULL - AND config.custom_headers NOT IN ('', '{}')) - THEN false - ELSE config.enabled - END, - config.model_intent, - config.allow_in_plan_mode, - config.forward_coder_headers, - config.created_by, - config.updated_by, - config.created_at, - config.updated_at -FROM mcp_server_config_org_map AS mapping -JOIN mcp_server_configs AS config ON config.id = mapping.old_id; - -UPDATE chats AS chat -SET mcp_server_ids = remapped.ids -FROM ( - SELECT - source.id, - COALESCE( - array_agg(COALESCE(mapping.new_id, item.config_id) ORDER BY item.position) - FILTER (WHERE item.config_id IS NOT NULL), - '{}'::UUID[] - ) AS ids - FROM chats AS source - LEFT JOIN LATERAL unnest(source.mcp_server_ids) WITH ORDINALITY - AS item(config_id, position) ON true - LEFT JOIN mcp_server_config_org_map AS mapping - ON mapping.old_id = item.config_id - AND mapping.organization_id = source.organization_id - WHERE source.organization_id != ( - SELECT id FROM organizations WHERE is_default = true LIMIT 1 - ) - GROUP BY source.id -) AS remapped -WHERE remapped.id = chat.id; + AND cardinality(mcp_server_ids) > 0; ALTER TABLE mcp_server_configs ALTER COLUMN organization_id SET NOT NULL, + DROP CONSTRAINT mcp_server_configs_slug_key, ADD CONSTRAINT mcp_server_configs_organization_id_slug_key UNIQUE (organization_id, slug); CREATE INDEX idx_mcp_server_configs_organization_id ON mcp_server_configs (organization_id); -- Pre-scoping replicas resolve configs globally and can write another org's --- config ID into a chat during a rolling upgrade. Remap to the same-slug --- local config (dropping unmappable IDs); remove once those replicas are gone. -CREATE FUNCTION remap_chat_mcp_server_ids_to_chat_org() +-- config ID into a chat during a rolling upgrade. Drop those foreign IDs +-- (keeping unknown IDs as written); remove once those replicas are gone. +CREATE FUNCTION drop_cross_org_chat_mcp_server_ids() RETURNS TRIGGER AS $$ BEGIN IF NEW.mcp_server_ids IS NULL OR cardinality(NEW.mcp_server_ids) = 0 THEN RETURN NEW; END IF; - -- same_org_config.id is non-NULL only for the foreign, remappable case, - -- so COALESCE keeps missing and same-org IDs as written. SELECT COALESCE( - array_agg( - COALESCE(same_org_config.id, item.config_id) - ORDER BY item.position - ) FILTER ( + array_agg(item.config_id ORDER BY item.position) FILTER ( WHERE config.id IS NULL OR config.organization_id = NEW.organization_id - OR same_org_config.id IS NOT NULL ), '{}'::uuid[] ) INTO NEW.mcp_server_ids FROM unnest(NEW.mcp_server_ids) WITH ORDINALITY AS item(config_id, position) - LEFT JOIN mcp_server_configs AS config ON config.id = item.config_id - LEFT JOIN mcp_server_configs AS same_org_config - ON config.organization_id != NEW.organization_id - AND same_org_config.organization_id = NEW.organization_id - AND same_org_config.slug = config.slug; + LEFT JOIN mcp_server_configs AS config ON config.id = item.config_id; RETURN NEW; END; $$ LANGUAGE plpgsql; -CREATE TRIGGER remap_chat_mcp_server_ids +CREATE TRIGGER drop_cross_org_chat_mcp_server_ids BEFORE INSERT OR UPDATE OF mcp_server_ids ON chats FOR EACH ROW - EXECUTE PROCEDURE remap_chat_mcp_server_ids_to_chat_org(); + EXECUTE PROCEDURE drop_cross_org_chat_mcp_server_ids(); -COMMENT ON TRIGGER remap_chat_mcp_server_ids ON chats IS - 'Rolling-upgrade compatibility: remaps config IDs written by pre-organization-scoping replicas to the chat organization''s same-slug config.'; +COMMENT ON TRIGGER drop_cross_org_chat_mcp_server_ids ON chats IS + 'Rolling-upgrade compatibility: drops config IDs written by pre-organization-scoping replicas that resolve to another organization''s config.'; diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index b7f2a54ba609e..9210fa21f51e1 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -3019,18 +3019,14 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { err = sqlDB.QueryRowContext(ctx, `SELECT id FROM organizations WHERE is_default = true`).Scan(&defaultOrgID) require.NoError(t, err) - liveOrgIDs := []uuid.UUID{uuid.New(), uuid.New()} - deletedOrgID := uuid.New() - organizationIDs := []uuid.UUID{liveOrgIDs[0], liveOrgIDs[1], deletedOrgID} - for i, orgID := range organizationIDs { - _, err = sqlDB.ExecContext(ctx, ` - INSERT INTO organizations ( - id, name, display_name, description, icon, created_at, updated_at, - is_default, deleted, default_org_member_roles - ) VALUES ($1, $2, $3, '', '', $4, $4, false, $5, '{}') - `, orgID, fmt.Sprintf("migration-568-org-%d", i), fmt.Sprintf("Migration 568 Org %d", i), now, orgID == deletedOrgID) - require.NoError(t, err) - } + otherOrgID := uuid.New() + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO organizations ( + id, name, display_name, description, icon, created_at, updated_at, + is_default, deleted, default_org_member_roles + ) VALUES ($1, 'migration-568-org', 'Migration 568 Org', '', '', $2, $2, false, false, '{}') + `, otherOrgID, now) + require.NoError(t, err) userID := uuid.New() _, err = sqlDB.ExecContext(ctx, ` @@ -3084,10 +3080,8 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { keyID := sql.NullString{String: keyDigest, Valid: true} configs := []configSeed{ {id: uuid.New(), slug: "migration-568-none", authType: "none", apiKeyHeader: "Authorization", customHeaders: "{}"}, - // Leftover OAuth fields on a non-oauth2 config: the API stores - // them for any auth type, so copies must clear them too. - {id: uuid.New(), slug: "migration-568-api-key", authType: "api_key", apiKeyHeader: "X-API-Key", apiKeyValue: "api-key-ciphertext", apiKeyValueKeyID: keyID, customHeaders: "{}", oauth2ClientID: "leftover-client-id", oauth2ClientSecret: "leftover-secret-ciphertext", oauth2ClientSecretKeyID: keyID, oauth2TokenURL: "https://oauth.example.com/leftover-token"}, - {id: uuid.New(), slug: "migration-568-custom-headers", authType: "custom_headers", apiKeyHeader: "Authorization", customHeaders: "custom-headers-ciphertext", customHeadersKeyID: keyID}, + // Every credential column carries ciphertext to prove the backfill + // leaves rows byte-identical apart from organization_id. { id: uuid.New(), slug: "migration-568-oauth2", @@ -3099,12 +3093,12 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { oauth2TokenURL: "https://oauth.example.com/token", oauth2RevocationURL: "https://oauth.example.com/revoke", oauth2Scopes: "openid profile", - apiKeyHeader: "Authorization", - customHeaders: "{}", + apiKeyHeader: "X-API-Key", + apiKeyValue: "api-key-ciphertext", + apiKeyValueKeyID: keyID, + customHeaders: "custom-headers-ciphertext", + customHeadersKeyID: keyID, }, - // Under dbcrypt an empty header map is stored as ciphertext, so - // the copy decision must not read it as real credentials. - {id: uuid.New(), slug: "migration-568-none-dbcrypt", authType: "none", apiKeyHeader: "Authorization", customHeaders: "empty-map-ciphertext", customHeadersKeyID: keyID}, } originalJSON := make(map[uuid.UUID]string, len(configs)) @@ -3138,7 +3132,7 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { originalJSON[config.id] = rowJSON } - oauthConfigID := configs[3].id + oauthConfigID := configs[1].id _, err = sqlDB.ExecContext(ctx, ` INSERT INTO mcp_server_user_tokens ( id, mcp_server_config_id, user_id, access_token, access_token_key_id, @@ -3153,9 +3147,8 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { configIDs []uuid.UUID } chats := []chatSeed{ - {id: uuid.New(), organizationID: defaultOrgID, configIDs: []uuid.UUID{configs[0].id, configs[3].id, configs[1].id, configs[2].id}}, - {id: uuid.New(), organizationID: liveOrgIDs[0], configIDs: []uuid.UUID{configs[2].id, configs[0].id, configs[3].id, configs[1].id}}, - {id: uuid.New(), organizationID: liveOrgIDs[1], configIDs: []uuid.UUID{configs[1].id, configs[3].id, configs[0].id}}, + {id: uuid.New(), organizationID: defaultOrgID, configIDs: []uuid.UUID{configs[0].id, configs[1].id}}, + {id: uuid.New(), organizationID: otherOrgID, configIDs: []uuid.UUID{configs[1].id, configs[0].id}}, } for i, chat := range chats { _, err = sqlDB.ExecContext(ctx, ` @@ -3171,21 +3164,12 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { require.NoError(t, err) require.EqualValues(t, 570, version) + // The existing rows become the default organization's servers; no copies + // are created for other organizations. var totalConfigs int err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_server_configs`).Scan(&totalConfigs) require.NoError(t, err) - require.Equal(t, len(configs)*(len(liveOrgIDs)+1), totalConfigs) - - for _, orgID := range append([]uuid.UUID{defaultOrgID}, liveOrgIDs...) { - var count int - err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_server_configs WHERE organization_id = $1`, orgID).Scan(&count) - require.NoError(t, err) - require.Equal(t, len(configs), count) - } - var deletedOrgConfigs int - err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_server_configs WHERE organization_id = $1`, deletedOrgID).Scan(&deletedOrgConfigs) - require.NoError(t, err) - require.Zero(t, deletedOrgConfigs) + require.Equal(t, len(configs), totalConfigs) for _, config := range configs { var gotJSON string @@ -3198,64 +3182,6 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { require.NoError(t, err) require.JSONEq(t, originalJSON[config.id], gotJSON) require.Equal(t, defaultOrgID, organizationID) - - var slugCount int - err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_server_configs WHERE slug = $1`, config.slug).Scan(&slugCount) - require.NoError(t, err) - require.Equal(t, len(liveOrgIDs)+1, slugCount) - } - - copiedIDs := make(map[uuid.UUID]map[uuid.UUID]uuid.UUID, len(liveOrgIDs)) - for _, orgID := range liveOrgIDs { - copiedIDs[orgID] = make(map[uuid.UUID]uuid.UUID, len(configs)) - for _, config := range configs { - var copiedID uuid.UUID - var authType, oauth2ClientID, oauth2ClientSecret string - var oauth2ClientSecretKeyID sql.NullString - var oauth2AuthURL, oauth2TokenURL, oauth2RevocationURL, oauth2Scopes string - var apiKeyValue string - var apiKeyValueKeyID sql.NullString - var customHeaders string - var customHeadersKeyID sql.NullString - var enabled bool - err = sqlDB.QueryRowContext(ctx, ` - SELECT - id, auth_type, oauth2_client_id, oauth2_client_secret, - oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, - oauth2_revocation_url, oauth2_scopes, api_key_value, - api_key_value_key_id, custom_headers, custom_headers_key_id, enabled - FROM mcp_server_configs - WHERE organization_id = $1 AND slug = $2 - `, orgID, config.slug).Scan( - &copiedID, &authType, &oauth2ClientID, &oauth2ClientSecret, - &oauth2ClientSecretKeyID, &oauth2AuthURL, &oauth2TokenURL, - &oauth2RevocationURL, &oauth2Scopes, &apiKeyValue, - &apiKeyValueKeyID, &customHeaders, &customHeadersKeyID, &enabled, - ) - require.NoError(t, err) - require.NotEqual(t, config.id, copiedID) - require.Equal(t, config.authType, authType) - copiedIDs[orgID][config.id] = copiedID - - // Each organization's admin must re-enter credentials deliberately. - require.Empty(t, apiKeyValue) - require.False(t, apiKeyValueKeyID.Valid) - require.Equal(t, "{}", customHeaders) - require.False(t, customHeadersKeyID.Valid) - require.Empty(t, oauth2ClientID) - require.Empty(t, oauth2ClientSecret) - require.False(t, oauth2ClientSecretKeyID.Valid) - require.Equal(t, config.oauth2AuthURL, oauth2AuthURL) - require.Equal(t, config.oauth2TokenURL, oauth2TokenURL) - require.Equal(t, config.oauth2RevocationURL, oauth2RevocationURL) - require.Equal(t, config.oauth2Scopes, oauth2Scopes) - switch config.authType { - case "none": - require.True(t, enabled) - default: - require.False(t, enabled) - } - } } var tokenCount int @@ -3281,59 +3207,41 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { require.NoError(t, err) return ids } - remap := func(orgID uuid.UUID, ids []uuid.UUID) []uuid.UUID { - if orgID == defaultOrgID { - return ids - } - remapped := make([]uuid.UUID, len(ids)) - for i, id := range ids { - remapped[i] = copiedIDs[orgID][id] - } - return remapped - } - for _, chat := range chats { - require.Equal(t, remap(chat.organizationID, chat.configIDs), getChatIDs(t, chat.id)) - } + require.Equal(t, chats[0].configIDs, getChatIDs(t, chats[0].id)) + // Chats outside the default organization lose their references because + // the configs now belong to the default organization only. + require.Empty(t, getChatIDs(t, chats[1].id)) remapTriggerExists := func(t *testing.T) bool { t.Helper() var exists bool err := sqlDB.QueryRowContext(ctx, ` - SELECT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'remap_chat_mcp_server_ids') + SELECT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'drop_cross_org_chat_mcp_server_ids') `).Scan(&exists) require.NoError(t, err) return exists } require.True(t, remapTriggerExists(t)) - // Verify the compatibility trigger remaps stale cross-organization - // writes to same-slug configs in the chat's organization. + // The compatibility trigger drops cross-organization config IDs, keeps + // same-organization IDs, and passes unknown IDs through as written. + orgLocalConfigID := uuid.New() + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO mcp_server_configs ( + id, organization_id, display_name, slug, url, auth_type + ) VALUES ($1, $2, 'Org-local config', 'migration-568-org-local', 'https://mcp.example.com/org-local', 'none') + `, orgLocalConfigID, otherOrgID) + require.NoError(t, err) + danglingID := uuid.New() staleWriteChatID := uuid.New() _, err = sqlDB.ExecContext(ctx, ` INSERT INTO chats ( id, owner_id, organization_id, last_model_config_id, title, mcp_server_ids, created_at, updated_at ) VALUES ($1, $2, $3, $4, 'Migration 568 stale write', $5, $6, $6) - `, staleWriteChatID, userID, liveOrgIDs[0], modelConfigID, pq.Array([]uuid.UUID{configs[1].id, configs[0].id}), now) - require.NoError(t, err) - require.Equal(t, - []uuid.UUID{copiedIDs[liveOrgIDs[0]][configs[1].id], copiedIDs[liveOrgIDs[0]][configs[0].id]}, - getChatIDs(t, staleWriteChatID)) - - // A config with no same-slug counterpart in the chat's organization is - // dropped instead of remapped. - defaultOnlyConfigID := uuid.New() - _, err = sqlDB.ExecContext(ctx, ` - INSERT INTO mcp_server_configs ( - id, organization_id, display_name, slug, url, auth_type - ) VALUES ($1, $2, 'Default-only config', 'migration-568-default-only', 'https://mcp.example.com/default-only', 'none') - `, defaultOnlyConfigID, defaultOrgID) - require.NoError(t, err) - _, err = sqlDB.ExecContext(ctx, ` - UPDATE chats SET mcp_server_ids = $2 WHERE id = $1 - `, staleWriteChatID, pq.Array([]uuid.UUID{configs[2].id, defaultOnlyConfigID})) + `, staleWriteChatID, userID, otherOrgID, modelConfigID, pq.Array([]uuid.UUID{configs[1].id, orgLocalConfigID, danglingID}), now) require.NoError(t, err) - require.Equal(t, []uuid.UUID{copiedIDs[liveOrgIDs[0]][configs[2].id]}, getChatIDs(t, staleWriteChatID)) + require.Equal(t, []uuid.UUID{orgLocalConfigID, danglingID}, getChatIDs(t, staleWriteChatID)) _, err = sqlDB.ExecContext(ctx, ` UPDATE chats SET mcp_server_ids = $2 WHERE id = $1 @@ -3341,33 +3249,30 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { require.NoError(t, err) require.Equal(t, chats[0].configIDs, getChatIDs(t, chats[0].id)) - // Remove the simulation rows so the down-migration assertions below see - // the original state. + // Remove the unknown-ID simulation row so the down-migration assertions + // below can prove no dangling references remain. _, err = sqlDB.ExecContext(ctx, `DELETE FROM chats WHERE id = $1`, staleWriteChatID) require.NoError(t, err) - _, err = sqlDB.ExecContext(ctx, `DELETE FROM mcp_server_configs WHERE id = $1`, defaultOnlyConfigID) - require.NoError(t, err) - orgOnlyConfigID := uuid.New() - _, err = sqlDB.ExecContext(ctx, ` - INSERT INTO mcp_server_configs ( - id, organization_id, display_name, slug, url, auth_type - ) VALUES ($1, $2, 'Org-only config', 'migration-568-org-only', 'https://mcp.example.com/org-only', 'none') - `, orgOnlyConfigID, liveOrgIDs[0]) - require.NoError(t, err) + // An organization-created config referenced by a chat exercises the down + // sweep: the config is deleted and its chat references removed. _, err = sqlDB.ExecContext(ctx, ` - UPDATE chats SET mcp_server_ids = array_append(mcp_server_ids, $2) WHERE id = $1 - `, chats[1].id, orgOnlyConfigID) + UPDATE chats SET mcp_server_ids = $2 WHERE id = $1 + `, chats[1].id, pq.Array([]uuid.UUID{orgLocalConfigID})) require.NoError(t, err) + require.Equal(t, []uuid.UUID{orgLocalConfigID}, getChatIDs(t, chats[1].id)) downSQL, err := os.ReadFile("000570_mcp_server_configs_organization_id.down.sql") require.NoError(t, err) _, err = sqlDB.ExecContext(ctx, string(downSQL)) require.NoError(t, err) - for _, chat := range chats { - require.Equal(t, chat.configIDs, getChatIDs(t, chat.id)) - } + err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_server_configs`).Scan(&totalConfigs) + require.NoError(t, err) + require.Equal(t, len(configs), totalConfigs) + + require.Equal(t, chats[0].configIDs, getChatIDs(t, chats[0].id)) + require.Empty(t, getChatIDs(t, chats[1].id)) var danglingIDs int err = sqlDB.QueryRowContext(ctx, ` SELECT COUNT(*) From 6f3529f0fa0d72ecf2954d88460ab0875aae350f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:57:04 +0000 Subject: [PATCH 39/59] chore(coderd/database): apply cleanup gate to the no-copy migration diff Simplify the compatibility trigger's aggregate FILTER to a WHERE clause, rename the stale remapTriggerExists test helper, and cover the trigger's cross-organization drop on the update path. --- coderd/database/dump.sql | 9 ++++---- ..._mcp_server_configs_organization_id.up.sql | 9 ++++---- coderd/database/migrations/migrate_test.go | 21 +++++++------------ 3 files changed, 15 insertions(+), 24 deletions(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index c7c9f7dbc0163..6e117bc1a9a92 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -970,15 +970,14 @@ BEGIN RETURN NEW; END IF; SELECT COALESCE( - array_agg(item.config_id ORDER BY item.position) FILTER ( - WHERE config.id IS NULL - OR config.organization_id = NEW.organization_id - ), + array_agg(item.config_id ORDER BY item.position), '{}'::uuid[] ) INTO NEW.mcp_server_ids FROM unnest(NEW.mcp_server_ids) WITH ORDINALITY AS item(config_id, position) - LEFT JOIN mcp_server_configs AS config ON config.id = item.config_id; + LEFT JOIN mcp_server_configs AS config ON config.id = item.config_id + WHERE config.id IS NULL + OR config.organization_id = NEW.organization_id; RETURN NEW; END; $$; diff --git a/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql index 04900ab351e2f..30be1c1d7378d 100644 --- a/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql +++ b/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql @@ -48,15 +48,14 @@ BEGIN RETURN NEW; END IF; SELECT COALESCE( - array_agg(item.config_id ORDER BY item.position) FILTER ( - WHERE config.id IS NULL - OR config.organization_id = NEW.organization_id - ), + array_agg(item.config_id ORDER BY item.position), '{}'::uuid[] ) INTO NEW.mcp_server_ids FROM unnest(NEW.mcp_server_ids) WITH ORDINALITY AS item(config_id, position) - LEFT JOIN mcp_server_configs AS config ON config.id = item.config_id; + LEFT JOIN mcp_server_configs AS config ON config.id = item.config_id + WHERE config.id IS NULL + OR config.organization_id = NEW.organization_id; RETURN NEW; END; $$ LANGUAGE plpgsql; diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 9210fa21f51e1..22528a0837a67 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -3164,8 +3164,6 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { require.NoError(t, err) require.EqualValues(t, 570, version) - // The existing rows become the default organization's servers; no copies - // are created for other organizations. var totalConfigs int err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_server_configs`).Scan(&totalConfigs) require.NoError(t, err) @@ -3212,7 +3210,7 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { // the configs now belong to the default organization only. require.Empty(t, getChatIDs(t, chats[1].id)) - remapTriggerExists := func(t *testing.T) bool { + dropTriggerExists := func(t *testing.T) bool { t.Helper() var exists bool err := sqlDB.QueryRowContext(ctx, ` @@ -3221,7 +3219,7 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { require.NoError(t, err) return exists } - require.True(t, remapTriggerExists(t)) + require.True(t, dropTriggerExists(t)) // The compatibility trigger drops cross-organization config IDs, keeps // same-organization IDs, and passes unknown IDs through as written. @@ -3243,22 +3241,17 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { require.NoError(t, err) require.Equal(t, []uuid.UUID{orgLocalConfigID, danglingID}, getChatIDs(t, staleWriteChatID)) - _, err = sqlDB.ExecContext(ctx, ` - UPDATE chats SET mcp_server_ids = $2 WHERE id = $1 - `, chats[0].id, pq.Array(chats[0].configIDs)) - require.NoError(t, err) - require.Equal(t, chats[0].configIDs, getChatIDs(t, chats[0].id)) - // Remove the unknown-ID simulation row so the down-migration assertions // below can prove no dangling references remain. _, err = sqlDB.ExecContext(ctx, `DELETE FROM chats WHERE id = $1`, staleWriteChatID) require.NoError(t, err) - // An organization-created config referenced by a chat exercises the down - // sweep: the config is deleted and its chat references removed. + // The trigger drops the cross-organization ID on update too, leaving the + // organization-created config to exercise the down sweep: the config is + // deleted and its chat references removed. _, err = sqlDB.ExecContext(ctx, ` UPDATE chats SET mcp_server_ids = $2 WHERE id = $1 - `, chats[1].id, pq.Array([]uuid.UUID{orgLocalConfigID})) + `, chats[1].id, pq.Array([]uuid.UUID{configs[1].id, orgLocalConfigID})) require.NoError(t, err) require.Equal(t, []uuid.UUID{orgLocalConfigID}, getChatIDs(t, chats[1].id)) @@ -3283,5 +3276,5 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { require.NoError(t, err) require.Zero(t, danglingIDs) - require.False(t, remapTriggerExists(t)) + require.False(t, dropTriggerExists(t)) } From e94b87e033d7d422a5203363c5516cf7f47b41f3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:46:33 +0000 Subject: [PATCH 40/59] fix(coderd/database): drop the rolling-upgrade chat remap trigger Upgrades run in scheduled maintenance downtime with the database locked during migration, so no pre-scoping replica writes chat MCP selections after the migration commits. --- coderd/database/dump.sql | 24 ------------ ...cp_server_configs_organization_id.down.sql | 3 -- ..._mcp_server_configs_organization_id.up.sql | 30 --------------- coderd/database/migrations/migrate_test.go | 38 ++----------------- 4 files changed, 3 insertions(+), 92 deletions(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 6e117bc1a9a92..63487a1a9a083 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -962,26 +962,6 @@ BEGIN END; $$; -CREATE FUNCTION drop_cross_org_chat_mcp_server_ids() RETURNS trigger - LANGUAGE plpgsql - AS $$ -BEGIN - IF NEW.mcp_server_ids IS NULL OR cardinality(NEW.mcp_server_ids) = 0 THEN - RETURN NEW; - END IF; - SELECT COALESCE( - array_agg(item.config_id ORDER BY item.position), - '{}'::uuid[] - ) - INTO NEW.mcp_server_ids - FROM unnest(NEW.mcp_server_ids) WITH ORDINALITY AS item(config_id, position) - LEFT JOIN mcp_server_configs AS config ON config.id = item.config_id - WHERE config.id IS NULL - OR config.organization_id = NEW.organization_id; - RETURN NEW; -END; -$$; - CREATE FUNCTION enforce_user_ai_budget_override_membership() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -5094,10 +5074,6 @@ CREATE OR REPLACE VIEW provisioner_job_stats AS LEFT JOIN provisioner_job_timings pjt ON ((pjt.job_id = pj.id))) GROUP BY pj.id, wb.workspace_id; -CREATE TRIGGER drop_cross_org_chat_mcp_server_ids BEFORE INSERT OR UPDATE OF mcp_server_ids ON chats FOR EACH ROW EXECUTE FUNCTION drop_cross_org_chat_mcp_server_ids(); - -COMMENT ON TRIGGER drop_cross_org_chat_mcp_server_ids ON chats IS 'Rolling-upgrade compatibility: drops config IDs written by pre-organization-scoping replicas that resolve to another organization''s config.'; - CREATE TRIGGER inhibit_enqueue_if_disabled BEFORE INSERT ON notification_messages FOR EACH ROW EXECUTE FUNCTION inhibit_enqueue_if_disabled(); CREATE TRIGGER protect_deleting_organizations BEFORE UPDATE ON organizations FOR EACH ROW WHEN (((new.deleted = true) AND (old.deleted = false))) EXECUTE FUNCTION protect_deleting_organizations(); diff --git a/coderd/database/migrations/000570_mcp_server_configs_organization_id.down.sql b/coderd/database/migrations/000570_mcp_server_configs_organization_id.down.sql index 92dc9b3c87dac..8915142fa9cdd 100644 --- a/coderd/database/migrations/000570_mcp_server_configs_organization_id.down.sql +++ b/coderd/database/migrations/000570_mcp_server_configs_organization_id.down.sql @@ -1,6 +1,3 @@ -DROP TRIGGER IF EXISTS drop_cross_org_chat_mcp_server_ids ON chats; -DROP FUNCTION IF EXISTS drop_cross_org_chat_mcp_server_ids(); - -- Configs created outside the default organization cannot move to the -- deployment-wide table because slugs may collide across organizations. -- Delete them; the delete trigger from 000510 removes their IDs from chats. diff --git a/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql index 30be1c1d7378d..539af907d2452 100644 --- a/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql +++ b/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql @@ -36,33 +36,3 @@ ALTER TABLE mcp_server_configs CREATE INDEX idx_mcp_server_configs_organization_id ON mcp_server_configs (organization_id); - --- Pre-scoping replicas resolve configs globally and can write another org's --- config ID into a chat during a rolling upgrade. Drop those foreign IDs --- (keeping unknown IDs as written); remove once those replicas are gone. -CREATE FUNCTION drop_cross_org_chat_mcp_server_ids() - RETURNS TRIGGER AS -$$ -BEGIN - IF NEW.mcp_server_ids IS NULL OR cardinality(NEW.mcp_server_ids) = 0 THEN - RETURN NEW; - END IF; - SELECT COALESCE( - array_agg(item.config_id ORDER BY item.position), - '{}'::uuid[] - ) - INTO NEW.mcp_server_ids - FROM unnest(NEW.mcp_server_ids) WITH ORDINALITY AS item(config_id, position) - LEFT JOIN mcp_server_configs AS config ON config.id = item.config_id - WHERE config.id IS NULL - OR config.organization_id = NEW.organization_id; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER drop_cross_org_chat_mcp_server_ids - BEFORE INSERT OR UPDATE OF mcp_server_ids ON chats FOR EACH ROW - EXECUTE PROCEDURE drop_cross_org_chat_mcp_server_ids(); - -COMMENT ON TRIGGER drop_cross_org_chat_mcp_server_ids ON chats IS - 'Rolling-upgrade compatibility: drops config IDs written by pre-organization-scoping replicas that resolve to another organization''s config.'; diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 22528a0837a67..7807cca104848 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -3210,19 +3210,8 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { // the configs now belong to the default organization only. require.Empty(t, getChatIDs(t, chats[1].id)) - dropTriggerExists := func(t *testing.T) bool { - t.Helper() - var exists bool - err := sqlDB.QueryRowContext(ctx, ` - SELECT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'drop_cross_org_chat_mcp_server_ids') - `).Scan(&exists) - require.NoError(t, err) - return exists - } - require.True(t, dropTriggerExists(t)) - - // The compatibility trigger drops cross-organization config IDs, keeps - // same-organization IDs, and passes unknown IDs through as written. + // An organization-created config referenced by a chat exercises the down + // sweep: the config is deleted and its chat references removed. orgLocalConfigID := uuid.New() _, err = sqlDB.ExecContext(ctx, ` INSERT INTO mcp_server_configs ( @@ -3230,28 +3219,9 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { ) VALUES ($1, $2, 'Org-local config', 'migration-568-org-local', 'https://mcp.example.com/org-local', 'none') `, orgLocalConfigID, otherOrgID) require.NoError(t, err) - danglingID := uuid.New() - staleWriteChatID := uuid.New() - _, err = sqlDB.ExecContext(ctx, ` - INSERT INTO chats ( - id, owner_id, organization_id, last_model_config_id, title, - mcp_server_ids, created_at, updated_at - ) VALUES ($1, $2, $3, $4, 'Migration 568 stale write', $5, $6, $6) - `, staleWriteChatID, userID, otherOrgID, modelConfigID, pq.Array([]uuid.UUID{configs[1].id, orgLocalConfigID, danglingID}), now) - require.NoError(t, err) - require.Equal(t, []uuid.UUID{orgLocalConfigID, danglingID}, getChatIDs(t, staleWriteChatID)) - - // Remove the unknown-ID simulation row so the down-migration assertions - // below can prove no dangling references remain. - _, err = sqlDB.ExecContext(ctx, `DELETE FROM chats WHERE id = $1`, staleWriteChatID) - require.NoError(t, err) - - // The trigger drops the cross-organization ID on update too, leaving the - // organization-created config to exercise the down sweep: the config is - // deleted and its chat references removed. _, err = sqlDB.ExecContext(ctx, ` UPDATE chats SET mcp_server_ids = $2 WHERE id = $1 - `, chats[1].id, pq.Array([]uuid.UUID{configs[1].id, orgLocalConfigID})) + `, chats[1].id, pq.Array([]uuid.UUID{orgLocalConfigID})) require.NoError(t, err) require.Equal(t, []uuid.UUID{orgLocalConfigID}, getChatIDs(t, chats[1].id)) @@ -3275,6 +3245,4 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { `).Scan(&danglingIDs) require.NoError(t, err) require.Zero(t, danglingIDs) - - require.False(t, dropTriggerExists(t)) } From 914f19720ff32c01b9612cbbddbdbb4cd6df1423 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:57:49 +0000 Subject: [PATCH 41/59] refactor: nest MCP server config routes under organizations --- coderd/coderd.go | 37 ++++++------- coderd/exp_chats_test.go | 6 +-- coderd/httpmw/mcpserverconfigparam.go | 4 ++ coderd/mcp.go | 2 +- coderd/mcp_test.go | 75 ++++++++++++++++----------- codersdk/mcp.go | 18 +++---- enterprise/coderd/mcp_test.go | 23 ++++---- 7 files changed, 91 insertions(+), 74 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index c8c83564c1fbd..e423045b8f6c8 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1378,29 +1378,23 @@ func New(options *Options) *API { r.Use(httpmw.RateLimit(options.FilesRateLimit, time.Minute)) r.Get("/chats/files/{file}/download", api.downloadChatFile) }) - r.Route("/mcp-servers/{mcpserverconfig}", func(r chi.Router) { + r.Route("/organizations", func(r chi.Router) { r.Use(apiKeyMiddleware) - // Disconnect skips the read-gated param middleware so - // token owners who can no longer read the config, such - // as users removed from the organization, can still - // delete their token and revoke the provider grant. - r.Delete("/oauth2/disconnect", api.mcpServerOAuth2Disconnect) - r.Group(func(r chi.Router) { - r.Use(httpmw.ExtractMCPServerConfigParam(options.Database)) - r.Get("/", api.getMCPServerConfig) - r.Patch("/", api.updateMCPServerConfig) - r.Delete("/", api.deleteMCPServerConfig) - r.Get("/oauth2/connect", api.mcpServerOAuth2Connect) + r.Route("/{organization}", func(r chi.Router) { + r.Use(httpmw.ExtractOrganizationParam(options.Database)) + r.Route("/mcp-servers", func(r chi.Router) { + r.Get("/", api.listMCPServerConfigs) + r.Post("/", api.createMCPServerConfig) + r.Route("/{mcpserverconfig}", func(r chi.Router) { + r.Use(httpmw.ExtractMCPServerConfigParam(options.Database)) + r.Get("/", api.getMCPServerConfig) + r.Patch("/", api.updateMCPServerConfig) + r.Delete("/", api.deleteMCPServerConfig) + r.Get("/oauth2/connect", api.mcpServerOAuth2Connect) + }) + }) }) }) - r.Route("/organizations/{organization}/mcp-servers", func(r chi.Router) { - r.Use( - apiKeyMiddleware, - httpmw.ExtractOrganizationParam(options.Database), - ) - r.Get("/", api.listMCPServerConfigs) - r.Post("/", api.createMCPServerConfig) - }) r.Route("/chats", func(r chi.Router) { r.Use( apiKeyMiddleware, @@ -1524,6 +1518,9 @@ func New(options *Options) *API { ) // This callback path is frozen because it is registered with OAuth2 providers. r.Get("/servers/{mcpServer}/oauth2/callback", api.mcpServerOAuth2Callback) + // Disconnect stays outside organization routes so former organization + // members can delete their stored token after losing config read access. + r.Delete("/servers/{mcpServer}/oauth2/disconnect", api.mcpServerOAuth2Disconnect) // MCP HTTP transport endpoint with mandatory authentication r.Route("/http", func(r chi.Router) { r.Use(httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2, codersdk.ExperimentMCPServerHTTP)) diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 1b07984fe65e3..87e9bca91714a 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -659,7 +659,7 @@ func TestPostChats(t *testing.T) { OrganizationID: firstUser.OrganizationID, Enabled: true, }) - disabledCfg, err := client.Client.UpdateMCPServerConfig(ctx, enabledCfg.ID, codersdk.UpdateMCPServerConfigRequest{ + disabledCfg, err := client.Client.UpdateMCPServerConfig(ctx, enabledCfg.OrganizationID, enabledCfg.ID, codersdk.UpdateMCPServerConfigRequest{ Enabled: ptr.Ref(false), }) require.NoError(t, err) @@ -734,7 +734,7 @@ func TestPostChats(t *testing.T) { }) require.NoError(t, err) - _, err = client.Client.UpdateMCPServerConfig(ctx, cfg.ID, codersdk.UpdateMCPServerConfigRequest{ + _, err = client.Client.UpdateMCPServerConfig(ctx, cfg.OrganizationID, cfg.ID, codersdk.UpdateMCPServerConfigRequest{ Enabled: ptr.Ref(false), }) require.NoError(t, err) @@ -761,7 +761,7 @@ func TestPostChats(t *testing.T) { OrganizationID: firstUser.OrganizationID, Enabled: true, }) - _, err = client.Client.UpdateMCPServerConfig(ctx, secondCfg.ID, codersdk.UpdateMCPServerConfigRequest{ + _, err = client.Client.UpdateMCPServerConfig(ctx, secondCfg.OrganizationID, secondCfg.ID, codersdk.UpdateMCPServerConfigRequest{ Enabled: ptr.Ref(false), }) require.NoError(t, err) diff --git a/coderd/httpmw/mcpserverconfigparam.go b/coderd/httpmw/mcpserverconfigparam.go index 5ac107669bf6f..0ea4a1eeed85c 100644 --- a/coderd/httpmw/mcpserverconfigparam.go +++ b/coderd/httpmw/mcpserverconfigparam.go @@ -45,6 +45,10 @@ func ExtractMCPServerConfigParam(db database.Store) func(http.Handler) http.Hand }) return } + if config.OrganizationID != OrganizationParam(r).ID { + httpapi.ResourceNotFound(rw) + return + } ctx = context.WithValue(ctx, mcpServerConfigParamContextKey{}, config) next.ServeHTTP(rw, r.WithContext(ctx)) diff --git a/coderd/mcp.go b/coderd/mcp.go index e4b30e7d90128..29c4e4209378a 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -1240,7 +1240,7 @@ func (api *API) mcpServerOAuth2Disconnect(rw http.ResponseWriter, r *http.Reques ctx := r.Context() apiKey := httpmw.APIKey(r) - configID, parsed := httpmw.ParseUUIDParam(rw, r, "mcpserverconfig") + configID, parsed := httpmw.ParseUUIDParam(rw, r, "mcpServer") if !parsed { return } diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 78d8c5fef78a7..1289d22605d08 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -147,7 +147,7 @@ func TestMCPServerConfigsCRUD(t *testing.T) { require.False(t, configs[0].AllowInPlanMode) require.False(t, configs[0].ForwardCoderHeaders) - fetched, err := client.MCPServerConfigByID(ctx, created.ID) + fetched, err := client.MCPServerConfigByID(ctx, created.OrganizationID, created.ID) require.NoError(t, err) require.Equal(t, created.ID, fetched.ID) require.False(t, fetched.AllowInPlanMode) @@ -159,7 +159,7 @@ func TestMCPServerConfigsCRUD(t *testing.T) { newAvail := "force_on" allowInPlanMode := true forwardCoderHeaders := true - updated, err := client.UpdateMCPServerConfig(ctx, created.ID, codersdk.UpdateMCPServerConfigRequest{ + updated, err := client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{ DisplayName: &newName, Availability: &newAvail, AllowInPlanMode: &allowInPlanMode, @@ -183,13 +183,13 @@ func TestMCPServerConfigsCRUD(t *testing.T) { require.True(t, configs[0].AllowInPlanMode) require.True(t, configs[0].ForwardCoderHeaders) - fetched, err = client.MCPServerConfigByID(ctx, created.ID) + fetched, err = client.MCPServerConfigByID(ctx, created.OrganizationID, created.ID) require.NoError(t, err) require.True(t, fetched.AllowInPlanMode) require.True(t, fetched.ForwardCoderHeaders) // Delete it. - err = client.DeleteMCPServerConfig(ctx, created.ID) + err = client.DeleteMCPServerConfig(ctx, created.OrganizationID, created.ID) require.NoError(t, err) // Verify it's gone. @@ -198,6 +198,25 @@ func TestMCPServerConfigsCRUD(t *testing.T) { require.Empty(t, configs) } +func TestMCPServerConfigWrongOrganization(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), + ChatProviderAPIKeys: &providerKeys, + }) + firstUser := coderdtest.CreateFirstUser(t, client) + config := createMCPServerConfig(t, client, firstUser.OrganizationID, "wrong-org", true) + otherOrganization := dbgen.Organization(t, db, database.Organization{}) + + _, err := client.MCPServerConfigByID(ctx, otherOrganization.ID, config.ID) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) +} + func TestMCPServerConfigsNonAdmin(t *testing.T) { t.Parallel() @@ -234,7 +253,7 @@ func TestMCPServerConfigsNonAdmin(t *testing.T) { for _, config := range auditorConfigs { require.NotEmpty(t, config.URL, "%s: %s", name, config.Slug) if !config.Enabled { - fetched, err := auditorClient.MCPServerConfigByID(ctx, config.ID) + fetched, err := auditorClient.MCPServerConfigByID(ctx, config.OrganizationID, config.ID) require.NoError(t, err, name) require.NotEmpty(t, fetched.URL, name) } @@ -313,7 +332,7 @@ func TestMCPServerConfigsSecretsNeverLeaked(t *testing.T) { } // Admin get-by-ID endpoint. - adminSingle, err := adminClient.MCPServerConfigByID(ctx, created.ID) + adminSingle, err := adminClient.MCPServerConfigByID(ctx, created.OrganizationID, created.ID) require.NoError(t, err) assertNoSecrets(t, "admin get-by-id", adminSingle) @@ -335,7 +354,7 @@ func TestMCPServerConfigsSecretsNeverLeaked(t *testing.T) { } // Non-admin get-by-ID endpoint. - memberSingle, err := memberClient.MCPServerConfigByID(ctx, created.ID) + memberSingle, err := memberClient.MCPServerConfigByID(ctx, created.OrganizationID, created.ID) require.NoError(t, err) assertNoSecrets(t, "member get-by-id", memberSingle) assert.Empty(t, memberSingle.OAuth2ClientID, "member should not see OAuth2ClientID") @@ -446,14 +465,14 @@ func TestMCPServerConfigsUserOIDCClearsFields(t *testing.T) { require.Equal(t, "https://auth.example.com/revoke", created.OAuth2RevocationURL) newRevocationURL := "https://auth.example.com/revoke2" - updated, err := client.UpdateMCPServerConfig(ctx, created.ID, codersdk.UpdateMCPServerConfigRequest{ + updated, err := client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{ OAuth2RevocationURL: &newRevocationURL, }) require.NoError(t, err) require.Equal(t, newRevocationURL, updated.OAuth2RevocationURL) invalidURL := "not a url" - _, err = client.UpdateMCPServerConfig(ctx, created.ID, codersdk.UpdateMCPServerConfigRequest{ + _, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{ OAuth2RevocationURL: &invalidURL, }) require.Error(t, err) @@ -463,7 +482,7 @@ func TestMCPServerConfigsUserOIDCClearsFields(t *testing.T) { // Plaintext URLs are rejected on save, not later at disconnect. plaintextURL := "http://auth.example.com/revoke" - _, err = client.UpdateMCPServerConfig(ctx, created.ID, codersdk.UpdateMCPServerConfigRequest{ + _, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{ OAuth2RevocationURL: &plaintextURL, }) require.ErrorAs(t, err, &sdkErr) @@ -489,20 +508,20 @@ func TestMCPServerConfigsUserOIDCClearsFields(t *testing.T) { // An explicit empty string clears the stored URL. emptyURL := "" - updated, err = client.UpdateMCPServerConfig(ctx, created.ID, codersdk.UpdateMCPServerConfigRequest{ + updated, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{ OAuth2RevocationURL: &emptyURL, }) require.NoError(t, err) require.Empty(t, updated.OAuth2RevocationURL) - updated, err = client.UpdateMCPServerConfig(ctx, created.ID, codersdk.UpdateMCPServerConfigRequest{ + updated, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{ OAuth2RevocationURL: &newRevocationURL, }) require.NoError(t, err) require.Equal(t, newRevocationURL, updated.OAuth2RevocationURL) newAuth := "user_oidc" - updated, err = client.UpdateMCPServerConfig(ctx, created.ID, codersdk.UpdateMCPServerConfigRequest{ + updated, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{ AuthType: &newAuth, }) require.NoError(t, err) @@ -611,7 +630,7 @@ func TestMCPServerConfigsUpdateInvalidatesUserGrants(t *testing.T) { seedToken(ctx, t, config.ID) newURL := "https://mcp.example.com/grant-url-change-moved" - _, err := adminClient.UpdateMCPServerConfig(ctx, config.ID, codersdk.UpdateMCPServerConfigRequest{ + _, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{ URL: &newURL, }) require.NoError(t, err) @@ -625,7 +644,7 @@ func TestMCPServerConfigsUpdateInvalidatesUserGrants(t *testing.T) { seedToken(ctx, t, config.ID) authType := "none" - _, err := adminClient.UpdateMCPServerConfig(ctx, config.ID, codersdk.UpdateMCPServerConfigRequest{ + _, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{ AuthType: &authType, }) require.NoError(t, err) @@ -639,7 +658,7 @@ func TestMCPServerConfigsUpdateInvalidatesUserGrants(t *testing.T) { seedToken(ctx, t, config.ID) movedEndpoint := "https://auth.example.com/other-endpoint" - _, err := adminClient.UpdateMCPServerConfig(ctx, config.ID, codersdk.UpdateMCPServerConfigRequest{ + _, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{ OAuth2TokenURL: &movedEndpoint, }) require.NoError(t, err) @@ -653,7 +672,7 @@ func TestMCPServerConfigsUpdateInvalidatesUserGrants(t *testing.T) { seedToken(ctx, t, config.ID) newClientID := "cid-2" - _, err := adminClient.UpdateMCPServerConfig(ctx, config.ID, codersdk.UpdateMCPServerConfigRequest{ + _, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{ OAuth2ClientID: &newClientID, }) require.NoError(t, err) @@ -668,7 +687,7 @@ func TestMCPServerConfigsUpdateInvalidatesUserGrants(t *testing.T) { displayName := "Grant Invalidation renamed" newSecret := "rotated-secret" - _, err := adminClient.UpdateMCPServerConfig(ctx, config.ID, codersdk.UpdateMCPServerConfigRequest{ + _, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{ DisplayName: &displayName, OAuth2ClientSecret: &newSecret, }) @@ -693,7 +712,7 @@ func TestMCPServerConfigsOAuth2CallbackRejectsSupersededConfig(t *testing.T) { tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { if id := configID.Load(); id != nil { movedURL := "https://attacker.example.com/superseded" - _, err := adminClient.UpdateMCPServerConfig(ctx, *id, codersdk.UpdateMCPServerConfigRequest{ + _, err := adminClient.UpdateMCPServerConfig(ctx, firstUser.OrganizationID, *id, codersdk.UpdateMCPServerConfigRequest{ URL: &movedURL, }) assert.NoError(t, err) @@ -780,7 +799,7 @@ func TestMCPServerConfigsUserOIDCRequiresDeploymentPerms(t *testing.T) { require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) userOIDC := "user_oidc" - _, err = orgAdminClient.UpdateMCPServerConfig(ctx, orgAdminOwned.ID, codersdk.UpdateMCPServerConfigRequest{ + _, err = orgAdminClient.UpdateMCPServerConfig(ctx, orgAdminOwned.OrganizationID, orgAdminOwned.ID, codersdk.UpdateMCPServerConfigRequest{ AuthType: &userOIDC, }) require.ErrorAs(t, err, &sdkErr) @@ -791,14 +810,14 @@ func TestMCPServerConfigsUserOIDCRequiresDeploymentPerms(t *testing.T) { // The URL determines where chat owners' OIDC tokens are sent. newURL := "https://attacker.example.com/exfil" - _, err = orgAdminClient.UpdateMCPServerConfig(ctx, deploymentOwned.ID, codersdk.UpdateMCPServerConfigRequest{ + _, err = orgAdminClient.UpdateMCPServerConfig(ctx, deploymentOwned.OrganizationID, deploymentOwned.ID, codersdk.UpdateMCPServerConfigRequest{ URL: &newURL, }) require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) updatedURL := "https://mcp.example.com/deployment-oidc-v2" - updated, err := adminClient.UpdateMCPServerConfig(ctx, deploymentOwned.ID, codersdk.UpdateMCPServerConfigRequest{ + updated, err := adminClient.UpdateMCPServerConfig(ctx, deploymentOwned.OrganizationID, deploymentOwned.ID, codersdk.UpdateMCPServerConfigRequest{ URL: &updatedURL, }) require.NoError(t, err) @@ -1967,12 +1986,8 @@ func TestMCPServerOAuth2PKCE(t *testing.T) { return http.ErrUseLastResponse } - connectURL, err := memberClient.URL.Parse( - "/api/experimental/mcp-servers/" + created.ID.String() + "/oauth2/connect", - ) - require.NoError(t, err) - - req, err := http.NewRequestWithContext(ctx, "GET", connectURL.String(), nil) + connectURL := memberClient.MCPServerOAuth2ConnectURL(created.OrganizationID, created.ID) + req, err := http.NewRequestWithContext(ctx, "GET", connectURL, nil) require.NoError(t, err) req.AddCookie(&http.Cookie{ Name: codersdk.SessionTokenCookie, @@ -2228,7 +2243,7 @@ func TestChatWithMCPServerIDs(t *testing.T) { require.NoError(t, err) require.ElementsMatch(t, []uuid.UUID{mcpConfigA.ID, mcpConfigB.ID}, fetched.MCPServerIDs) - err = client.DeleteMCPServerConfig(ctx, mcpConfigA.ID) + err = client.DeleteMCPServerConfig(ctx, mcpConfigA.OrganizationID, mcpConfigA.ID) require.NoError(t, err) fetched, err = expClient.GetChat(ctx, chat.ID) @@ -2835,7 +2850,7 @@ func TestMCPServerConfigsRevokedGrant(t *testing.T) { require.Equal(t, hitsAfterFirstList, tokenEndpointHits.Load()) // The single-config endpoint agrees. - single, err := memberClient.MCPServerConfigByID(ctx, created.ID) + single, err := memberClient.MCPServerConfigByID(ctx, created.OrganizationID, created.ID) require.NoError(t, err) require.False(t, single.AuthConnected) require.Equal(t, hitsAfterFirstList, tokenEndpointHits.Load()) diff --git a/codersdk/mcp.go b/codersdk/mcp.go index 7f7596fbeddbd..a26c855a3faa0 100644 --- a/codersdk/mcp.go +++ b/codersdk/mcp.go @@ -12,8 +12,8 @@ import ( // 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(id uuid.UUID) string { - return fmt.Sprintf("%s/api/experimental/mcp-servers/%s/oauth2/connect", c.URL.String(), id) +func (c *Client) MCPServerOAuth2ConnectURL(organizationID, id uuid.UUID) string { + return fmt.Sprintf("%s/api/experimental/organizations/%s/mcp-servers/%s/oauth2/connect", c.URL.String(), organizationID, id) } // MCPServerOAuth2DisconnectResponse reports whether the removed token @@ -34,7 +34,7 @@ func (c *Client) MCPServerOAuth2Disconnect(ctx context.Context, id uuid.UUID) er // 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/experimental/mcp-servers/%s/oauth2/disconnect", id), nil) + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/mcp/servers/%s/oauth2/disconnect", id), nil) if err != nil { return MCPServerOAuth2DisconnectResponse{}, err } @@ -187,8 +187,8 @@ func (c *Client) MCPServerConfigs(ctx context.Context, organizationID uuid.UUID) return configs, ReadBodyAsJSON(res, &configs) } -func (c *Client) MCPServerConfigByID(ctx context.Context, id uuid.UUID) (MCPServerConfig, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/mcp-servers/%s", id), nil) +func (c *Client) MCPServerConfigByID(ctx context.Context, organizationID, id uuid.UUID) (MCPServerConfig, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/mcp-servers/%s", organizationID, id), nil) if err != nil { return MCPServerConfig{}, err } @@ -213,8 +213,8 @@ func (c *Client) CreateMCPServerConfig(ctx context.Context, organizationID uuid. return config, ReadBodyAsJSON(res, &config) } -func (c *Client) UpdateMCPServerConfig(ctx context.Context, id uuid.UUID, req UpdateMCPServerConfigRequest) (MCPServerConfig, error) { - res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/mcp-servers/%s", id), req) +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/experimental/organizations/%s/mcp-servers/%s", organizationID, id), req) if err != nil { return MCPServerConfig{}, err } @@ -226,8 +226,8 @@ func (c *Client) UpdateMCPServerConfig(ctx context.Context, id uuid.UUID, req Up return config, ReadBodyAsJSON(res, &config) } -func (c *Client) DeleteMCPServerConfig(ctx context.Context, id uuid.UUID) error { - res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/mcp-servers/%s", id), nil) +func (c *Client) DeleteMCPServerConfig(ctx context.Context, organizationID, id uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/organizations/%s/mcp-servers/%s", organizationID, id), nil) if err != nil { return err } diff --git a/enterprise/coderd/mcp_test.go b/enterprise/coderd/mcp_test.go index 155c7b5cd7de3..aad14dd9c5fcf 100644 --- a/enterprise/coderd/mcp_test.go +++ b/enterprise/coderd/mcp_test.go @@ -46,8 +46,7 @@ func requireMCPServerConfigRequestStatus( t *testing.T, client *codersdk.Client, method string, - configID uuid.UUID, - pathSuffix string, + path string, body any, wantStatus int, ) { @@ -56,7 +55,7 @@ func requireMCPServerConfigRequestStatus( res, err := client.Request( testutil.Context(t, testutil.WaitLong), method, - "/api/experimental/mcp-servers/"+configID.String()+pathSuffix, + path, body, ) require.NoError(t, err) @@ -107,23 +106,25 @@ func TestMCPServerConfigItemCrossOrganizationConcealment(t *testing.T) { secondOrg := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{}) otherClient, _ := coderdtest.CreateAnotherUser(t, client, secondOrg.ID) config := createMCPServerConfigForOrganization(t, client, firstUser.OrganizationID, "private-org-one-mcp") + organizationPath := "/api/experimental/organizations/" + secondOrg.ID.String() + "/mcp-servers/" + config.ID.String() + frozenPath := "/api/experimental/mcp/servers/" + config.ID.String() for _, test := range []struct { name string method string - pathSuffix string + path string body any wantStatus int }{ - {name: "Get", method: http.MethodGet}, - {name: "Patch", method: http.MethodPatch, body: codersdk.UpdateMCPServerConfigRequest{DisplayName: ptr.Ref("cross-org")}}, - {name: "Delete", method: http.MethodDelete}, - {name: "OAuthConnect", method: http.MethodGet, pathSuffix: "/oauth2/connect"}, - {name: "OAuthCallback", method: http.MethodGet, pathSuffix: "/oauth2/callback"}, + {name: "Get", method: http.MethodGet, path: organizationPath}, + {name: "Patch", method: http.MethodPatch, path: organizationPath, body: codersdk.UpdateMCPServerConfigRequest{DisplayName: ptr.Ref("cross-org")}}, + {name: "Delete", method: http.MethodDelete, path: organizationPath}, + {name: "OAuthConnect", method: http.MethodGet, path: organizationPath + "/oauth2/connect"}, + {name: "OAuthCallback", method: http.MethodGet, path: frozenPath + "/oauth2/callback"}, // Disconnect returns 200 for every caller without a token, // including nonexistent config IDs, so the response does not // reveal whether the config exists. - {name: "OAuthDisconnect", method: http.MethodDelete, pathSuffix: "/oauth2/disconnect", wantStatus: http.StatusOK}, + {name: "OAuthDisconnect", method: http.MethodDelete, path: frozenPath + "/oauth2/disconnect", wantStatus: http.StatusOK}, } { t.Run(test.name, func(t *testing.T) { t.Parallel() @@ -131,7 +132,7 @@ func TestMCPServerConfigItemCrossOrganizationConcealment(t *testing.T) { if wantStatus == 0 { wantStatus = http.StatusNotFound } - requireMCPServerConfigRequestStatus(t, otherClient, test.method, config.ID, test.pathSuffix, test.body, wantStatus) + requireMCPServerConfigRequestStatus(t, otherClient, test.method, test.path, test.body, wantStatus) }) } } From bc6b4253ba797e8b08c14988ecf865d34ea44cd9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:04:38 +0000 Subject: [PATCH 42/59] refactor: filter enabled MCP server configs in SQL --- coderd/database/dbauthz/dbauthz.go | 8 +- coderd/database/dbauthz/dbauthz_test.go | 6 +- coderd/database/dbmetrics/querymetrics.go | 16 +- coderd/database/dbmock/dbmock.go | 30 ++-- coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 147 ++++++++++--------- coderd/database/queries/mcpserverconfigs.sql | 3 +- coderd/exp_chats.go | 14 +- coderd/x/chatd/generation_preparer.go | 15 +- enterprise/dbcrypt/dbcrypt.go | 4 +- enterprise/dbcrypt/dbcrypt_internal_test.go | 4 +- 11 files changed, 120 insertions(+), 129 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 899f10f11d610..9a17d6588e560 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3783,6 +3783,10 @@ func (q *querier) GetEnabledMCPServerConfigsByOrganization(ctx context.Context, return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetEnabledMCPServerConfigsByOrganization)(ctx, organizationID) } +func (q *querier) GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg database.GetEnabledMCPServerConfigsByOrganizationAndIDsParams) ([]database.MCPServerConfig, error) { + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetEnabledMCPServerConfigsByOrganizationAndIDs)(ctx, arg) +} + // GetExternalAgentTokensByTemplateID is used for scaletesting purposes; the // scaletest agentfake path calls this query directly via a connection to the // database. There is no production code path that uses this method, and it is @@ -4077,10 +4081,6 @@ func (q *querier) GetMCPServerConfigsByOrganization(ctx context.Context, organiz return q.db.GetAuthorizedMCPServerConfigs(ctx, organizationID, prepared) } -func (q *querier) GetMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg database.GetMCPServerConfigsByOrganizationAndIDsParams) ([]database.MCPServerConfig, error) { - return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetMCPServerConfigsByOrganizationAndIDs)(ctx, arg) -} - func (q *querier) GetMCPServerUserToken(ctx context.Context, arg database.GetMCPServerUserTokenParams) (database.MCPServerUserToken, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { return database.MCPServerUserToken{}, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 8ceaf42f5792c..7b30954f54ece 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1699,14 +1699,14 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetAuthorizedMCPServerConfigs(gomock.Any(), orgID, gomock.Any()).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() check.Args(orgID, emptyPreparedAuthorized{}).Asserts().Returns([]database.MCPServerConfig{configA, configB}) })) - s.Run("GetMCPServerConfigsByOrganizationAndIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - arg := database.GetMCPServerConfigsByOrganizationAndIDsParams{ + s.Run("GetEnabledMCPServerConfigsByOrganizationAndIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + arg := database.GetEnabledMCPServerConfigsByOrganizationAndIDsParams{ OrganizationID: uuid.New(), IDs: []uuid.UUID{uuid.New(), uuid.New()}, } configA := testutil.Fake(s.T(), faker, database.MCPServerConfig{ID: arg.IDs[0], OrganizationID: arg.OrganizationID}) configB := testutil.Fake(s.T(), faker, database.MCPServerConfig{ID: arg.IDs[1], OrganizationID: arg.OrganizationID}) - dbm.EXPECT().GetMCPServerConfigsByOrganizationAndIDs(gomock.Any(), arg).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() + dbm.EXPECT().GetEnabledMCPServerConfigsByOrganizationAndIDs(gomock.Any(), arg).Return([]database.MCPServerConfig{configA, configB}, nil).AnyTimes() check.Args(arg).Asserts(configA, policy.ActionRead, configB, policy.ActionRead).OutOfOrder().Returns([]database.MCPServerConfig{configA, configB}) })) s.Run("GetMCPServerUserToken", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index f248aade43b2b..2c1614ed4c5aa 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2025,6 +2025,14 @@ func (m queryMetricsStore) GetEnabledMCPServerConfigsByOrganization(ctx context. return r0, r1 } +func (m queryMetricsStore) GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg database.GetEnabledMCPServerConfigsByOrganizationAndIDsParams) ([]database.MCPServerConfig, error) { + start := time.Now() + r0, r1 := m.s.GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx, arg) + m.queryLatencies.WithLabelValues("GetEnabledMCPServerConfigsByOrganizationAndIDs").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetEnabledMCPServerConfigsByOrganizationAndIDs").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetExternalAgentTokensByTemplateID(ctx context.Context, arg database.GetExternalAgentTokensByTemplateIDParams) ([]database.GetExternalAgentTokensByTemplateIDRow, error) { start := time.Now() r0, r1 := m.s.GetExternalAgentTokensByTemplateID(ctx, arg) @@ -2353,14 +2361,6 @@ func (m queryMetricsStore) GetMCPServerConfigsByOrganization(ctx context.Context return r0, r1 } -func (m queryMetricsStore) GetMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg database.GetMCPServerConfigsByOrganizationAndIDsParams) ([]database.MCPServerConfig, error) { - start := time.Now() - r0, r1 := m.s.GetMCPServerConfigsByOrganizationAndIDs(ctx, arg) - m.queryLatencies.WithLabelValues("GetMCPServerConfigsByOrganizationAndIDs").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetMCPServerConfigsByOrganizationAndIDs").Inc() - return r0, r1 -} - func (m queryMetricsStore) GetMCPServerUserToken(ctx context.Context, arg database.GetMCPServerUserTokenParams) (database.MCPServerUserToken, error) { start := time.Now() r0, r1 := m.s.GetMCPServerUserToken(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 2cb4cb7593a81..729e58cf05188 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3779,6 +3779,21 @@ func (mr *MockStoreMockRecorder) GetEnabledMCPServerConfigsByOrganization(ctx, o return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEnabledMCPServerConfigsByOrganization", reflect.TypeOf((*MockStore)(nil).GetEnabledMCPServerConfigsByOrganization), ctx, organizationID) } +// GetEnabledMCPServerConfigsByOrganizationAndIDs mocks base method. +func (m *MockStore) GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg database.GetEnabledMCPServerConfigsByOrganizationAndIDsParams) ([]database.MCPServerConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEnabledMCPServerConfigsByOrganizationAndIDs", ctx, arg) + ret0, _ := ret[0].([]database.MCPServerConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEnabledMCPServerConfigsByOrganizationAndIDs indicates an expected call of GetEnabledMCPServerConfigsByOrganizationAndIDs. +func (mr *MockStoreMockRecorder) GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEnabledMCPServerConfigsByOrganizationAndIDs", reflect.TypeOf((*MockStore)(nil).GetEnabledMCPServerConfigsByOrganizationAndIDs), ctx, arg) +} + // GetExternalAgentTokensByTemplateID mocks base method. func (m *MockStore) GetExternalAgentTokensByTemplateID(ctx context.Context, arg database.GetExternalAgentTokensByTemplateIDParams) ([]database.GetExternalAgentTokensByTemplateIDRow, error) { m.ctrl.T.Helper() @@ -4394,21 +4409,6 @@ func (mr *MockStoreMockRecorder) GetMCPServerConfigsByOrganization(ctx, organiza return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigsByOrganization", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigsByOrganization), ctx, organizationID) } -// GetMCPServerConfigsByOrganizationAndIDs mocks base method. -func (m *MockStore) GetMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg database.GetMCPServerConfigsByOrganizationAndIDsParams) ([]database.MCPServerConfig, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetMCPServerConfigsByOrganizationAndIDs", ctx, arg) - ret0, _ := ret[0].([]database.MCPServerConfig) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetMCPServerConfigsByOrganizationAndIDs indicates an expected call of GetMCPServerConfigsByOrganizationAndIDs. -func (mr *MockStoreMockRecorder) GetMCPServerConfigsByOrganizationAndIDs(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigsByOrganizationAndIDs", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigsByOrganizationAndIDs), ctx, arg) -} - // GetMCPServerUserToken mocks base method. func (m *MockStore) GetMCPServerUserToken(ctx context.Context, arg database.GetMCPServerUserTokenParams) (database.MCPServerUserToken, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index c7d1f74645e78..fbb499a0f5450 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -556,6 +556,7 @@ type sqlcQuerier interface { GetEnabledChatModelConfigByID(ctx context.Context, id uuid.UUID) (ChatModelConfig, error) GetEnabledChatModelConfigs(ctx context.Context) ([]GetEnabledChatModelConfigsRow, error) GetEnabledMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]MCPServerConfig, error) + GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg GetEnabledMCPServerConfigsByOrganizationAndIDsParams) ([]MCPServerConfig, error) // GetExternalAgentTokensByTemplateID returns the auth tokens for all // non-deleted external agents on the latest build of every running workspace // of the given template. "Running" means the latest build has @@ -649,7 +650,6 @@ type sqlcQuerier interface { GetMCPServerConfigByIDForUpdate(ctx context.Context, id uuid.UUID) (MCPServerConfig, error) GetMCPServerConfigByOrganizationAndSlug(ctx context.Context, arg GetMCPServerConfigByOrganizationAndSlugParams) (MCPServerConfig, error) GetMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]MCPServerConfig, error) - GetMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg GetMCPServerConfigsByOrganizationAndIDsParams) ([]MCPServerConfig, error) GetMCPServerUserToken(ctx context.Context, arg GetMCPServerUserTokenParams) (MCPServerUserToken, error) GetMCPServerUserTokensByUserID(ctx context.Context, userID uuid.UUID) ([]MCPServerUserToken, error) // Must be called from within a transaction. The row lock is released diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b07254b102ccf..03709b3738ef6 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -17213,6 +17213,80 @@ func (q *sqlQuerier) GetEnabledMCPServerConfigsByOrganization(ctx context.Contex return items, nil } +const getEnabledMCPServerConfigsByOrganizationAndIDs = `-- name: GetEnabledMCPServerConfigsByOrganizationAndIDs :many +SELECT + id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id +FROM + mcp_server_configs +WHERE + organization_id = $1::uuid + AND id = ANY($2::uuid[]) + AND enabled = TRUE +ORDER BY + display_name ASC +` + +type GetEnabledMCPServerConfigsByOrganizationAndIDsParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + IDs []uuid.UUID `db:"ids" json:"ids"` +} + +func (q *sqlQuerier) GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg GetEnabledMCPServerConfigsByOrganizationAndIDsParams) ([]MCPServerConfig, error) { + rows, err := q.db.QueryContext(ctx, getEnabledMCPServerConfigsByOrganizationAndIDs, arg.OrganizationID, pq.Array(arg.IDs)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []MCPServerConfig + for rows.Next() { + var i MCPServerConfig + if err := rows.Scan( + &i.ID, + &i.DisplayName, + &i.Slug, + &i.Description, + &i.IconURL, + &i.Transport, + &i.Url, + &i.AuthType, + &i.OAuth2ClientID, + &i.OAuth2ClientSecret, + &i.OAuth2ClientSecretKeyID, + &i.OAuth2AuthURL, + &i.OAuth2TokenURL, + &i.OAuth2Scopes, + &i.APIKeyHeader, + &i.APIKeyValue, + &i.APIKeyValueKeyID, + &i.CustomHeaders, + &i.CustomHeadersKeyID, + pq.Array(&i.ToolAllowList), + pq.Array(&i.ToolDenyList), + &i.Availability, + &i.Enabled, + &i.CreatedBy, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.ModelIntent, + &i.AllowInPlanMode, + &i.ForwardCoderHeaders, + &i.OAuth2RevocationURL, + &i.OrganizationID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getForcedMCPServerConfigsByOrganization = `-- name: GetForcedMCPServerConfigsByOrganization :many SELECT id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id @@ -17505,79 +17579,6 @@ func (q *sqlQuerier) GetMCPServerConfigsByOrganization(ctx context.Context, orga return items, nil } -const getMCPServerConfigsByOrganizationAndIDs = `-- name: GetMCPServerConfigsByOrganizationAndIDs :many -SELECT - id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id -FROM - mcp_server_configs -WHERE - organization_id = $1::uuid - AND id = ANY($2::uuid[]) -ORDER BY - display_name ASC -` - -type GetMCPServerConfigsByOrganizationAndIDsParams struct { - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - IDs []uuid.UUID `db:"ids" json:"ids"` -} - -func (q *sqlQuerier) GetMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg GetMCPServerConfigsByOrganizationAndIDsParams) ([]MCPServerConfig, error) { - rows, err := q.db.QueryContext(ctx, getMCPServerConfigsByOrganizationAndIDs, arg.OrganizationID, pq.Array(arg.IDs)) - if err != nil { - return nil, err - } - defer rows.Close() - var items []MCPServerConfig - for rows.Next() { - var i MCPServerConfig - if err := rows.Scan( - &i.ID, - &i.DisplayName, - &i.Slug, - &i.Description, - &i.IconURL, - &i.Transport, - &i.Url, - &i.AuthType, - &i.OAuth2ClientID, - &i.OAuth2ClientSecret, - &i.OAuth2ClientSecretKeyID, - &i.OAuth2AuthURL, - &i.OAuth2TokenURL, - &i.OAuth2Scopes, - &i.APIKeyHeader, - &i.APIKeyValue, - &i.APIKeyValueKeyID, - &i.CustomHeaders, - &i.CustomHeadersKeyID, - pq.Array(&i.ToolAllowList), - pq.Array(&i.ToolDenyList), - &i.Availability, - &i.Enabled, - &i.CreatedBy, - &i.UpdatedBy, - &i.CreatedAt, - &i.UpdatedAt, - &i.ModelIntent, - &i.AllowInPlanMode, - &i.ForwardCoderHeaders, - &i.OAuth2RevocationURL, - &i.OrganizationID, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const getMCPServerUserToken = `-- name: GetMCPServerUserToken :one SELECT id, mcp_server_config_id, user_id, access_token, access_token_key_id, refresh_token, refresh_token_key_id, token_type, expiry, created_at, updated_at, oauth_refresh_failure_reason diff --git a/coderd/database/queries/mcpserverconfigs.sql b/coderd/database/queries/mcpserverconfigs.sql index 47fde2e3c2b04..1e3908bb44049 100644 --- a/coderd/database/queries/mcpserverconfigs.sql +++ b/coderd/database/queries/mcpserverconfigs.sql @@ -47,7 +47,7 @@ WHERE ORDER BY display_name ASC; --- name: GetMCPServerConfigsByOrganizationAndIDs :many +-- name: GetEnabledMCPServerConfigsByOrganizationAndIDs :many SELECT * FROM @@ -55,6 +55,7 @@ FROM WHERE organization_id = @organization_id::uuid AND id = ANY(@ids::uuid[]) + AND enabled = TRUE ORDER BY display_name ASC; diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 9bca7310b5326..c2c8b8b07d204 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -1263,23 +1263,21 @@ func validateChatMCPServerIDs( return unique, nil, nil } - configs, err := db.GetMCPServerConfigsByOrganizationAndIDs(ctx, database.GetMCPServerConfigsByOrganizationAndIDsParams{ + configs, err := db.GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx, database.GetEnabledMCPServerConfigsByOrganizationAndIDsParams{ OrganizationID: organizationID, IDs: unique, }) if err != nil { - return nil, nil, xerrors.Errorf("get MCP server configs for organization: %w", err) + return nil, nil, xerrors.Errorf("get enabled MCP server configs for organization: %w", err) } - enabled := make(map[uuid.UUID]struct{}, len(configs)) + valid := make(map[uuid.UUID]struct{}, len(configs)) for _, config := range configs { - if config.Enabled { - enabled[config.ID] = struct{}{} - } + valid[config.ID] = struct{}{} } - invalid = make([]uuid.UUID, 0, len(unique)-len(enabled)) + invalid = make([]uuid.UUID, 0, len(unique)-len(valid)) for _, id := range unique { - if _, ok := enabled[id]; !ok { + if _, ok := valid[id]; !ok { invalid = append(invalid, id) } } diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index 0563a8d881f8c..b61491d3b4f3e 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -911,27 +911,18 @@ func latestAssistantText(messages []database.ChatMessage) string { return "" } -// Returns enabled requested configs visible to the chat organization. Filtering -// here preserves the pre-org-scoping behavior of skipping disabled configs. func enabledMCPServerConfigsForChatOrg( ctx context.Context, db database.Store, organizationID uuid.UUID, ids []uuid.UUID, ) ([]database.MCPServerConfig, error) { - configs, err := db.GetMCPServerConfigsByOrganizationAndIDs(ctx, database.GetMCPServerConfigsByOrganizationAndIDsParams{ + configs, err := db.GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx, database.GetEnabledMCPServerConfigsByOrganizationAndIDsParams{ OrganizationID: organizationID, IDs: ids, }) if err != nil { - return nil, xerrors.Errorf("get MCP server configs for organization: %w", err) + return nil, xerrors.Errorf("get enabled MCP server configs for organization: %w", err) } - - enabled := make([]database.MCPServerConfig, 0, len(configs)) - for _, cfg := range configs { - if cfg.Enabled { - enabled = append(enabled, cfg) - } - } - return enabled, nil + return configs, nil } diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index d2f81d3995357..60f3f92d1432d 100644 --- a/enterprise/dbcrypt/dbcrypt.go +++ b/enterprise/dbcrypt/dbcrypt.go @@ -749,8 +749,8 @@ func (db *dbCrypt) GetMCPServerConfigsByOrganization(ctx context.Context, organi return cfgs, nil } -func (db *dbCrypt) GetMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg database.GetMCPServerConfigsByOrganizationAndIDsParams) ([]database.MCPServerConfig, error) { - cfgs, err := db.Store.GetMCPServerConfigsByOrganizationAndIDs(ctx, arg) +func (db *dbCrypt) GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg database.GetEnabledMCPServerConfigsByOrganizationAndIDsParams) ([]database.MCPServerConfig, error) { + cfgs, err := db.Store.GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx, arg) if err != nil { return nil, err } diff --git a/enterprise/dbcrypt/dbcrypt_internal_test.go b/enterprise/dbcrypt/dbcrypt_internal_test.go index a696376b9fe65..5ecb4a7324688 100644 --- a/enterprise/dbcrypt/dbcrypt_internal_test.go +++ b/enterprise/dbcrypt/dbcrypt_internal_test.go @@ -1031,12 +1031,12 @@ func TestMCPServerConfigs(t *testing.T) { requireMCPServerConfigRawEncrypted(ctx, t, db, cfg.ID, ciphers, oauthSecret, apiKeyValue, customHeaders) }) - t.Run("GetMCPServerConfigsByOrganizationAndIDs", func(t *testing.T) { + t.Run("GetEnabledMCPServerConfigsByOrganizationAndIDs", func(t *testing.T) { t.Parallel() db, crypt, ciphers := setup(t) cfg := insertConfig(t, crypt, ciphers) - cfgs, err := crypt.GetMCPServerConfigsByOrganizationAndIDs(ctx, database.GetMCPServerConfigsByOrganizationAndIDsParams{ + cfgs, err := crypt.GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx, database.GetEnabledMCPServerConfigsByOrganizationAndIDsParams{ OrganizationID: cfg.OrganizationID, IDs: []uuid.UUID{cfg.ID}, }) From f710f2a24ace669ac7245c8cf7f49e9010feb537 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:02:11 +0000 Subject: [PATCH 43/59] fix(coderd/database): drop defensive paths from migration 000570 --- ..._mcp_server_configs_organization_id.up.sql | 17 ----- coderd/database/migrations/migrate_test.go | 65 +++++++++---------- 2 files changed, 29 insertions(+), 53 deletions(-) diff --git a/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql index 539af907d2452..51f4d6c26efd2 100644 --- a/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql +++ b/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql @@ -7,28 +7,11 @@ ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'mcp_server_config:delete'; ALTER TABLE mcp_server_configs ADD COLUMN organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE; -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM organizations WHERE is_default = true) THEN - RAISE EXCEPTION 'cannot scope mcp_server_configs: no default organization exists'; - END IF; -END $$; - -- The deployment-wide originals become the default organization's servers, -- credentials intact. Other organizations start with no MCP servers. UPDATE mcp_server_configs SET organization_id = (SELECT id FROM organizations WHERE is_default = true LIMIT 1); --- Chats outside the default organization referenced deployment-wide configs --- that now belong to the default organization and no longer resolve in the --- chat's organization. -UPDATE chats -SET mcp_server_ids = '{}' -WHERE organization_id != ( - SELECT id FROM organizations WHERE is_default = true LIMIT 1 -) - AND cardinality(mcp_server_ids) > 0; - ALTER TABLE mcp_server_configs ALTER COLUMN organization_id SET NOT NULL, DROP CONSTRAINT mcp_server_configs_slug_key, diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 7807cca104848..b7f45cbea4823 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -117,6 +117,21 @@ func testSQLDB(t testing.TB) *sql.DB { return db } +func stepMigrationsUpTo(t *testing.T, next func() (version uint, more bool, err error), target uint) { + t.Helper() + + for { + version, more, err := next() + require.NoError(t, err) + if !more { + t.Fatalf("migration %d not found", target) + } + if version == target { + return + } + } +} + // paralleltest linter doesn't correctly handle table-driven tests (https://github.com/kunwardeep/paralleltest/issues/8) // nolint:paralleltest func TestCheckLatestVersion(t *testing.T) { @@ -3001,16 +3016,7 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { sqlDB := testSQLDB(t) next, err := migrations.Stepper(sqlDB) require.NoError(t, err) - for { - version, more, err := next() - require.NoError(t, err) - if !more { - t.Fatalf("migration %d not found", priorMigrationVersion) - } - if version == priorMigrationVersion { - break - } - } + stepMigrationsUpTo(t, next, priorMigrationVersion) ctx := testutil.Context(t, testutil.WaitSuperLong) now := time.Now().UTC().Truncate(time.Microsecond) @@ -3141,24 +3147,15 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { `, uuid.New(), oauthConfigID, userID, keyDigest, now) require.NoError(t, err) - type chatSeed struct { - id uuid.UUID - organizationID uuid.UUID - configIDs []uuid.UUID - } - chats := []chatSeed{ - {id: uuid.New(), organizationID: defaultOrgID, configIDs: []uuid.UUID{configs[0].id, configs[1].id}}, - {id: uuid.New(), organizationID: otherOrgID, configIDs: []uuid.UUID{configs[1].id, configs[0].id}}, - } - for i, chat := range chats { - _, err = sqlDB.ExecContext(ctx, ` - INSERT INTO chats ( - id, owner_id, organization_id, last_model_config_id, title, - mcp_server_ids, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $7) - `, chat.id, userID, chat.organizationID, modelConfigID, fmt.Sprintf("Migration 568 Chat %d", i), pq.Array(chat.configIDs), now) - require.NoError(t, err) - } + chatID := uuid.New() + chatConfigIDs := []uuid.UUID{configs[1].id, configs[0].id} + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO chats ( + id, owner_id, organization_id, last_model_config_id, title, + mcp_server_ids, created_at, updated_at + ) VALUES ($1, $2, $3, $4, 'Migration 568 Chat', $5, $6, $6) + `, chatID, userID, otherOrgID, modelConfigID, pq.Array(chatConfigIDs), now) + require.NoError(t, err) version, _, err := next() require.NoError(t, err) @@ -3205,10 +3202,7 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { require.NoError(t, err) return ids } - require.Equal(t, chats[0].configIDs, getChatIDs(t, chats[0].id)) - // Chats outside the default organization lose their references because - // the configs now belong to the default organization only. - require.Empty(t, getChatIDs(t, chats[1].id)) + require.Equal(t, chatConfigIDs, getChatIDs(t, chatID)) // An organization-created config referenced by a chat exercises the down // sweep: the config is deleted and its chat references removed. @@ -3221,9 +3215,9 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { require.NoError(t, err) _, err = sqlDB.ExecContext(ctx, ` UPDATE chats SET mcp_server_ids = $2 WHERE id = $1 - `, chats[1].id, pq.Array([]uuid.UUID{orgLocalConfigID})) + `, chatID, pq.Array([]uuid.UUID{orgLocalConfigID})) require.NoError(t, err) - require.Equal(t, []uuid.UUID{orgLocalConfigID}, getChatIDs(t, chats[1].id)) + require.Equal(t, []uuid.UUID{orgLocalConfigID}, getChatIDs(t, chatID)) downSQL, err := os.ReadFile("000570_mcp_server_configs_organization_id.down.sql") require.NoError(t, err) @@ -3234,8 +3228,7 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { require.NoError(t, err) require.Equal(t, len(configs), totalConfigs) - require.Equal(t, chats[0].configIDs, getChatIDs(t, chats[0].id)) - require.Empty(t, getChatIDs(t, chats[1].id)) + require.Empty(t, getChatIDs(t, chatID)) var danglingIDs int err = sqlDB.QueryRowContext(ctx, ` SELECT COUNT(*) From ca286ffb3eedcc27a7e97f4a129c120029fefbbb Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:04:44 +0000 Subject: [PATCH 44/59] fix(site/src): scope MCP server API routes by organization --- site/src/api/api.ts | 20 ++++++++++++------ site/src/api/queries/chats.ts | 17 ++++++++++----- .../UpdateMCPServerPage.tsx | 9 ++++++-- .../AgentsPage/AgentChatPage.stories.tsx | 4 ++-- site/src/pages/AgentsPage/AgentChatPage.tsx | 12 +++++------ .../AgentsPage/AgentsPageLayout.stories.tsx | 10 ++++----- .../components/AgentChatInput.stories.tsx | 3 ++- .../AgentsPage/components/AgentChatInput.tsx | 9 +++++++- .../AgentsPage/components/AgentCreateForm.tsx | 13 ++++++------ .../components/MCPServerPicker.stories.tsx | 3 ++- .../AgentsPage/components/MCPServerPicker.tsx | 21 ++++++++++++++++++- 11 files changed, 85 insertions(+), 36 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 4b76e07a0f0a0..c8a54b696bf08 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -361,8 +361,12 @@ const userAIProviderKeysPath = (user = "me") => `/api/experimental/users/${encodeURIComponent(user)}/ai-provider-keys`; const mcpServerConfigsPath = (organization: string) => `/api/experimental/organizations/${encodeURIComponent(organization)}/mcp-servers`; -const mcpServerConfigPath = (id: string) => - `/api/experimental/mcp-servers/${encodeURIComponent(id)}`; +const mcpServerConfigPath = (organization: string, id: string) => + `${mcpServerConfigsPath(organization)}/${encodeURIComponent(id)}`; +export const mcpServerOAuth2ConnectPath = (organization: string, id: string) => + `${mcpServerConfigPath(organization, id)}/oauth2/connect`; +const mcpServerOAuth2DisconnectPath = (id: string) => + `/api/experimental/mcp/servers/${encodeURIComponent(id)}/oauth2/disconnect`; type Claims = { license_expires: number; @@ -3929,18 +3933,22 @@ class ExperimentalApiMethods { }; updateMCPServerConfig = async ( + organization: string, id: string, req: TypesGen.UpdateMCPServerConfigRequest, ): Promise => { const response = await this.axios.patch( - mcpServerConfigPath(id), + mcpServerConfigPath(organization, id), req, ); return response.data; }; - deleteMCPServerConfig = async (id: string): Promise => { - await this.axios.delete(mcpServerConfigPath(id)); + deleteMCPServerConfig = async ( + organization: string, + id: string, + ): Promise => { + await this.axios.delete(mcpServerConfigPath(organization, id)); }; disconnectMCPServerOAuth2 = async ( @@ -3948,7 +3956,7 @@ class ExperimentalApiMethods { ): Promise => { const response = await this.axios.delete( - `${mcpServerConfigPath(id)}/oauth2/disconnect`, + mcpServerOAuth2DisconnectPath(id), ); return response.data; }; diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 65504be5db460..d9f347d914279 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -2327,7 +2327,7 @@ export const updateChatModelOverride = ( // ── MCP Server Configs ─────────────────────────────────────── const mcpServersKey = ["mcp", "servers"] as const; -const mcpServerConfigsKey = (organization: string) => +export const mcpServerConfigsKey = (organization: string) => [...mcpServersKey, organization] as const; export const mcpServerConfigs = (organization: string) => ({ @@ -2356,16 +2356,23 @@ type UpdateMCPServerConfigMutationArgs = { req: TypesGen.UpdateMCPServerConfigRequest; }; -export const updateMCPServerConfig = (queryClient: QueryClient) => ({ +export const updateMCPServerConfig = ( + queryClient: QueryClient, + organization: string, +) => ({ mutationFn: ({ id, req }: UpdateMCPServerConfigMutationArgs) => - API.experimental.updateMCPServerConfig(id, req), + API.experimental.updateMCPServerConfig(organization, id, req), onSuccess: async () => { await invalidateMCPServerConfigQueries(queryClient); }, }); -export const deleteMCPServerConfig = (queryClient: QueryClient) => ({ - mutationFn: (id: string) => API.experimental.deleteMCPServerConfig(id), +export const deleteMCPServerConfig = ( + queryClient: QueryClient, + organization: string, +) => ({ + mutationFn: (id: string) => + API.experimental.deleteMCPServerConfig(organization, id), onSuccess: async () => { await invalidateMCPServerConfigQueries(queryClient); }, diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx index 68b34639d8f86..6289fbcc82103 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -29,9 +29,14 @@ const UpdateMCPServerPage: FC = () => { ...mcpServerConfigs(organization), enabled: Boolean(organization), }); - const updateMutation = useMutation(updateMCPServerConfig(queryClient)); - const deleteMutation = useMutation(deleteMCPServerConfig(queryClient)); const server = serversQuery.data?.find((item) => item.id === serverId); + const serverOrganization = server?.organization_id ?? organization; + const updateMutation = useMutation( + updateMCPServerConfig(queryClient, serverOrganization), + ); + const deleteMutation = useMutation( + deleteMCPServerConfig(queryClient, serverOrganization), + ); return ( diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index e35a0ca1f92cc..bada8299013dd 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -17,7 +17,7 @@ import { chatModelConfigs, chatModelsKey, chatPromptsKey, - mcpServerConfigs, + mcpServerConfigsKey, toChatListParams, } from "#/api/queries/chats"; import { workspaceByIdKey } from "#/api/queries/workspaces"; @@ -307,7 +307,7 @@ const buildQueries = ( { key: chatModelsKey, data: mockModelCatalog }, { key: chatModelConfigs().queryKey, data: mockModelConfigs }, { - key: mcpServerConfigs(chat.organization_id).queryKey, + key: mcpServerConfigsKey(chat.organization_id), data: opts?.mcpServers ?? [], }, buildChatAuthorizationQuery(chat, { diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 1ca9cecf020b0..bbe87ba9cfb1b 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -107,8 +107,8 @@ import { workspaceSkillsFromChat } from "./components/ChatPageContent"; import { getDefaultMCPSelection, getSavedMCPSelection, - migrateLegacyMCPSelection, saveMCPSelection, + useLegacyMCPSelectionMigration, } from "./components/MCPServerPicker"; import { getModelSelectorHelp } from "./components/ModelSelectorHelp"; import { useGitWatcher } from "./hooks/useGitWatcher"; @@ -932,11 +932,11 @@ const AgentChatPage: FC = () => { (organization) => organization.id === chatOrganizationId && organization.is_default, ); - useEffect(() => { - if (isDefaultChatOrganization && mcpServersQuery.data) { - migrateLegacyMCPSelection(chatOrganizationId, mcpServersQuery.data); - } - }, [chatOrganizationId, isDefaultChatOrganization, mcpServersQuery.data]); + useLegacyMCPSelectionMigration( + chatOrganizationId, + mcpServersQuery.data, + isDefaultChatOrganization, + ); const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 })); const workspaceOptions = getWorkspaceOptionsWithLinkedWorkspace( workspacesQuery.data?.workspaces ?? [], diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index 3025af644eccd..97ca4121941f2 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -74,14 +74,14 @@ const defaultModelConfigs: TypesGen.ChatModelConfig[] = [ }, ]; -const defaultOrganizationMCPServer: TypesGen.MCPServerConfig = { +const mockDefaultOrganizationMCPServer: TypesGen.MCPServerConfig = { ...MockMCPServerConfig, id: "mcp-default-organization", display_name: "Default organization MCP", slug: "default-organization-mcp", }; -const secondOrganizationMCPServer: TypesGen.MCPServerConfig = { +const mockSecondOrganizationMCPServer: TypesGen.MCPServerConfig = { ...MockMCPServerConfig, id: "mcp-second-organization", display_name: "Second organization MCP", @@ -467,7 +467,7 @@ export const OrganizationScopedMCPServers: Story = { queries: [ { key: permittedOrganizations({ - object: { resource_type: "chat" }, + object: { resource_type: "chat", owner_id: "me" }, action: "create", }).queryKey, data: [MockDefaultOrganization, MockOrganization2], @@ -478,8 +478,8 @@ export const OrganizationScopedMCPServers: Story = { spyOn(API.experimental, "getMCPServerConfigs").mockImplementation( async (organization) => organization === MockDefaultOrganization.id - ? [defaultOrganizationMCPServer] - : [secondOrganizationMCPServer], + ? [mockDefaultOrganizationMCPServer] + : [mockSecondOrganizationMCPServer], ); }, play: async ({ canvasElement }) => { diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index 93097b1d24e99..4c54454efe88e 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -751,6 +751,7 @@ const notionMCPConnected = buildMCPServer({ }); const mcpDefaults = { + chatOrganizationId: "org-1", onMCPSelectionChange: fn(), onMCPAuthComplete: fn(), }; @@ -782,7 +783,7 @@ export const WithMCPNeedingAuth: Story = { await userEvent.click(canvas.getByRole("button", { name: "More options" })); await userEvent.click(body.getByRole("button", { name: "Auth" })); expect(window.open).toHaveBeenCalledWith( - "/api/experimental/mcp-servers/mcp-github/oauth2/connect", + "/api/experimental/organizations/org-1/mcp-servers/mcp-github/oauth2/connect", "_blank", "width=900,height=600", ); diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index c518f2de749e6..40ac33424470c 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -24,6 +24,7 @@ import { import { useMutation, useQueryClient } from "react-query"; import { Link } from "react-router"; import { toast } from "sonner"; +import { mcpServerOAuth2ConnectPath } from "#/api/api"; import { getErrorMessage } from "#/api/errors"; import { disconnectMCPServerOAuth2 } from "#/api/queries/chats"; import type * as TypesGen from "#/api/typesGenerated"; @@ -553,8 +554,14 @@ export const AgentChatInput: FC = ({ }; const handleMcpConnect = (server: TypesGen.MCPServerConfig) => { + if (!chatOrganizationId) { + return; + } setMcpConnectingId(server.id); - const connectUrl = `/api/experimental/mcp-servers/${encodeURIComponent(server.id)}/oauth2/connect`; + const connectUrl = mcpServerOAuth2ConnectPath( + chatOrganizationId, + server.id, + ); mcpPopupRef.current = window.open( connectUrl, "_blank", diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 4540633005c36..a42ed0987bec0 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -35,8 +35,8 @@ import { CompactOrgSelector } from "./ChatElements"; import { getDefaultMCPSelection, getSavedMCPSelection, - migrateLegacyMCPSelection, saveMCPSelection, + useLegacyMCPSelectionMigration, } from "./MCPServerPicker"; import { getModelSelectorHelp } from "./ModelSelectorHelp"; @@ -421,11 +421,11 @@ export const AgentCreateForm: FC = ({ } return getDefaultMCPSelection(mcpServers); })(); - useEffect(() => { - if (effectiveOrg?.is_default) { - migrateLegacyMCPSelection(organizationId, mcpServers); - } - }, [organizationId, mcpServers, effectiveOrg?.is_default]); + useLegacyMCPSelectionMigration( + organizationId, + mcpServers, + effectiveOrg?.is_default ?? false, + ); const handleWorkspaceChange = (value: string | null) => { if (value === null) { setSelectedWorkspaceId(null); @@ -653,6 +653,7 @@ export const AgentCreateForm: FC = ({ previewUrls={previewUrls} textContents={textContents} mcpServers={mcpServers} + chatOrganizationId={organizationId} selectedMCPServerIds={effectiveMCPServerIds} onMCPSelectionChange={(ids) => { setUserMCPServerIds(ids); diff --git a/site/src/pages/AgentsPage/components/MCPServerPicker.stories.tsx b/site/src/pages/AgentsPage/components/MCPServerPicker.stories.tsx index 951d55d85f811..e3e7ea6d3372e 100644 --- a/site/src/pages/AgentsPage/components/MCPServerPicker.stories.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerPicker.stories.tsx @@ -118,6 +118,7 @@ const meta: Meta = { title: "pages/AgentsPage/MCPServerPicker", component: MCPServerPicker, args: { + organizationId: "org-1", onSelectionChange: fn(), onAuthComplete: fn(), }, @@ -192,7 +193,7 @@ export const OAuthNeedsAuth: Story = { body.getByRole("button", { name: "Authenticate with GitHub" }), ); expect(window.open).toHaveBeenCalledWith( - "/api/experimental/mcp-servers/mcp-github/oauth2/connect", + "/api/experimental/organizations/org-1/mcp-servers/mcp-github/oauth2/connect", "_blank", "width=900,height=600", ); diff --git a/site/src/pages/AgentsPage/components/MCPServerPicker.tsx b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx index 3b4f0c9d09cab..0a45c189e7c0c 100644 --- a/site/src/pages/AgentsPage/components/MCPServerPicker.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx @@ -1,5 +1,6 @@ import { ChevronDownIcon, LockIcon, ServerIcon } from "lucide-react"; import { type FC, useEffect, useRef, useState } from "react"; +import { mcpServerOAuth2ConnectPath } from "#/api/api"; import type * as TypesGen from "#/api/typesGenerated"; import { Button } from "#/components/Button/Button"; import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; @@ -21,6 +22,7 @@ import { cn } from "#/utils/cn"; // ── Types ────────────────────────────────────────────────────── interface MCPServerPickerProps { + organizationId: string; /** All MCP server configs from the API. Will be filtered to enabled only. */ servers: readonly TypesGen.MCPServerConfig[]; /** Currently selected server IDs. */ @@ -178,6 +180,22 @@ export const migrateLegacyMCPSelection = ( localStorage.removeItem(legacyMCPSelectionStorageKey); }; +/** + * Migrates the pre-org-scoping deployment-wide selection once onto the default + * organization's key, preserving upgraded users' MCP server selection. + */ +export const useLegacyMCPSelectionMigration = ( + organizationId: string, + servers: readonly TypesGen.MCPServerConfig[] | undefined, + isDefaultOrganization: boolean, +) => { + useEffect(() => { + if (isDefaultOrganization && servers) { + migrateLegacyMCPSelection(organizationId, servers); + } + }, [organizationId, servers, isDefaultOrganization]); +}; + // ── Overlapping icon stack for the trigger ───────────────────── const ICON_STACK_MAX = 3; @@ -215,6 +233,7 @@ const TriggerIconStack: FC<{ // ── Component ────────────────────────────────────────────────── export const MCPServerPicker: FC = ({ + organizationId, servers, selectedServerIds, onSelectionChange, @@ -285,7 +304,7 @@ export const MCPServerPicker: FC = ({ const handleConnect = (server: TypesGen.MCPServerConfig) => { setConnectingServerId(server.id); - const connectUrl = `/api/experimental/mcp-servers/${encodeURIComponent(server.id)}/oauth2/connect`; + const connectUrl = mcpServerOAuth2ConnectPath(organizationId, server.id); popupRef.current = window.open( connectUrl, "_blank", From dc5242d8e55d2a8c5b769c9907e555e08fe25622 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:59:35 +0000 Subject: [PATCH 45/59] chore(site/src/pages/AgentsPage): tighten the legacy MCP selection hook doc --- site/src/pages/AgentsPage/components/MCPServerPicker.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/MCPServerPicker.tsx b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx index 0a45c189e7c0c..0a68ea2ae03c3 100644 --- a/site/src/pages/AgentsPage/components/MCPServerPicker.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx @@ -181,8 +181,8 @@ export const migrateLegacyMCPSelection = ( }; /** - * Migrates the pre-org-scoping deployment-wide selection once onto the default - * organization's key, preserving upgraded users' MCP server selection. + * Legacy selections predate organization scoping, so only the default + * organization inherits them. */ export const useLegacyMCPSelectionMigration = ( organizationId: string, From d6f9a302ac93d5b9aa29f260df1e3afa6b55a4ab Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:20:35 +0000 Subject: [PATCH 46/59] fix(coderd/database): renumber MCP org migration to 000571 --- ...=> 000571_mcp_server_configs_organization_id.down.sql} | 0 ...l => 000571_mcp_server_configs_organization_id.up.sql} | 0 coderd/database/migrations/migrate_test.go | 8 ++++---- ...l => 000571_mcp_server_configs_organization_id.up.sql} | 0 4 files changed, 4 insertions(+), 4 deletions(-) rename coderd/database/migrations/{000570_mcp_server_configs_organization_id.down.sql => 000571_mcp_server_configs_organization_id.down.sql} (100%) rename coderd/database/migrations/{000570_mcp_server_configs_organization_id.up.sql => 000571_mcp_server_configs_organization_id.up.sql} (100%) rename coderd/database/migrations/testdata/fixtures/{000570_mcp_server_configs_organization_id.up.sql => 000571_mcp_server_configs_organization_id.up.sql} (100%) diff --git a/coderd/database/migrations/000570_mcp_server_configs_organization_id.down.sql b/coderd/database/migrations/000571_mcp_server_configs_organization_id.down.sql similarity index 100% rename from coderd/database/migrations/000570_mcp_server_configs_organization_id.down.sql rename to coderd/database/migrations/000571_mcp_server_configs_organization_id.down.sql diff --git a/coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000571_mcp_server_configs_organization_id.up.sql similarity index 100% rename from coderd/database/migrations/000570_mcp_server_configs_organization_id.up.sql rename to coderd/database/migrations/000571_mcp_server_configs_organization_id.up.sql diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index b7f45cbea4823..b94ebfd2a460e 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -3008,10 +3008,10 @@ func TestMigration000566OAuth2AuthMethodBackfill(t *testing.T) { "the backfill aligns the declaration to what is enforced, so the enforced value must be unchanged") } -func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { +func TestMigration000571MCPServerConfigsOrganizationID(t *testing.T) { t.Parallel() - const priorMigrationVersion = 569 + const priorMigrationVersion = 570 sqlDB := testSQLDB(t) next, err := migrations.Stepper(sqlDB) @@ -3159,7 +3159,7 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { version, _, err := next() require.NoError(t, err) - require.EqualValues(t, 570, version) + require.EqualValues(t, 571, version) var totalConfigs int err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_server_configs`).Scan(&totalConfigs) @@ -3219,7 +3219,7 @@ func TestMigration000570MCPServerConfigsOrganizationID(t *testing.T) { require.NoError(t, err) require.Equal(t, []uuid.UUID{orgLocalConfigID}, getChatIDs(t, chatID)) - downSQL, err := os.ReadFile("000570_mcp_server_configs_organization_id.down.sql") + downSQL, err := os.ReadFile("000571_mcp_server_configs_organization_id.down.sql") require.NoError(t, err) _, err = sqlDB.ExecContext(ctx, string(downSQL)) require.NoError(t, err) diff --git a/coderd/database/migrations/testdata/fixtures/000570_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/testdata/fixtures/000571_mcp_server_configs_organization_id.up.sql similarity index 100% rename from coderd/database/migrations/testdata/fixtures/000570_mcp_server_configs_organization_id.up.sql rename to coderd/database/migrations/testdata/fixtures/000571_mcp_server_configs_organization_id.up.sql From cbaedd0a41e309c1a33ac34685496980060f8b5e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:28:42 +0000 Subject: [PATCH 47/59] fix(coderd): invalidate grants on revocation URL changes --- coderd/mcp.go | 13 ++++--- coderd/mcp_test.go | 91 ++++++++++++++++++++++++++++++++-------------- 2 files changed, 70 insertions(+), 34 deletions(-) diff --git a/coderd/mcp.go b/coderd/mcp.go index 29c4e4209378a..a3a889d26c1ac 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -857,12 +857,12 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { } } - // User grants are bound to the destination, auth flow, token endpoint, - // and OAuth client the user authorized. Invalidate them when any of - // these change so stored tokens cannot be replayed against another - // endpoint or client (refresh posts the refresh token to token_url). + // User grants are bound to the destination, auth flow, token and revocation + // endpoints, and OAuth client. Invalidate them when any of these change so + // stored tokens cannot be sent to another endpoint or client. if serverURL != existing.Url || authType != existing.AuthType || - oauth2TokenURL != existing.OAuth2TokenURL || oauth2ClientID != existing.OAuth2ClientID { + oauth2TokenURL != existing.OAuth2TokenURL || oauth2RevocationURL != existing.OAuth2RevocationURL || + oauth2ClientID != existing.OAuth2ClientID { if err := tx.DeleteMCPServerUserTokensByConfigID(ctx, existing.ID); err != nil { return xerrors.Errorf("invalidate MCP server user tokens: %w", err) } @@ -1186,7 +1186,8 @@ func (api *API) mcpServerOAuth2Callback(rw http.ResponseWriter, r *http.Request) return xerrors.Errorf("re-read MCP server config: %w", err) } if current.Url != config.Url || current.AuthType != config.AuthType || - current.OAuth2TokenURL != config.OAuth2TokenURL || current.OAuth2ClientID != config.OAuth2ClientID { + current.OAuth2TokenURL != config.OAuth2TokenURL || current.OAuth2RevocationURL != config.OAuth2RevocationURL || + current.OAuth2ClientID != config.OAuth2ClientID { return errMCPConfigSupersededDuringAuth } //nolint:gocritic // Users store their own tokens. diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 1289d22605d08..06322d5ed0c82 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -578,18 +578,19 @@ func TestMCPServerConfigsUpdateInvalidatesUserGrants(t *testing.T) { newConfig := func(ctx context.Context, t *testing.T, slug string) codersdk.MCPServerConfig { t.Helper() created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ - DisplayName: "Grant Invalidation " + slug, - Slug: slug, - Transport: "streamable_http", - URL: "https://mcp.example.com/" + slug, - AuthType: "oauth2", - OAuth2ClientID: "cid", - OAuth2AuthURL: "https://auth.example.com/authorize", - OAuth2TokenURL: "https://auth.example.com/token", - Availability: "default_on", - Enabled: true, - ToolAllowList: []string{}, - ToolDenyList: []string{}, + DisplayName: "Grant Invalidation " + slug, + Slug: slug, + Transport: "streamable_http", + URL: "https://mcp.example.com/" + slug, + AuthType: "oauth2", + OAuth2ClientID: "cid", + OAuth2AuthURL: "https://auth.example.com/authorize", + OAuth2TokenURL: "https://auth.example.com/token", + OAuth2RevocationURL: "https://auth.example.com/revoke", + Availability: "default_on", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, }) require.NoError(t, err) return created @@ -665,6 +666,20 @@ func TestMCPServerConfigsUpdateInvalidatesUserGrants(t *testing.T) { require.False(t, tokenExists(ctx, t, config.ID)) }) + t.Run("RevocationURLChangeDeletesGrants", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + config := newConfig(ctx, t, "grant-revocation-url-change") + seedToken(ctx, t, config.ID) + + movedEndpoint := "https://auth.example.com/other-revocation-endpoint" + _, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{ + OAuth2RevocationURL: &movedEndpoint, + }) + require.NoError(t, err) + require.False(t, tokenExists(ctx, t, config.ID)) + }) + t.Run("ClientIDChangeDeletesGrants", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -699,6 +714,28 @@ func TestMCPServerConfigsUpdateInvalidatesUserGrants(t *testing.T) { func TestMCPServerConfigsOAuth2CallbackRejectsSupersededConfig(t *testing.T) { t.Parallel() + movedURL := "https://attacker.example.com/superseded" + runMCPServerConfigsOAuth2CallbackSupersessionTest(t, "superseded-callback", codersdk.UpdateMCPServerConfigRequest{ + URL: &movedURL, + }) +} + +func TestMCPServerConfigsOAuth2CallbackRejectsSupersededRevocationURL(t *testing.T) { + t.Parallel() + + movedRevocationURL := "https://attacker.example.com/revoke" + runMCPServerConfigsOAuth2CallbackSupersessionTest(t, "superseded-revocation-callback", codersdk.UpdateMCPServerConfigRequest{ + OAuth2RevocationURL: &movedRevocationURL, + }) +} + +func runMCPServerConfigsOAuth2CallbackSupersessionTest( + t *testing.T, + slug string, + updateRequest codersdk.UpdateMCPServerConfigRequest, +) { + t.Helper() + ctx := testutil.Context(t, testutil.WaitLong) providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t) adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ @@ -711,10 +748,7 @@ func TestMCPServerConfigsOAuth2CallbackRejectsSupersededConfig(t *testing.T) { var configID atomic.Pointer[uuid.UUID] tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { if id := configID.Load(); id != nil { - movedURL := "https://attacker.example.com/superseded" - _, err := adminClient.UpdateMCPServerConfig(ctx, firstUser.OrganizationID, *id, codersdk.UpdateMCPServerConfigRequest{ - URL: &movedURL, - }) + _, err := adminClient.UpdateMCPServerConfig(ctx, firstUser.OrganizationID, *id, updateRequest) assert.NoError(t, err) } w.Header().Set("Content-Type", "application/json") @@ -723,18 +757,19 @@ func TestMCPServerConfigsOAuth2CallbackRejectsSupersededConfig(t *testing.T) { t.Cleanup(tokenServer.Close) created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ - DisplayName: "Superseded Callback", - Slug: "superseded-callback", - Transport: "streamable_http", - URL: "https://mcp.example.com/superseded-callback", - AuthType: "oauth2", - OAuth2ClientID: "cid", - OAuth2AuthURL: "https://auth.example.com/authorize", - OAuth2TokenURL: tokenServer.URL + "/token", - Availability: "default_on", - Enabled: true, - ToolAllowList: []string{}, - ToolDenyList: []string{}, + DisplayName: "Superseded Callback " + slug, + Slug: slug, + Transport: "streamable_http", + URL: "https://mcp.example.com/" + slug, + AuthType: "oauth2", + OAuth2ClientID: "cid", + OAuth2AuthURL: "https://auth.example.com/authorize", + OAuth2TokenURL: tokenServer.URL + "/token", + OAuth2RevocationURL: "https://auth.example.com/revoke", + Availability: "default_on", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, }) require.NoError(t, err) configID.Store(&created.ID) From 2def75f93fb274e6ff37ce4c092e5064be85a0cf Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:15:09 +0000 Subject: [PATCH 48/59] fix(coderd): require config read for the full-view MCP list --- coderd/mcp.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/coderd/mcp.go b/coderd/mcp.go index a3a889d26c1ac..a50acd7f92953 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -157,7 +157,10 @@ func (api *API) listMCPServerConfigs(rw http.ResponseWriter, r *http.Request) { // Auditors get it to inspect audit-logged resources; their MCP config // read grant cannot select it because members hold the same read. // Other members see enabled configs with management fields redacted. - hasFullView := api.Authorize(r, policy.ActionUpdate, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) || + // The update leg also requires config read so a custom role granting + // update without read cannot lift the read filtering below. + hasFullView := (api.Authorize(r, policy.ActionRead, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) && + api.Authorize(r, policy.ActionUpdate, rbac.ResourceMCPServerConfig.InOrg(organization.ID))) || api.Authorize(r, policy.ActionRead, rbac.ResourceAuditLog.InOrg(organization.ID)) var configs []database.MCPServerConfig From 25d082e7f6c31ab371bb5c62cb1b5b8c4128eb9f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:42:55 +0000 Subject: [PATCH 49/59] fix(coderd): discover MCP OAuth before config creation --- coderd/database/dbauthz/dbauthz_test.go | 1 + coderd/database/dbgen/dbgen.go | 1 + coderd/database/queries.sql.go | 16 +- coderd/database/queries/mcpserverconfigs.sql | 2 + coderd/mcp.go | 149 ++++++------------ coderd/mcp_test.go | 82 ++++++++-- .../generation_preparer_internal_test.go | 1 + 7 files changed, 131 insertions(+), 121 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 7b30954f54ece..f3ebd3b07a57f 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1726,6 +1726,7 @@ func (s *MethodTestSuite) TestChats() { })) s.Run("InsertMCPServerConfig", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { arg := database.InsertMCPServerConfigParams{ + ID: uuid.New(), OrganizationID: uuid.New(), DisplayName: "Test MCP Server", Slug: "test-mcp-server", diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 973cd673a9308..2912e098f1192 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -348,6 +348,7 @@ func MCPServerConfig(t testing.TB, db database.Store, seed database.MCPServerCon } cfg, err := db.InsertMCPServerConfig(genCtx, database.InsertMCPServerConfigParams{ + ID: takeFirst(seed.ID, uuid.New()), OrganizationID: organizationID, DisplayName: takeFirst(seed.DisplayName, "Test MCP Server"), Slug: takeFirst(seed.Slug, testutil.GetRandomName(t)), diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 03709b3738ef6..4021d4b2e3a82 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -17661,6 +17661,7 @@ func (q *sqlQuerier) GetMCPServerUserTokensByUserID(ctx context.Context, userID const insertMCPServerConfig = `-- name: InsertMCPServerConfig :one INSERT INTO mcp_server_configs ( + id, organization_id, display_name, slug, @@ -17692,7 +17693,7 @@ INSERT INTO mcp_server_configs ( updated_by ) VALUES ( $1::uuid, - $2::text, + $2::uuid, $3::text, $4::text, $5::text, @@ -17711,21 +17712,23 @@ INSERT INTO mcp_server_configs ( $18::text, $19::text, $20::text, - $21::text[], + $21::text, $22::text[], - $23::text, - $24::boolean, + $23::text[], + $24::text, $25::boolean, $26::boolean, $27::boolean, - $28::uuid, - $29::uuid + $28::boolean, + $29::uuid, + $30::uuid ) RETURNING id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id ` type InsertMCPServerConfigParams struct { + ID uuid.UUID `db:"id" json:"id"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` DisplayName string `db:"display_name" json:"display_name"` Slug string `db:"slug" json:"slug"` @@ -17759,6 +17762,7 @@ type InsertMCPServerConfigParams struct { func (q *sqlQuerier) InsertMCPServerConfig(ctx context.Context, arg InsertMCPServerConfigParams) (MCPServerConfig, error) { row := q.db.QueryRowContext(ctx, insertMCPServerConfig, + arg.ID, arg.OrganizationID, arg.DisplayName, arg.Slug, diff --git a/coderd/database/queries/mcpserverconfigs.sql b/coderd/database/queries/mcpserverconfigs.sql index 1e3908bb44049..2d648809a9d64 100644 --- a/coderd/database/queries/mcpserverconfigs.sql +++ b/coderd/database/queries/mcpserverconfigs.sql @@ -73,6 +73,7 @@ ORDER BY -- name: InsertMCPServerConfig :one INSERT INTO mcp_server_configs ( + id, organization_id, display_name, slug, @@ -103,6 +104,7 @@ INSERT INTO mcp_server_configs ( created_by, updated_by ) VALUES ( + @id::uuid, @organization_id::uuid, @display_name::text, @slug::text, diff --git a/coderd/mcp.go b/coderd/mcp.go index a50acd7f92953..371eebdf7b85f 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -266,13 +266,8 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { // Metadata (RFC 9728) and Authorization Server Metadata // (RFC 8414), then register a client dynamically. if req.OAuth2ClientID == "" && req.OAuth2AuthURL == "" && req.OAuth2TokenURL == "" { - // Auto-discovery flow: we need the config ID first to - // build the correct callback URL. Insert the record - // with empty OAuth2 fields, perform discovery, then - // update. The flow also updates the row with discovered - // credentials and deletes it when discovery fails, so - // require those actions up front rather than inserting a - // row a create-only caller can neither finish nor remove. + // Automatic discovery registration deliberately requires full + // MCP server config management permissions. if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) || !api.Authorize(r, policy.ActionDelete, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) { httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ @@ -290,62 +285,8 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { return } - inserted, err := api.Database.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{ - OrganizationID: organization.ID, - DisplayName: strings.TrimSpace(req.DisplayName), - Slug: strings.TrimSpace(req.Slug), - Description: strings.TrimSpace(req.Description), - IconURL: strings.TrimSpace(req.IconURL), - Transport: strings.TrimSpace(req.Transport), - Url: strings.TrimSpace(req.URL), - AuthType: strings.TrimSpace(req.AuthType), - OAuth2ClientID: "", - OAuth2ClientSecret: "", - OAuth2ClientSecretKeyID: sql.NullString{}, - OAuth2AuthURL: "", - OAuth2TokenURL: "", - OAuth2RevocationURL: "", - OAuth2Scopes: "", - APIKeyHeader: strings.TrimSpace(req.APIKeyHeader), - APIKeyValue: strings.TrimSpace(req.APIKeyValue), - APIKeyValueKeyID: sql.NullString{}, - CustomHeaders: customHeadersJSON, - CustomHeadersKeyID: sql.NullString{}, - ToolAllowList: coalesceStringSlice(trimStringSlice(req.ToolAllowList)), - ToolDenyList: coalesceStringSlice(trimStringSlice(req.ToolDenyList)), - Availability: strings.TrimSpace(req.Availability), - Enabled: req.Enabled, - ModelIntent: req.ModelIntent, - AllowInPlanMode: req.AllowInPlanMode, - ForwardCoderHeaders: req.ForwardCoderHeaders, - CreatedBy: apiKey.UserID, - UpdatedBy: apiKey.UserID, - }) - if err != nil { - switch { - case database.IsUniqueViolation(err): - httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ - Message: "MCP server config already exists.", - Detail: err.Error(), - }) - return - case database.IsCheckViolation(err): - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid MCP server config.", - Detail: err.Error(), - }) - return - default: - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to create MCP server config.", - Detail: err.Error(), - }) - return - } - } - - // Now build the callback URL with the actual ID. - callbackURL := api.AccessURL.String() + mcpServerOAuth2CallbackPath(inserted.ID) + configID := uuid.New() + callbackURL := api.AccessURL.String() + mcpServerOAuth2CallbackPath(configID) // Discovery targets are attacker-influenced (the MCP // server URL and any endpoints or redirects it // advertises), so all discovery traffic goes through an @@ -354,15 +295,6 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { httpClient := newMCPDiscoveryHTTPClient(api.HTTPClient, api.MCPOAuth2DiscoveryAllowedIPRanges) result, err := discoverAndRegisterMCPOAuth2(ctx, httpClient, strings.TrimSpace(req.URL), callbackURL) if err != nil { - // Clean up: delete the partially created config. - deleteErr := api.Database.DeleteMCPServerConfigByID(ctx, inserted.ID) - if deleteErr != nil { - api.Logger.Warn(ctx, "failed to clean up MCP server config after OAuth2 discovery failure", - slog.F("config_id", inserted.ID), - slog.Error(deleteErr), - ) - } - api.Logger.Warn(ctx, "mcp oauth2 auto-discovery failed", slog.F("url", req.URL), slog.Error(err), @@ -397,16 +329,16 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { } } - // Update the record with discovered OAuth2 credentials. - updated, err := api.Database.UpdateMCPServerConfig(ctx, database.UpdateMCPServerConfigParams{ - ID: inserted.ID, - DisplayName: inserted.DisplayName, - Slug: inserted.Slug, - Description: inserted.Description, - IconURL: inserted.IconURL, - Transport: inserted.Transport, - Url: inserted.Url, - AuthType: inserted.AuthType, + inserted, err := api.Database.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{ + ID: configID, + OrganizationID: organization.ID, + DisplayName: strings.TrimSpace(req.DisplayName), + Slug: strings.TrimSpace(req.Slug), + Description: strings.TrimSpace(req.Description), + IconURL: strings.TrimSpace(req.IconURL), + Transport: strings.TrimSpace(req.Transport), + Url: strings.TrimSpace(req.URL), + AuthType: strings.TrimSpace(req.AuthType), OAuth2ClientID: result.clientID, OAuth2ClientSecret: result.clientSecret, OAuth2ClientSecretKeyID: sql.NullString{}, @@ -414,29 +346,45 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { OAuth2TokenURL: result.tokenURL, OAuth2RevocationURL: oauth2RevocationURL, OAuth2Scopes: oauth2Scopes, - APIKeyHeader: inserted.APIKeyHeader, - APIKeyValue: inserted.APIKeyValue, - APIKeyValueKeyID: inserted.APIKeyValueKeyID, - CustomHeaders: inserted.CustomHeaders, - CustomHeadersKeyID: inserted.CustomHeadersKeyID, - ToolAllowList: inserted.ToolAllowList, - ToolDenyList: inserted.ToolDenyList, - Availability: inserted.Availability, - Enabled: inserted.Enabled, - ModelIntent: inserted.ModelIntent, - AllowInPlanMode: inserted.AllowInPlanMode, - ForwardCoderHeaders: inserted.ForwardCoderHeaders, + APIKeyHeader: strings.TrimSpace(req.APIKeyHeader), + APIKeyValue: strings.TrimSpace(req.APIKeyValue), + APIKeyValueKeyID: sql.NullString{}, + CustomHeaders: customHeadersJSON, + CustomHeadersKeyID: sql.NullString{}, + ToolAllowList: coalesceStringSlice(trimStringSlice(req.ToolAllowList)), + ToolDenyList: coalesceStringSlice(trimStringSlice(req.ToolDenyList)), + Availability: strings.TrimSpace(req.Availability), + Enabled: req.Enabled, + ModelIntent: req.ModelIntent, + AllowInPlanMode: req.AllowInPlanMode, + ForwardCoderHeaders: req.ForwardCoderHeaders, + CreatedBy: apiKey.UserID, UpdatedBy: apiKey.UserID, }) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to update MCP server config with OAuth2 credentials.", - Detail: err.Error(), - }) - return + switch { + case database.IsUniqueViolation(err): + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "MCP server config already exists.", + Detail: err.Error(), + }) + return + case database.IsCheckViolation(err): + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid MCP server config.", + Detail: err.Error(), + }) + return + default: + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to create MCP server config.", + Detail: err.Error(), + }) + return + } } - httpapi.Write(ctx, rw, http.StatusCreated, convertMCPServerConfig(updated)) + httpapi.Write(ctx, rw, http.StatusCreated, convertMCPServerConfig(inserted)) return } else if req.OAuth2ClientID == "" || req.OAuth2AuthURL == "" || req.OAuth2TokenURL == "" { // Partial manual config: all three fields are required together. @@ -471,6 +419,7 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { } inserted, err := api.Database.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{ + ID: uuid.New(), OrganizationID: organization.ID, DisplayName: strings.TrimSpace(req.DisplayName), Slug: strings.TrimSpace(req.Slug), diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 06322d5ed0c82..8777bf9882cce 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -1280,6 +1280,17 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) + registrationStarted := make(chan struct{}) + completeRegistration := make(chan struct{}) + var blockRegistration sync.Once + var completeRegistrationOnce sync.Once + releaseRegistration := func() { + completeRegistrationOnce.Do(func() { + close(completeRegistration) + }) + } + t.Cleanup(releaseRegistration) + // Stand up a mock auth server that serves RFC 8414 metadata and // a RFC 7591 dynamic client registration endpoint. authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1300,6 +1311,10 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } + blockRegistration.Do(func() { + close(registrationStarted) + <-completeRegistration + }) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(`{ @@ -1331,23 +1346,50 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { })) t.Cleanup(mcpServer.Close) - client := newMCPClient(t) + providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), + ChatProviderAPIKeys: &providerKeys, + }) firstUser := coderdtest.CreateFirstUser(t, client) - // Create config with auth_type=oauth2 but no OAuth2 fields — - // the server should auto-discover them. - created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ - DisplayName: "Auto-Discovery Server", - Slug: "auto-discovery", - Transport: "streamable_http", - URL: mcpServer.URL + "/v1/mcp", - AuthType: "oauth2", - Availability: "default_on", - Enabled: true, - ToolAllowList: []string{}, - ToolDenyList: []string{}, + type createResult struct { + config codersdk.MCPServerConfig + err error + } + createdCh := make(chan createResult, 1) + go func() { + created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "Auto-Discovery Server", + Slug: "auto-discovery", + Transport: "streamable_http", + URL: mcpServer.URL + "/v1/mcp", + AuthType: "oauth2", + Availability: "default_on", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, + }) + createdCh <- createResult{config: created, err: err} + }() + + select { + case <-registrationStarted: + case <-time.After(testutil.WaitShort): + releaseRegistration() + t.Fatal("timed out waiting for dynamic client registration") + } + //nolint:gocritic // Verifying persisted state requires system access. + _, err := db.GetMCPServerConfigByOrganizationAndSlug(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerConfigByOrganizationAndSlugParams{ + OrganizationID: firstUser.OrganizationID, + Slug: "auto-discovery", }) - require.NoError(t, err) + releaseRegistration() + require.ErrorIs(t, err, sql.ErrNoRows) + + create := <-createdCh + require.NoError(t, create.err) + created := create.config require.Equal(t, "auto-discovered-client-id", created.OAuth2ClientID) require.True(t, created.HasOAuth2Secret) require.Equal(t, authServer.URL+"/authorize", created.OAuth2AuthURL) @@ -1865,7 +1907,11 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { })) t.Cleanup(mcpServer.Close) - client := newMCPClient(t) + providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), + ChatProviderAPIKeys: &providerKeys, + }) firstUser := coderdtest.CreateFirstUser(t, client) _, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ @@ -1884,6 +1930,12 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "auto-discovery failed") + //nolint:gocritic // Verifying persisted state requires system access. + _, err = db.GetMCPServerConfigByOrganizationAndSlug(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerConfigByOrganizationAndSlugParams{ + OrganizationID: firstUser.OrganizationID, + Slug: "discovery-fail", + }) + require.ErrorIs(t, err, sql.ErrNoRows) }) t.Run("ManualConfigStillWorks", func(t *testing.T) { diff --git a/coderd/x/chatd/generation_preparer_internal_test.go b/coderd/x/chatd/generation_preparer_internal_test.go index 6698949d4b86b..400c66217a006 100644 --- a/coderd/x/chatd/generation_preparer_internal_test.go +++ b/coderd/x/chatd/generation_preparer_internal_test.go @@ -681,6 +681,7 @@ func TestEnabledMCPServerConfigsForChatOrg(t *testing.T) { chatOrg := dbgen.Organization(t, db, database.Organization{}) user := dbgen.User(t, db, database.User{}) disabledCfg, err := db.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{ + ID: uuid.New(), OrganizationID: chatOrg.ID, DisplayName: "Disabled MCP Server", Slug: testutil.GetRandomName(t), From 43acab989d0c850302a01db17b14d841a8125a0c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:59:46 +0000 Subject: [PATCH 50/59] refactor(coderd): share MCP config insert path --- coderd/mcp.go | 79 ++++++---------------------------------------- coderd/mcp_test.go | 22 ++++--------- 2 files changed, 16 insertions(+), 85 deletions(-) diff --git a/coderd/mcp.go b/coderd/mcp.go index 371eebdf7b85f..29b8f9736a5c4 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -256,6 +256,8 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { } } + configID := uuid.New() + // Validate auth-type-dependent fields. switch req.AuthType { case "oauth2": @@ -266,8 +268,6 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { // Metadata (RFC 9728) and Authorization Server Metadata // (RFC 8414), then register a client dynamically. if req.OAuth2ClientID == "" && req.OAuth2AuthURL == "" && req.OAuth2TokenURL == "" { - // Automatic discovery registration deliberately requires full - // MCP server config management permissions. if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) || !api.Authorize(r, policy.ActionDelete, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) { httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ @@ -276,16 +276,6 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { }) return } - customHeadersJSON, err := marshalCustomHeaders(req.CustomHeaders) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid custom headers.", - Detail: err.Error(), - }) - return - } - - configID := uuid.New() callbackURL := api.AccessURL.String() + mcpServerOAuth2CallbackPath(configID) // Discovery targets are attacker-influenced (the MCP // server URL and any endpoints or redirects it @@ -329,63 +319,12 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { } } - inserted, err := api.Database.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{ - ID: configID, - OrganizationID: organization.ID, - DisplayName: strings.TrimSpace(req.DisplayName), - Slug: strings.TrimSpace(req.Slug), - Description: strings.TrimSpace(req.Description), - IconURL: strings.TrimSpace(req.IconURL), - Transport: strings.TrimSpace(req.Transport), - Url: strings.TrimSpace(req.URL), - AuthType: strings.TrimSpace(req.AuthType), - OAuth2ClientID: result.clientID, - OAuth2ClientSecret: result.clientSecret, - OAuth2ClientSecretKeyID: sql.NullString{}, - OAuth2AuthURL: result.authURL, - OAuth2TokenURL: result.tokenURL, - OAuth2RevocationURL: oauth2RevocationURL, - OAuth2Scopes: oauth2Scopes, - APIKeyHeader: strings.TrimSpace(req.APIKeyHeader), - APIKeyValue: strings.TrimSpace(req.APIKeyValue), - APIKeyValueKeyID: sql.NullString{}, - CustomHeaders: customHeadersJSON, - CustomHeadersKeyID: sql.NullString{}, - ToolAllowList: coalesceStringSlice(trimStringSlice(req.ToolAllowList)), - ToolDenyList: coalesceStringSlice(trimStringSlice(req.ToolDenyList)), - Availability: strings.TrimSpace(req.Availability), - Enabled: req.Enabled, - ModelIntent: req.ModelIntent, - AllowInPlanMode: req.AllowInPlanMode, - ForwardCoderHeaders: req.ForwardCoderHeaders, - CreatedBy: apiKey.UserID, - UpdatedBy: apiKey.UserID, - }) - if err != nil { - switch { - case database.IsUniqueViolation(err): - httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ - Message: "MCP server config already exists.", - Detail: err.Error(), - }) - return - case database.IsCheckViolation(err): - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid MCP server config.", - Detail: err.Error(), - }) - return - default: - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to create MCP server config.", - Detail: err.Error(), - }) - return - } - } - - httpapi.Write(ctx, rw, http.StatusCreated, convertMCPServerConfig(inserted)) - return + req.OAuth2ClientID = result.clientID + req.OAuth2ClientSecret = result.clientSecret + req.OAuth2AuthURL = result.authURL + req.OAuth2TokenURL = result.tokenURL + req.OAuth2RevocationURL = oauth2RevocationURL + req.OAuth2Scopes = oauth2Scopes } else if req.OAuth2ClientID == "" || req.OAuth2AuthURL == "" || req.OAuth2TokenURL == "" { // Partial manual config: all three fields are required together. httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -419,7 +358,7 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { } inserted, err := api.Database.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{ - ID: uuid.New(), + ID: configID, OrganizationID: organization.ID, DisplayName: strings.TrimSpace(req.DisplayName), Slug: strings.TrimSpace(req.Slug), diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 8777bf9882cce..0c55eda9e4a5e 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -1289,7 +1289,6 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { close(completeRegistration) }) } - t.Cleanup(releaseRegistration) // Stand up a mock auth server that serves RFC 8414 metadata and // a RFC 7591 dynamic client registration endpoint. @@ -1358,7 +1357,7 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { err error } createdCh := make(chan createResult, 1) - go func() { + testutil.Go(t, func() { created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Auto-Discovery Server", Slug: "auto-discovery", @@ -1371,14 +1370,10 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { ToolDenyList: []string{}, }) createdCh <- createResult{config: created, err: err} - }() + }) + t.Cleanup(releaseRegistration) - select { - case <-registrationStarted: - case <-time.After(testutil.WaitShort): - releaseRegistration() - t.Fatal("timed out waiting for dynamic client registration") - } + testutil.TryReceive(ctx, t, registrationStarted) //nolint:gocritic // Verifying persisted state requires system access. _, err := db.GetMCPServerConfigByOrganizationAndSlug(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerConfigByOrganizationAndSlugParams{ OrganizationID: firstUser.OrganizationID, @@ -1387,7 +1382,7 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { releaseRegistration() require.ErrorIs(t, err, sql.ErrNoRows) - create := <-createdCh + create := testutil.RequireReceive(ctx, t, createdCh) require.NoError(t, create.err) created := create.config require.Equal(t, "auto-discovered-client-id", created.OAuth2ClientID) @@ -1429,11 +1424,8 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { }) firstUser := coderdtest.CreateFirstUser(t, client) - // Discovery inserts, then updates and possibly deletes the - // row. A create-only caller must be rejected before the - // insert instead of leaving an orphaned row behind. MCP - // config scopes are not user-mintable, so seed the scoped - // key directly. + // MCP config scopes are not user-mintable, so seed a create-only key + // to verify automatic discovery requires full management access. _, token := dbgen.APIKey(t, db, database.APIKey{ UserID: firstUser.UserID, Scopes: database.APIKeyScopes{ From 985aaeb748901fe79130a82627bcaea0ee5c53b3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:28:19 +0000 Subject: [PATCH 51/59] fix(coderd): avoid orphaned MCP OAuth clients --- coderd/mcp.go | 23 ++++++++--- coderd/mcp_test.go | 96 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 97 insertions(+), 22 deletions(-) diff --git a/coderd/mcp.go b/coderd/mcp.go index 29b8f9736a5c4..1e653dc16a9d4 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -268,14 +268,27 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { // Metadata (RFC 9728) and Authorization Server Metadata // (RFC 8414), then register a client dynamically. if req.OAuth2ClientID == "" && req.OAuth2AuthURL == "" && req.OAuth2TokenURL == "" { - if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) || - !api.Authorize(r, policy.ActionDelete, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) { - httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ - Message: "OAuth2 auto-discovery requires permission to update and delete MCP server configs.", - Detail: "Provide oauth2_client_id, oauth2_auth_url, and oauth2_token_url manually, or use credentials with broader MCP server config permissions.", + // Create-only callers cannot read configs. This pre-DCR check reveals nothing + // beyond the insert's conflict response, which remains authoritative for races. + //nolint:gocritic // Restrict system access to this existence check. + _, err := api.Database.GetMCPServerConfigByOrganizationAndSlug(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerConfigByOrganizationAndSlugParams{ + OrganizationID: organization.ID, + Slug: strings.TrimSpace(req.Slug), + }) + switch { + case err == nil: + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "MCP server config already exists.", + }) + return + case !errors.Is(err, sql.ErrNoRows): + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to create MCP server config.", + Detail: err.Error(), }) return } + callbackURL := api.AccessURL.String() + mcpServerOAuth2CallbackPath(configID) // Discovery targets are attacker-influenced (the MCP // server URL and any endpoints or redirects it diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 0c55eda9e4a5e..75a108b80b74b 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -73,6 +73,50 @@ func createMCPServerConfig(t testing.TB, client *codersdk.Client, organizationID return config } +func newMCPDiscoveryServer(t testing.TB, registrationRequests *atomic.Int64) string { + t.Helper() + + authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/oauth-authorization-server": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "issuer": "` + "http://" + r.Host + `", + "authorization_endpoint": "` + "http://" + r.Host + `/authorize", + "token_endpoint": "` + "http://" + r.Host + `/token", + "registration_endpoint": "` + "http://" + r.Host + `/register", + "response_types_supported": ["code"] + }`)) + case "/register": + registrationRequests.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{ + "client_id": "discovered-client-id", + "client_secret": "discovered-client-secret" + }`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(authServer.Close) + + mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/oauth-protected-resource/mcp": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "resource": "` + "http://" + r.Host + `/mcp", + "authorization_servers": ["` + authServer.URL + `"] + }`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(mcpServer.Close) + return mcpServer.URL + "/mcp" +} + func TestMCPServerConfigLegacyRoutesRemoved(t *testing.T) { t.Parallel() @@ -1409,11 +1453,7 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { require.Equal(t, "https://override.example.com/revoke", overridden.OAuth2RevocationURL) }) - // Verify that when both path-aware and root-level protected - // resource metadata are available, the path-aware URL takes - // priority. Each points to a different auth server so we can - // distinguish which one was actually used. - t.Run("CreateOnlyScopeRejectedUpFront", func(t *testing.T) { + t.Run("CreateOnlyScopeAllowed", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -1424,8 +1464,7 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { }) firstUser := coderdtest.CreateFirstUser(t, client) - // MCP config scopes are not user-mintable, so seed a create-only key - // to verify automatic discovery requires full management access. + // MCP config scopes are not user-mintable, so seed a create-only key. _, token := dbgen.APIKey(t, db, database.APIKey{ UserID: firstUser.UserID, Scopes: database.APIKeyScopes{ @@ -1435,28 +1474,51 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { }) scopedClient := codersdk.New(client.URL) scopedClient.SetSessionToken(token) + var registrationRequests atomic.Int64 - _, err := scopedClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ + created, err := scopedClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Create Only Discovery", Slug: "create-only-discovery", Transport: "streamable_http", - URL: "http://127.0.0.1:1", + URL: newMCPDiscoveryServer(t, ®istrationRequests), AuthType: "oauth2", Availability: "default_on", Enabled: true, }) - var sdkErr *codersdk.Error - require.ErrorAs(t, err, &sdkErr) - require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + require.NoError(t, err) + require.Equal(t, "discovered-client-id", created.OAuth2ClientID) + require.EqualValues(t, 1, registrationRequests.Load()) + }) - //nolint:gocritic // Verifying persisted state requires system access. - _, err = db.GetMCPServerConfigByOrganizationAndSlug(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerConfigByOrganizationAndSlugParams{ - OrganizationID: firstUser.OrganizationID, - Slug: "create-only-discovery", + t.Run("ExistingSlugSkipsRegistration", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newMCPClient(t) + firstUser := coderdtest.CreateFirstUser(t, client) + createMCPServerConfig(t, client, firstUser.OrganizationID, "discovery-conflict", true) + var registrationRequests atomic.Int64 + + _, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "Discovery Conflict", + Slug: " discovery-conflict ", + Transport: "streamable_http", + URL: newMCPDiscoveryServer(t, ®istrationRequests), + AuthType: "oauth2", + Availability: "default_on", + Enabled: true, }) - require.ErrorIs(t, err, sql.ErrNoRows) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusConflict, sdkErr.StatusCode()) + require.Equal(t, "MCP server config already exists.", sdkErr.Message) + require.Zero(t, registrationRequests.Load()) }) + // Verify that when both path-aware and root-level protected + // resource metadata are available, the path-aware URL takes + // priority. Each points to a different auth server so we can + // distinguish which one was actually used. t.Run("PathAwareTakesPriority", func(t *testing.T) { t.Parallel() From bc64b4ffd34bae1578d39a9651d691c095753a44 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:40:28 +0000 Subject: [PATCH 52/59] test(coderd): preserve MCP comment scope --- coderd/mcp_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 75a108b80b74b..8f8f8d859a602 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -1453,6 +1453,10 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { require.Equal(t, "https://override.example.com/revoke", overridden.OAuth2RevocationURL) }) + // Verify that when both path-aware and root-level protected + // resource metadata are available, the path-aware URL takes + // priority. Each points to a different auth server so we can + // distinguish which one was actually used. t.Run("CreateOnlyScopeAllowed", func(t *testing.T) { t.Parallel() @@ -1515,10 +1519,6 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { require.Zero(t, registrationRequests.Load()) }) - // Verify that when both path-aware and root-level protected - // resource metadata are available, the path-aware URL takes - // priority. Each points to a different auth server so we can - // distinguish which one was actually used. t.Run("PathAwareTakesPriority", func(t *testing.T) { t.Parallel() From d5ae8c07a3e47d1bc3cd9b1b4654ec4a757b2bcb Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:42:10 +0000 Subject: [PATCH 53/59] test(coderd): remove redundant MCP test comment --- coderd/mcp_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 8f8f8d859a602..e303294471b23 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -1453,10 +1453,6 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { require.Equal(t, "https://override.example.com/revoke", overridden.OAuth2RevocationURL) }) - // Verify that when both path-aware and root-level protected - // resource metadata are available, the path-aware URL takes - // priority. Each points to a different auth server so we can - // distinguish which one was actually used. t.Run("CreateOnlyScopeAllowed", func(t *testing.T) { t.Parallel() From 3004c3987c8a87b313a8b69b3eb41e3505d13748 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:13:10 +0000 Subject: [PATCH 54/59] docs(coderd/x/chatd): add architecture TODO for MCP org scoping --- coderd/x/chatd/ARCHITECTURE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index d9f1e16f8f683..b146449ddb758 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -846,6 +846,7 @@ The generation goroutine supports: - chat compaction (automatic and manual, see [Manual compaction](#manual-compaction)) - MCP tools + - TODO(human): document that effective MCP server configs are now scoped to the chat's organization (generation preparation only loads configs from the chat's organization). - file links - workspace binding - plan mode From 3b064b67dbf517a3f7b88f8a39349fdc26652700 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:33:29 +0200 Subject: [PATCH 55/59] refactor(site/src/pages/AgentsPage): heal legacy MCP selection lazily on read Replace the eager migrateLegacyMCPSelection/useLegacyMCPSelectionMigration effect pair with a self-healing read: when getSavedMCPSelection falls back to the legacy unscoped localStorage key (default organization only), the parsed selection is rewritten under the organization-scoped key and the legacy entry is removed. The healing is skipped while the server list is empty, since saved IDs cannot be validated yet, and never runs for non-default organizations. Callers no longer need migration wiring. --- site/src/pages/AgentsPage/AgentChatPage.tsx | 6 -- .../AgentsPage/components/AgentCreateForm.tsx | 6 -- .../components/MCPServerPicker.test.ts | 67 ++++++++++++++++--- .../AgentsPage/components/MCPServerPicker.tsx | 46 +++---------- 4 files changed, 67 insertions(+), 58 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index bbe87ba9cfb1b..9d2b6dabe856d 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -108,7 +108,6 @@ import { getDefaultMCPSelection, getSavedMCPSelection, saveMCPSelection, - useLegacyMCPSelectionMigration, } from "./components/MCPServerPicker"; import { getModelSelectorHelp } from "./components/ModelSelectorHelp"; import { useGitWatcher } from "./hooks/useGitWatcher"; @@ -932,11 +931,6 @@ const AgentChatPage: FC = () => { (organization) => organization.id === chatOrganizationId && organization.is_default, ); - useLegacyMCPSelectionMigration( - chatOrganizationId, - mcpServersQuery.data, - isDefaultChatOrganization, - ); const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 })); const workspaceOptions = getWorkspaceOptionsWithLinkedWorkspace( workspacesQuery.data?.workspaces ?? [], diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index a42ed0987bec0..ef910038639c3 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -36,7 +36,6 @@ import { getDefaultMCPSelection, getSavedMCPSelection, saveMCPSelection, - useLegacyMCPSelectionMigration, } from "./MCPServerPicker"; import { getModelSelectorHelp } from "./ModelSelectorHelp"; @@ -421,11 +420,6 @@ export const AgentCreateForm: FC = ({ } return getDefaultMCPSelection(mcpServers); })(); - useLegacyMCPSelectionMigration( - organizationId, - mcpServers, - effectiveOrg?.is_default ?? false, - ); const handleWorkspaceChange = (value: string | null) => { if (value === null) { setSelectedWorkspaceId(null); diff --git a/site/src/pages/AgentsPage/components/MCPServerPicker.test.ts b/site/src/pages/AgentsPage/components/MCPServerPicker.test.ts index 232157d161df5..c54cd654d1d4f 100644 --- a/site/src/pages/AgentsPage/components/MCPServerPicker.test.ts +++ b/site/src/pages/AgentsPage/components/MCPServerPicker.test.ts @@ -5,7 +5,6 @@ import { getDefaultMCPSelection, getSavedMCPSelection, mcpSelectionStorageKey, - migrateLegacyMCPSelection, saveMCPSelection, } from "./MCPServerPicker"; @@ -84,7 +83,7 @@ describe("MCP selection persistence", () => { expect(getSavedMCPSelection(organizationId, servers)).toBeNull(); }); - it("migrates a legacy selection for the default organization", () => { + it("heals a legacy selection into the organization-scoped key on read", () => { localStorage.setItem( "agents.selected-mcp-server-ids", JSON.stringify(["s3"]), @@ -94,30 +93,76 @@ describe("MCP selection persistence", () => { "s3", "s1", ]); - expect( - localStorage.getItem(mcpSelectionStorageKey(organizationId)), - ).toBeNull(); - - migrateLegacyMCPSelection(organizationId, servers); - expect(localStorage.getItem(mcpSelectionStorageKey(organizationId))).toBe( JSON.stringify(["s3", "s1"]), ); expect(localStorage.getItem("agents.selected-mcp-server-ids")).toBeNull(); + // Subsequent reads come from the scoped key. + expect(getSavedMCPSelection(organizationId, servers)).toEqual([ + "s3", + "s1", + ]); }); - it("migrates an empty legacy selection without enabling default-on servers", () => { + it("heals an empty legacy selection without enabling default-on servers", () => { localStorage.setItem("agents.selected-mcp-server-ids", "[]"); expect(getSavedMCPSelection(organizationId, servers, true)).toEqual([ "s1", ]); - migrateLegacyMCPSelection(organizationId, servers); - expect(localStorage.getItem(mcpSelectionStorageKey(organizationId))).toBe( JSON.stringify(["s1"]), ); + expect(localStorage.getItem("agents.selected-mcp-server-ids")).toBeNull(); + }); + + it("leaves the legacy selection alone while the server list is empty", () => { + localStorage.setItem( + "agents.selected-mcp-server-ids", + JSON.stringify(["s3"]), + ); + + expect(getSavedMCPSelection(organizationId, [], true)).toBeNull(); + + expect( + localStorage.getItem(mcpSelectionStorageKey(organizationId)), + ).toBeNull(); + expect(localStorage.getItem("agents.selected-mcp-server-ids")).toBe( + JSON.stringify(["s3"]), + ); + }); + + it("does not read or heal the legacy selection for non-default organizations", () => { + localStorage.setItem( + "agents.selected-mcp-server-ids", + JSON.stringify(["s3"]), + ); + + expect(getSavedMCPSelection(organizationId, servers)).toBeNull(); + + expect( + localStorage.getItem(mcpSelectionStorageKey(organizationId)), + ).toBeNull(); + expect(localStorage.getItem("agents.selected-mcp-server-ids")).toBe( + JSON.stringify(["s3"]), + ); + }); + + it("prefers the scoped key over a lingering legacy selection", () => { + saveMCPSelection(organizationId, ["s2"]); + localStorage.setItem( + "agents.selected-mcp-server-ids", + JSON.stringify(["s3"]), + ); + + expect(getSavedMCPSelection(organizationId, servers, true)).toEqual([ + "s2", + "s1", + ]); + expect(localStorage.getItem("agents.selected-mcp-server-ids")).toBe( + JSON.stringify(["s3"]), + ); }); it("restores saved IDs that still exist as enabled servers", () => { diff --git a/site/src/pages/AgentsPage/components/MCPServerPicker.tsx b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx index 0a68ea2ae03c3..a145f13a6e8f9 100644 --- a/site/src/pages/AgentsPage/components/MCPServerPicker.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx @@ -103,14 +103,21 @@ export const mcpSelectionStorageKey = (organizationId: string) => * Read the persisted MCP selection from localStorage, filtered to only * include IDs that still exist in the current server list. * Returns `null` when nothing is stored (caller should fall back to defaults). + * + * When `readLegacy` is set (the default organization inherits selections + * that predate organization scoping), a selection found under the legacy + * unscoped key is rewritten under the organization-scoped key, so the + * storage schema heals itself on first successful read. */ export const getSavedMCPSelection = ( organizationId: string, servers: readonly TypesGen.MCPServerConfig[], readLegacy = false, ): string[] | null => { let raw = localStorage.getItem(mcpSelectionStorageKey(organizationId)); + let fromLegacy = false; if (raw === null && readLegacy) { raw = localStorage.getItem(legacyMCPSelectionStorageKey); + fromLegacy = raw !== null; } if (raw === null) { return null; @@ -145,6 +152,10 @@ export const mcpSelectionStorageKey = (organizationId: string) => restored.push(id); } } + if (fromLegacy) { + saveMCPSelection(organizationId, restored); + localStorage.removeItem(legacyMCPSelectionStorageKey); + } return restored; } catch { return null; @@ -161,41 +172,6 @@ export const saveMCPSelection = ( ); }; -export const migrateLegacyMCPSelection = ( - organizationId: string, - servers: readonly TypesGen.MCPServerConfig[], -): void => { - const storageKey = mcpSelectionStorageKey(organizationId); - if ( - localStorage.getItem(storageKey) !== null || - localStorage.getItem(legacyMCPSelectionStorageKey) === null - ) { - return; - } - const selection = getSavedMCPSelection(organizationId, servers, true); - if (selection === null) { - return; - } - saveMCPSelection(organizationId, selection); - localStorage.removeItem(legacyMCPSelectionStorageKey); -}; - -/** - * Legacy selections predate organization scoping, so only the default - * organization inherits them. - */ -export const useLegacyMCPSelectionMigration = ( - organizationId: string, - servers: readonly TypesGen.MCPServerConfig[] | undefined, - isDefaultOrganization: boolean, -) => { - useEffect(() => { - if (isDefaultOrganization && servers) { - migrateLegacyMCPSelection(organizationId, servers); - } - }, [organizationId, servers, isDefaultOrganization]); -}; - // ── Overlapping icon stack for the trigger ───────────────────── const ICON_STACK_MAX = 3; From 5f4b4da26c4c6a6183fe4b7ac1e0acc8760ce2df Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:51:31 +0000 Subject: [PATCH 56/59] fix(coderd/database): renumber MCP org migration to 000574 --- ...=> 000574_mcp_server_configs_organization_id.down.sql} | 0 ...l => 000574_mcp_server_configs_organization_id.up.sql} | 0 coderd/database/migrations/migrate_test.go | 8 ++++---- ...l => 000574_mcp_server_configs_organization_id.up.sql} | 0 4 files changed, 4 insertions(+), 4 deletions(-) rename coderd/database/migrations/{000571_mcp_server_configs_organization_id.down.sql => 000574_mcp_server_configs_organization_id.down.sql} (100%) rename coderd/database/migrations/{000571_mcp_server_configs_organization_id.up.sql => 000574_mcp_server_configs_organization_id.up.sql} (100%) rename coderd/database/migrations/testdata/fixtures/{000571_mcp_server_configs_organization_id.up.sql => 000574_mcp_server_configs_organization_id.up.sql} (100%) diff --git a/coderd/database/migrations/000571_mcp_server_configs_organization_id.down.sql b/coderd/database/migrations/000574_mcp_server_configs_organization_id.down.sql similarity index 100% rename from coderd/database/migrations/000571_mcp_server_configs_organization_id.down.sql rename to coderd/database/migrations/000574_mcp_server_configs_organization_id.down.sql diff --git a/coderd/database/migrations/000571_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000574_mcp_server_configs_organization_id.up.sql similarity index 100% rename from coderd/database/migrations/000571_mcp_server_configs_organization_id.up.sql rename to coderd/database/migrations/000574_mcp_server_configs_organization_id.up.sql diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index b94ebfd2a460e..c9d7515473286 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -3008,10 +3008,10 @@ func TestMigration000566OAuth2AuthMethodBackfill(t *testing.T) { "the backfill aligns the declaration to what is enforced, so the enforced value must be unchanged") } -func TestMigration000571MCPServerConfigsOrganizationID(t *testing.T) { +func TestMigration000574MCPServerConfigsOrganizationID(t *testing.T) { t.Parallel() - const priorMigrationVersion = 570 + const priorMigrationVersion = 573 sqlDB := testSQLDB(t) next, err := migrations.Stepper(sqlDB) @@ -3159,7 +3159,7 @@ func TestMigration000571MCPServerConfigsOrganizationID(t *testing.T) { version, _, err := next() require.NoError(t, err) - require.EqualValues(t, 571, version) + require.EqualValues(t, 574, version) var totalConfigs int err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_server_configs`).Scan(&totalConfigs) @@ -3219,7 +3219,7 @@ func TestMigration000571MCPServerConfigsOrganizationID(t *testing.T) { require.NoError(t, err) require.Equal(t, []uuid.UUID{orgLocalConfigID}, getChatIDs(t, chatID)) - downSQL, err := os.ReadFile("000571_mcp_server_configs_organization_id.down.sql") + downSQL, err := os.ReadFile("000574_mcp_server_configs_organization_id.down.sql") require.NoError(t, err) _, err = sqlDB.ExecContext(ctx, string(downSQL)) require.NoError(t, err) diff --git a/coderd/database/migrations/testdata/fixtures/000571_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/testdata/fixtures/000574_mcp_server_configs_organization_id.up.sql similarity index 100% rename from coderd/database/migrations/testdata/fixtures/000571_mcp_server_configs_organization_id.up.sql rename to coderd/database/migrations/testdata/fixtures/000574_mcp_server_configs_organization_id.up.sql From caa6aeaaae0b292b9202ce8dfa3b1a8aaf92a5fc Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:51:45 +0000 Subject: [PATCH 57/59] fix(coderd/x/chatd): scope tool search test MCP configs to the chat org --- coderd/x/chatd/chatd_test.go | 66 ++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index fcbe238f7bc9a..c623356d351ad 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -10467,11 +10467,12 @@ func TestMCPToolSearchGenerationFlows(t *testing.T) { }) user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) mcpConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "Search MCP", - Slug: "search-mcp", - Url: mcpTS.URL, - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "Search MCP", + Slug: "search-mcp", + Url: mcpTS.URL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) @@ -10549,11 +10550,12 @@ func TestMCPToolSearchGenerationFlows(t *testing.T) { }) user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) mcpConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "Direct MCP", - Slug: "direct-mcp", - Url: mcpTS.URL, - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "Direct MCP", + Slug: "direct-mcp", + Url: mcpTS.URL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) @@ -10609,11 +10611,12 @@ func TestMCPToolSearchGenerationFlows(t *testing.T) { }) user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) mcpConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "Count MCP", - Slug: "count-mcp", - Url: mcpTS.URL, - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "Count MCP", + Slug: "count-mcp", + Url: mcpTS.URL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) reg := prometheus.NewRegistry() server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { @@ -10666,11 +10669,12 @@ func TestMCPToolSearchGenerationFlows(t *testing.T) { }) user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) mcpConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "Hooked MCP", - Slug: "hooked-mcp", - Url: mcpTS.URL, - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "Hooked MCP", + Slug: "hooked-mcp", + Url: mcpTS.URL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) reg := prometheus.NewRegistry() server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { @@ -10727,11 +10731,12 @@ func TestMCPToolSearchGenerationFlows(t *testing.T) { t.Cleanup(consumer.Close) user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) mcpConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "Failing MCP", - Slug: "failing-mcp", - Url: mcpTS.URL, - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "Failing MCP", + Slug: "failing-mcp", + Url: mcpTS.URL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) reg := prometheus.NewRegistry() server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { @@ -10783,11 +10788,12 @@ func TestMCPToolSearchGenerationFlows(t *testing.T) { model.ContextLimit = 100_000 model = updateChatModelContextLimit(t, db, model) mcpConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ - DisplayName: "Small MCP", - Slug: "small-mcp", - Url: mcpTS.URL, - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + OrganizationID: org.ID, + DisplayName: "Small MCP", + Slug: "small-mcp", + Url: mcpTS.URL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, }) server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) From 88d8e93a62c8a90519e08246292f660beba01553 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:52:31 +0000 Subject: [PATCH 58/59] docs(docs/ai-coder/agents/platform-controls): note user_oidc needs deployment permission --- docs/ai-coder/agents/platform-controls/mcp-servers.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/ai-coder/agents/platform-controls/mcp-servers.md b/docs/ai-coder/agents/platform-controls/mcp-servers.md index a61f19a9b35cd..a44319a19d011 100644 --- a/docs/ai-coder/agents/platform-controls/mcp-servers.md +++ b/docs/ai-coder/agents/platform-controls/mcp-servers.md @@ -174,5 +174,7 @@ wins. | View enabled servers | Organization member | | OAuth2 connect and disconnect | Organization member | +Creating or updating a server with `auth_type` set to `user_oidc` also requires the `deployment_config:update` permission. + Members only see enabled servers in their own organizations. Sensitive fields such as API keys and client secrets are redacted in API responses. From 14fd3a22538c08c1a7cba6bfb3bb6ff4b2382260 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:27:07 +0000 Subject: [PATCH 59/59] Revert "docs(coderd/x/chatd): add architecture TODO for MCP org scoping" This reverts commit 0181a29f837d80703f42de6f7522d3026a4c55c5. --- coderd/x/chatd/ARCHITECTURE.md | 1 - 1 file changed, 1 deletion(-) diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index b146449ddb758..d9f1e16f8f683 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -846,7 +846,6 @@ The generation goroutine supports: - chat compaction (automatic and manual, see [Manual compaction](#manual-compaction)) - MCP tools - - TODO(human): document that effective MCP server configs are now scoped to the chat's organization (generation preparation only loads configs from the chat's organization). - file links - workspace binding - plan mode