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/coderd.go b/coderd/coderd.go index 3f9d3babbf8ac..e423045b8f6c8 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1378,6 +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("/organizations", func(r chi.Router) { + r.Use(apiKeyMiddleware) + 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("/chats", func(r chi.Router) { r.Use( apiKeyMiddleware, @@ -1499,20 +1516,11 @@ 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) + // 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/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 743e51538b9bb..9a17d6588e560 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}, }), User: []rbac.Permission{}, @@ -2283,7 +2284,11 @@ func (q *querier) DeleteLicense(ctx context.Context, id int32) (int32, error) { } func (q *querier) DeleteMCPServerConfigByID(ctx context.Context, id uuid.UUID) error { - 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) @@ -2296,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 @@ -3763,11 +3779,12 @@ 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.ResourceDeploymentConfig); 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) +} + +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 @@ -3842,11 +3859,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.ResourceDeploymentConfig); err != nil { - return nil, err - } - return q.db.GetForcedMCPServerConfigs(ctx) +func (q *querier) GetForcedMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetForcedMCPServerConfigsByOrganization)(ctx, organizationID) } func (q *querier) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) { @@ -4048,31 +4062,23 @@ func (q *querier) GetLogoURL(ctx context.Context) (string, error) { } func (q *querier) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (database.MCPServerConfig, error) { - 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) GetMCPServerConfigBySlug(ctx context.Context, slug string) (database.MCPServerConfig, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return database.MCPServerConfig{}, err - } - return q.db.GetMCPServerConfigBySlug(ctx, slug) +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) GetMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return nil, err - } - return q.db.GetMCPServerConfigs(ctx) +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) GetMCPServerConfigsByIDs(ctx context.Context, ids []uuid.UUID) ([]database.MCPServerConfig, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return nil, err +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("prepare sql filter: %w", err) } - return q.db.GetMCPServerConfigsByIDs(ctx, ids) + return q.db.GetAuthorizedMCPServerConfigs(ctx, organizationID, prepared) } func (q *querier) GetMCPServerUserToken(ctx context.Context, arg database.GetMCPServerUserTokenParams) (database.MCPServerUserToken, error) { @@ -6194,7 +6200,7 @@ func (q *querier) InsertLicense(ctx context.Context, arg database.InsertLicenseP } func (q *querier) InsertMCPServerConfig(ctx context.Context, arg database.InsertMCPServerConfigParams) (database.MCPServerConfig, error) { - 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) @@ -7661,7 +7667,11 @@ func (q *querier) UpdateInboxNotificationReadStatus(ctx context.Context, args da } func (q *querier) UpdateMCPServerConfig(ctx context.Context, arg database.UpdateMCPServerConfigParams) (database.MCPServerConfig, error) { - 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) @@ -9395,3 +9405,7 @@ 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, 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 38d8435e71bd2..f3ebd3b07a57f 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,41 +1646,68 @@ 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.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.MCPServerConfig{configA, configB}) + 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("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}) + 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(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("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(), + 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(config, 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("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() - check.Args().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{}) - 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}) + 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(), 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) { + 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("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().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) { arg := database.GetMCPServerUserTokenParams{ @@ -1698,12 +1726,14 @@ 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", + ID: uuid.New(), + 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{}) @@ -1754,8 +1784,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{}) @@ -7587,6 +7618,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/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index df4f2cc2afb43..2912e098f1192 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,8 @@ 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)), Description: seed.Description, diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 66d40bbb23959..2c1614ed4c5aa 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) @@ -2009,11 +2017,19 @@ 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.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 +} + +func (m queryMetricsStore) GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx context.Context, arg database.GetEnabledMCPServerConfigsByOrganizationAndIDsParams) ([]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.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 } @@ -2081,11 +2097,11 @@ func (m queryMetricsStore) GetFilteredInboxNotificationsByUserID(ctx context.Con return r0, r1 } -func (m queryMetricsStore) GetForcedMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { +func (m queryMetricsStore) GetForcedMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]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() + 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 } @@ -2321,27 +2337,27 @@ 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) GetMCPServerConfigByIDForUpdate(ctx context.Context, id uuid.UUID) (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.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) GetMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { +func (m queryMetricsStore) GetMCPServerConfigByOrganizationAndSlug(ctx context.Context, arg database.GetMCPServerConfigByOrganizationAndSlugParams) (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() + 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 } -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 } @@ -6792,3 +6808,11 @@ 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, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) { + start := time.Now() + 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 01d8960437173..729e58cf05188 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() @@ -2505,6 +2519,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, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) { + m.ctrl.T.Helper() + 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, organizationID, prepared any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedMCPServerConfigs", reflect.TypeOf((*MockStore)(nil).GetAuthorizedMCPServerConfigs), ctx, organizationID, 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() @@ -3735,19 +3764,34 @@ 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, "GetEnabledMCPServerConfigsByOrganization", ctx, organizationID) + ret0, _ := ret[0].([]database.MCPServerConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// 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, "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, "GetEnabledMCPServerConfigs", ctx) + ret := m.ctrl.Call(m, "GetEnabledMCPServerConfigsByOrganizationAndIDs", ctx, arg) 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 { +// 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, "GetEnabledMCPServerConfigs", reflect.TypeOf((*MockStore)(nil).GetEnabledMCPServerConfigs), ctx) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEnabledMCPServerConfigsByOrganizationAndIDs", reflect.TypeOf((*MockStore)(nil).GetEnabledMCPServerConfigsByOrganizationAndIDs), ctx, arg) } // GetExternalAgentTokensByTemplateID mocks base method. @@ -3870,19 +3914,19 @@ 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) { +// 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, "GetForcedMCPServerConfigs", ctx) + ret := m.ctrl.Call(m, "GetForcedMCPServerConfigsByOrganization", ctx, organizationID) 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 { +// 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, "GetForcedMCPServerConfigs", reflect.TypeOf((*MockStore)(nil).GetForcedMCPServerConfigs), ctx) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetForcedMCPServerConfigsByOrganization", reflect.TypeOf((*MockStore)(nil).GetForcedMCPServerConfigsByOrganization), ctx, organizationID) } // GetGitSSHKey mocks base method. @@ -4320,49 +4364,49 @@ 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) { +// 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, "GetMCPServerConfigBySlug", ctx, slug) + ret := m.ctrl.Call(m, "GetMCPServerConfigByIDForUpdate", ctx, id) 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 { +// 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, "GetMCPServerConfigBySlug", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigBySlug), ctx, slug) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigByIDForUpdate", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigByIDForUpdate), ctx, id) } -// GetMCPServerConfigs mocks base method. -func (m *MockStore) GetMCPServerConfigs(ctx context.Context) ([]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, "GetMCPServerConfigs", ctx) - ret0, _ := ret[0].([]database.MCPServerConfig) + ret := m.ctrl.Call(m, "GetMCPServerConfigByOrganizationAndSlug", ctx, arg) + 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 { +// 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, "GetMCPServerConfigs", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigs), ctx) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerConfigByOrganizationAndSlug", reflect.TypeOf((*MockStore)(nil).GetMCPServerConfigByOrganizationAndSlug), ctx, arg) } -// 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) } // GetMCPServerUserToken mocks base method. diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index ee444df91696b..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 ( @@ -2514,6 +2519,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 +4410,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 +4878,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 +5304,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/000574_mcp_server_configs_organization_id.down.sql b/coderd/database/migrations/000574_mcp_server_configs_organization_id.down.sql new file mode 100644 index 0000000000000..8915142fa9cdd --- /dev/null +++ b/coderd/database/migrations/000574_mcp_server_configs_organization_id.down.sql @@ -0,0 +1,16 @@ +-- 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 +); + +DROP INDEX idx_mcp_server_configs_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/000574_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/000574_mcp_server_configs_organization_id.up.sql new file mode 100644 index 0000000000000..51f4d6c26efd2 --- /dev/null +++ b/coderd/database/migrations/000574_mcp_server_configs_organization_id.up.sql @@ -0,0 +1,21 @@ +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'; + +ALTER TABLE mcp_server_configs + ADD COLUMN organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE; + +-- 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 + 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/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index df0c7d14ea9bb..c9d7515473286 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) { @@ -2992,3 +3007,235 @@ 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 TestMigration000574MCPServerConfigsOrganizationID(t *testing.T) { + t.Parallel() + + const priorMigrationVersion = 573 + + sqlDB := testSQLDB(t) + next, err := migrations.Stepper(sqlDB) + require.NoError(t, err) + stepMigrationsUpTo(t, next, priorMigrationVersion) + + 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) + + 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, ` + INSERT INTO users ( + id, username, email, hashed_password, created_at, updated_at, + status, rbac_roles, login_type + ) VALUES ($1, 'migration-568-user', 'migration-568@example.com', ''::bytea, $2, $2, 'active', '{}', 'password') + `, userID, now) + require.NoError(t, err) + + const keyDigest = "migration-568-key" + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO dbcrypt_keys (number, active_key_digest, test) + VALUES (568000, $1, 'migration-568-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-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-568-model', 'Migration 568 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-568-none", authType: "none", apiKeyHeader: "Authorization", customHeaders: "{}"}, + // Every credential column carries ciphertext to prove the backfill + // leaves rows byte-identical apart from organization_id. + { + id: uuid.New(), + slug: "migration-568-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: "X-API-Key", + apiKeyValue: "api-key-ciphertext", + apiKeyValueKeyID: keyID, + customHeaders: "custom-headers-ciphertext", + customHeadersKeyID: keyID, + }, + } + + 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 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, + 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[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, + 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) + + 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) + require.EqualValues(t, 574, 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), totalConfigs) + + 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 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 + } + 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. + 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) + _, err = sqlDB.ExecContext(ctx, ` + UPDATE chats SET mcp_server_ids = $2 WHERE id = $1 + `, chatID, pq.Array([]uuid.UUID{orgLocalConfigID})) + require.NoError(t, err) + require.Equal(t, []uuid.UUID{orgLocalConfigID}, getChatIDs(t, chatID)) + + 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) + + err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM mcp_server_configs`).Scan(&totalConfigs) + require.NoError(t, err) + require.Equal(t, len(configs), totalConfigs) + + require.Empty(t, getChatIDs(t, chatID)) + 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/coderd/database/migrations/testdata/fixtures/000574_mcp_server_configs_organization_id.up.sql b/coderd/database/migrations/testdata/fixtures/000574_mcp_server_configs_organization_id.up.sql new file mode 100644 index 0000000000000..0b8795d7186a3 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000574_mcp_server_configs_organization_id.up.sql @@ -0,0 +1,149 @@ +-- 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, + 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/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..dbdaa647ea03e 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,74 @@ 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, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]MCPServerConfig, error) +} + +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 { + return nil, xerrors.Errorf("compile authorized filter: %w", err) + } + + 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, 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 + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/coderd/database/models.go b/coderd/database/models.go index cff3b4c6bf446..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, } } @@ -5455,6 +5470,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..fbb499a0f5450 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 @@ -554,7 +555,8 @@ 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) + 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 @@ -579,7 +581,7 @@ 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) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) @@ -645,9 +647,9 @@ 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) - GetMCPServerConfigs(ctx context.Context) ([]MCPServerConfig, error) - GetMCPServerConfigsByIDs(ctx context.Context, ids []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) 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..4021d4b2e3a82 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -17133,19 +17133,32 @@ func (q *sqlQuerier) DeleteMCPServerUserToken(ctx context.Context, arg DeleteMCP return err } -const getEnabledMCPServerConfigs = `-- name: GetEnabledMCPServerConfigs :many +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 + 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 + organization_id = $1::uuid + AND enabled = TRUE ORDER BY display_name ASC ` -func (q *sqlQuerier) GetEnabledMCPServerConfigs(ctx context.Context) ([]MCPServerConfig, error) { - rows, err := q.db.QueryContext(ctx, getEnabledMCPServerConfigs) +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 } @@ -17185,6 +17198,7 @@ func (q *sqlQuerier) GetEnabledMCPServerConfigs(ctx context.Context) ([]MCPServe &i.AllowInPlanMode, &i.ForwardCoderHeaders, &i.OAuth2RevocationURL, + &i.OrganizationID, ); err != nil { return nil, err } @@ -17199,20 +17213,95 @@ func (q *sqlQuerier) GetEnabledMCPServerConfigs(ctx context.Context) ([]MCPServe return items, nil } -const getForcedMCPServerConfigs = `-- name: GetForcedMCPServerConfigs :many +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 + 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 + 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 +FROM + mcp_server_configs +WHERE + organization_id = $1::uuid + AND enabled = TRUE AND availability = 'force_on' ORDER BY display_name ASC ` -func (q *sqlQuerier) GetForcedMCPServerConfigs(ctx context.Context) ([]MCPServerConfig, error) { - rows, err := q.db.QueryContext(ctx, getForcedMCPServerConfigs) +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 } @@ -17252,6 +17341,7 @@ func (q *sqlQuerier) GetForcedMCPServerConfigs(ctx context.Context) ([]MCPServer &i.AllowInPlanMode, &i.ForwardCoderHeaders, &i.OAuth2RevocationURL, + &i.OrganizationID, ); err != nil { return nil, err } @@ -17268,7 +17358,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 +17400,23 @@ 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 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 + 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 + id = $1::uuid +FOR UPDATE ` -func (q *sqlQuerier) GetMCPServerConfigBySlug(ctx context.Context, slug string) (MCPServerConfig, error) { - row := q.db.QueryRowContext(ctx, getMCPServerConfigBySlug, slug) +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, @@ -17358,87 +17450,81 @@ 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 +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 -ORDER BY - display_name ASC +WHERE + organization_id = $1::uuid + AND slug = $2::text ` -func (q *sqlQuerier) GetMCPServerConfigs(ctx context.Context) ([]MCPServerConfig, error) { - rows, err := q.db.QueryContext(ctx, getMCPServerConfigs) - 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, - ); 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 +type GetMCPServerConfigByOrganizationAndSlugParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Slug string `db:"slug" json:"slug"` } -const getMCPServerConfigsByIDs = `-- name: GetMCPServerConfigsByIDs :many +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, + &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 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 + 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[]) + organization_id = $1::uuid + -- Authorize Filter clause will be injected below in GetAuthorizedMCPServerConfigs + -- @authorize_filter 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)) +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 } @@ -17478,6 +17564,7 @@ func (q *sqlQuerier) GetMCPServerConfigsByIDs(ctx context.Context, ids []uuid.UU &i.AllowInPlanMode, &i.ForwardCoderHeaders, &i.OAuth2RevocationURL, + &i.OrganizationID, ); err != nil { return nil, err } @@ -17574,6 +17661,8 @@ func (q *sqlQuerier) GetMCPServerUserTokensByUserID(ctx context.Context, userID const insertMCPServerConfig = `-- name: InsertMCPServerConfig :one INSERT INTO mcp_server_configs ( + id, + organization_id, display_name, slug, description, @@ -17603,8 +17692,8 @@ INSERT INTO mcp_server_configs ( created_by, updated_by ) VALUES ( - $1::text, - $2::text, + $1::uuid, + $2::uuid, $3::text, $4::text, $5::text, @@ -17622,21 +17711,25 @@ INSERT INTO mcp_server_configs ( $17::text, $18::text, $19::text, - $20::text[], - $21::text[], - $22::text, - $23::boolean, - $24::boolean, + $20::text, + $21::text, + $22::text[], + $23::text[], + $24::text, $25::boolean, $26::boolean, - $27::uuid, - $28::uuid + $27::boolean, + $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 + 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"` Description string `db:"description" json:"description"` @@ -17669,6 +17762,8 @@ 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, arg.Description, @@ -17731,6 +17826,7 @@ func (q *sqlQuerier) InsertMCPServerConfig(ctx context.Context, arg InsertMCPSer &i.AllowInPlanMode, &i.ForwardCoderHeaders, &i.OAuth2RevocationURL, + &i.OrganizationID, ) return i, err } @@ -17818,7 +17914,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 +18012,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..2d648809a9d64 100644 --- a/coderd/database/queries/mcpserverconfigs.sql +++ b/coderd/database/queries/mcpserverconfigs.sql @@ -6,55 +6,75 @@ FROM WHERE id = @id::uuid; --- name: GetMCPServerConfigBySlug :one +-- name: GetMCPServerConfigByIDForUpdate :one SELECT * FROM mcp_server_configs WHERE - slug = @slug::text; + id = @id::uuid +FOR UPDATE; + +-- name: GetMCPServerConfigByOrganizationAndSlug :one +SELECT + * +FROM + mcp_server_configs +WHERE + organization_id = @organization_id::uuid + AND slug = @slug::text; --- name: GetMCPServerConfigs :many +-- name: GetMCPServerConfigsByOrganization :many SELECT * FROM mcp_server_configs +WHERE + 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 + organization_id = @organization_id::uuid + AND enabled = TRUE ORDER BY display_name ASC; --- name: GetMCPServerConfigsByIDs :many +-- name: GetEnabledMCPServerConfigsByOrganizationAndIDs :many SELECT * FROM mcp_server_configs WHERE - id = ANY(@ids::uuid[]) + organization_id = @organization_id::uuid + AND id = ANY(@ids::uuid[]) + AND enabled = TRUE ORDER BY display_name ASC; --- name: GetForcedMCPServerConfigs :many +-- name: GetForcedMCPServerConfigsByOrganization :many SELECT * FROM mcp_server_configs WHERE - enabled = TRUE + 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 ( + id, + organization_id, display_name, slug, description, @@ -84,6 +104,8 @@ INSERT INTO mcp_server_configs ( created_by, updated_by ) VALUES ( + @id::uuid, + @organization_id::uuid, @display_name::text, @slug::text, @description::text, @@ -257,6 +279,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/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..c2c8b8b07d204 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -1244,6 +1244,57 @@ func (api *API) validateExplicitChatModelConfigAvailable( return status, resp } +func validateChatMCPServerIDs( + ctx context.Context, + db database.Store, + organizationID uuid.UUID, + ids []uuid.UUID, +) (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) + } + if len(unique) == 0 { + return unique, nil, nil + } + + configs, err := db.GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx, database.GetEnabledMCPServerConfigsByOrganizationAndIDsParams{ + OrganizationID: organizationID, + IDs: unique, + }) + if err != nil { + return nil, nil, xerrors.Errorf("get enabled MCP server configs for organization: %w", err) + } + + valid := make(map[uuid.UUID]struct{}, len(configs)) + for _, config := range configs { + valid[config.ID] = struct{}{} + } + invalid = make([]uuid.UUID, 0, len(unique)-len(valid)) + for _, id := range unique { + if _, ok := valid[id]; !ok { + invalid = append(invalid, id) + } + } + 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 @@ -1337,34 +1388,18 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { return } - // Validate MCP server IDs exist. - if len(req.MCPServerIDs) > 0 { - //nolint:gocritic // Need to validate MCP server IDs exist. - existingConfigs, err := api.Database.GetMCPServerConfigsByIDs(dbauthz.AsSystemRestricted(ctx), 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 { + httpapi.Write(ctx, rw, http.StatusBadRequest, invalidChatMCPServerIDsResponse(invalidMCPServerIDs)) + return } mcpServerIDs := req.MCPServerIDs @@ -2735,10 +2770,8 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { return } - // Validate MCP server IDs exist. - 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) + 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.", @@ -2746,21 +2779,24 @@ 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 + // 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) } - 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, ", ")), - }) + } + 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 da0edd73215cd..87e9bca91714a 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -570,6 +570,249 @@ func TestPostChats(t *testing.T) { })) }) + t.Run("MCPServerIDsCrossOrgRejected", 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) + + 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) + + _, 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}, + }) + 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("MCPServerIDsDuplicatesNormalized", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + secondOrg := dbgen.Organization(t, db, database.Organization{}) + orgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: secondOrg.ID, + Enabled: true, + }) + 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{orgConfig.ID, orgConfig.ID}, + }) + require.NoError(t, err) + require.Equal(t, []uuid.UUID{orgConfig.ID}, chat.MCPServerIDs) + + _, 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{orgConfig.ID, orgConfig.ID}, + }) + 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("MCPServerIDsDisabledConfigRejected", 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) + + enabledCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: firstUser.OrganizationID, + Enabled: true, + }) + disabledCfg, err := client.Client.UpdateMCPServerConfig(ctx, enabledCfg.OrganizationID, enabledCfg.ID, codersdk.UpdateMCPServerConfigRequest{ + Enabled: ptr.Ref(false), + }) + require.NoError(t, err) + + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + _, err = memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + 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) + + 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) + + _, err = memberClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "selecting the disabled config", + }, + }, + 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) + }) + + 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.OrganizationID, 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.OrganizationID, 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() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + 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 or disabled.", sdkErr.Message) + require.Equal(t, "Invalid IDs: "+thirdOrgConfig.ID.String(), sdkErr.Detail) + }) + t.Run("MemberWithoutAgentsAccess", func(t *testing.T) { t.Parallel() @@ -1142,7 +1385,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/httpmw/mcpserverconfigparam.go b/coderd/httpmw/mcpserverconfigparam.go new file mode 100644 index 0000000000000..0ea4a1eeed85c --- /dev/null +++ b/coderd/httpmw/mcpserverconfigparam.go @@ -0,0 +1,57 @@ +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 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) { + 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 + } + 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 f9c90b01b6bbf..1e653dc16a9d4 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -151,19 +151,24 @@ 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) - - // 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 := httpmw.OrganizationParam(r) + + // 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. + // 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 var err error - if isAdmin { - configs, err = api.Database.GetMCPServerConfigs(ctx) + if hasFullView { + 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 +181,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{ @@ -204,7 +209,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) @@ -226,7 +231,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 } @@ -236,6 +242,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{ @@ -246,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": @@ -256,74 +268,28 @@ 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. - customHeadersJSON, err := marshalCustomHeaders(req.CustomHeaders) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid custom headers.", + // 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 } - inserted, err := api.Database.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{ - 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 := fmt.Sprintf("%s/api/experimental/mcp/servers/%s/oauth2/callback", api.AccessURL.String(), inserted.ID) + 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 @@ -332,15 +298,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), @@ -375,47 +332,12 @@ 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, - OAuth2ClientID: result.clientID, - OAuth2ClientSecret: result.clientSecret, - OAuth2ClientSecretKeyID: sql.NullString{}, - OAuth2AuthURL: result.authURL, - 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, - 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 - } - - httpapi.Write(ctx, rw, http.StatusCreated, convertMCPServerConfig(updated)) - 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{ @@ -449,6 +371,8 @@ 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), @@ -512,40 +436,18 @@ 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 - } - - 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 - } - } - 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(), - }) + // 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) @@ -554,26 +456,52 @@ 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) } +var errUserOIDCRequiresDeploymentPerms = xerrors.New("managing user_oidc MCP server configs requires deployment-level permissions") + +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. +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) + if !api.Authorize(r, action, config) { + httpapi.Forbidden(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. @@ -582,12 +510,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 } @@ -636,10 +559,20 @@ 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) + // 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 } + existing = current + + 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 { @@ -828,7 +761,18 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { } } - updated, err = tx.UpdateMCPServerConfig(ctx, database.UpdateMCPServerConfigParams{ + // 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 || 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) + } + } + + updatedConfig, err := tx.UpdateMCPServerConfig(ctx, database.UpdateMCPServerConfigParams{ DisplayName: displayName, Slug: slug, Description: description, @@ -858,10 +802,19 @@ 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 { + 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 @@ -894,29 +847,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(), @@ -935,25 +871,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() - - mcpServerID, ok := parseMCPServerConfigID(rw, r) - 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 - } + config := httpmw.MCPServerConfigParam(r) if !config.Enabled { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -980,7 +898,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, @@ -1035,18 +953,13 @@ func (api *API) mcpServerOAuth2Callback(rw http.ResponseWriter, r *http.Request) 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) + config, err := api.Database.GetMCPServerConfigByID(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(), - }) + httpapi.InternalServerError(rw, err) return } @@ -1099,7 +1012,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: "", @@ -1168,17 +1081,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: mcpServerID, - 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 { + // 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) + } + if current.Url != config.Url || current.AuthType != config.AuthType || + current.OAuth2TokenURL != config.OAuth2TokenURL || current.OAuth2RevocationURL != config.OAuth2RevocationURL || + 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.", @@ -1210,8 +1145,8 @@ func (api *API) mcpServerOAuth2Disconnect(rw http.ResponseWriter, r *http.Reques ctx := r.Context() apiKey := httpmw.APIKey(r) - mcpServerID, ok := parseMCPServerConfigID(rw, r) - if !ok { + configID, parsed := httpmw.ParseUUIDParam(rw, r, "mcpServer") + if !parsed { return } @@ -1224,20 +1159,23 @@ func (api *API) mcpServerOAuth2Disconnect(rw http.ResponseWriter, r *http.Reques // 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: 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. - dbConfig, err := tx.GetMCPServerConfigByID(systemCtx, mcpServerID) + // 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: mcpServerID, + MCPServerConfigID: configID, UserID: apiKey.UserID, }); err != nil { return err @@ -1418,8 +1356,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 { @@ -1437,11 +1385,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_test.go b/coderd/mcp_test.go index 064dcdba83189..e303294471b23 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" @@ -23,6 +25,8 @@ 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/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -48,11 +52,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.", @@ -69,16 +73,80 @@ func createMCPServerConfig(t testing.TB, client *codersdk.Client, slug string, e 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() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newMCPClient(t) + firstUser := coderdtest.CreateFirstUser(t, client) + 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) { t.Parallel() 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.", @@ -98,6 +166,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) @@ -114,7 +183,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) @@ -122,7 +191,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) @@ -134,7 +203,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, @@ -150,7 +219,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) @@ -158,21 +227,40 @@ 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. - configs, err = client.MCPServerConfigs(ctx) + configs, err = client.MCPServerConfigs(ctx, firstUser.OrganizationID) require.NoError(t, err) 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() @@ -182,19 +270,39 @@ 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) + + // 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.OrganizationID, config.ID) + require.NoError(t, err, name) + require.NotEmpty(t, fetched.URL, name) + } + } + } } // TestMCPServerConfigsSecretsNeverLeaked is a load-bearing test that @@ -211,7 +319,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", @@ -260,7 +368,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 { @@ -268,12 +376,12 @@ 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) // 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 { @@ -290,7 +398,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") @@ -311,7 +419,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", @@ -329,7 +437,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) @@ -337,12 +445,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", @@ -355,7 +463,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 { @@ -373,12 +481,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", @@ -401,14 +509,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) @@ -418,13 +526,13 @@ 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) 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", @@ -444,20 +552,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) @@ -480,9 +588,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", @@ -500,11 +608,306 @@ 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", + OAuth2RevocationURL: "https://auth.example.com/revoke", + 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.OrganizationID, 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.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{ + AuthType: &authType, + }) + require.NoError(t, err) + 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.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{ + OAuth2TokenURL: &movedEndpoint, + }) + require.NoError(t, err) + 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) + config := newConfig(ctx, t, "grant-client-id-change") + seedToken(ctx, t, config.ID) + + newClientID := "cid-2" + _, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, 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) + config := newConfig(ctx, t, "grant-unrelated") + seedToken(ctx, t, config.ID) + + displayName := "Grant Invalidation renamed" + newSecret := "rotated-secret" + _, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{ + DisplayName: &displayName, + OAuth2ClientSecret: &newSecret, + }) + require.NoError(t, err) + require.True(t, tokenExists(ctx, t, config.ID)) + }) +} + +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{ + DeploymentValues: mcpDeploymentValues(t), + ChatProviderAPIKeys: &providerKeys, + }) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + var configID atomic.Pointer[uuid.UUID] + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if id := configID.Load(); id != nil { + _, err := adminClient.UpdateMCPServerConfig(ctx, firstUser.OrganizationID, *id, updateRequest) + 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, + 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) + + 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() + + 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{}, + } + } + + 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.OrganizationID, orgAdminOwned.ID, codersdk.UpdateMCPServerConfigRequest{ + AuthType: &userOIDC, + }) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + + deploymentOwned, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, newRequest("deployment-oidc", "user_oidc")) + require.NoError(t, err) + + // The URL determines where chat owners' OIDC tokens are sent. + newURL := "https://attacker.example.com/exfil" + _, 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.OrganizationID, deploymentOwned.ID, codersdk.UpdateMCPServerConfigRequest{ + URL: &updatedURL, + }) + require.NoError(t, err) + require.Equal(t, updatedURL, updated.URL) +} + 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 { @@ -512,7 +915,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", @@ -531,7 +934,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", @@ -554,9 +957,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", @@ -570,7 +973,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", @@ -602,7 +1005,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", @@ -662,43 +1065,40 @@ func TestMCPServerConfigsOAuth2Disconnect(t *testing.T) { require.Empty(t, resp.TokenRevocationError) }) - t.Run("DoesNotRevealHiddenConfigs", func(t *testing.T) { + t.Run("RemovedOrgMemberCanDisconnect", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t) - adminClient, _ := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ DeploymentValues: mcpDeploymentValues(t), ChatProviderAPIKeys: &providerKeys, }) - firstUser := coderdtest.CreateFirstUser(t, adminClient) - memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + coderdtest.CreateFirstUser(t, adminClient) - created, err := adminClient.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ - DisplayName: "OAuth Disconnect Hidden", - Slug: "disc-hidden", - Transport: "streamable_http", - URL: "https://mcp.example.com/disc-hidden", + 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", - OAuth2ClientID: "cid", - OAuth2AuthURL: "https://auth.example.com/authorize", - OAuth2TokenURL: "https://auth.example.com/token", - Availability: "default_on", - Enabled: false, - ToolAllowList: []string{}, - ToolDenyList: []string{}, + Enabled: true, }) - require.NoError(t, err) + seedToken(t, db, config.ID, member.ID) - // Disconnecting a disabled config the member cannot see must be - // indistinguishable from disconnecting a nonexistent config ID. - hiddenResp, err := memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, created.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) - missingResp, err := memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, uuid.New()) + + // 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) - require.Equal(t, missingResp, hiddenResp) - require.False(t, hiddenResp.TokenRevoked) - require.Empty(t, hiddenResp.TokenRevocationError) + requireTokenDeleted(t, db, config.ID, member.ID) }) t.Run("RevokesAtProvider", func(t *testing.T) { @@ -759,7 +1159,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", @@ -792,7 +1192,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} }() @@ -868,7 +1268,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", @@ -898,7 +1298,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) @@ -924,6 +1324,16 @@ 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) + }) + } + // 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) { @@ -944,6 +1354,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(`{ @@ -975,23 +1389,46 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { })) t.Cleanup(mcpServer.Close) - client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + 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, 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) + testutil.Go(t, 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} }) - require.NoError(t, err) + t.Cleanup(releaseRegistration) + + testutil.TryReceive(ctx, t, registrationStarted) + //nolint:gocritic // Verifying persisted state requires system access. + _, err := db.GetMCPServerConfigByOrganizationAndSlug(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerConfigByOrganizationAndSlugParams{ + OrganizationID: firstUser.OrganizationID, + Slug: "auto-discovery", + }) + releaseRegistration() + require.ErrorIs(t, err, sql.ErrNoRows) + + create := testutil.RequireReceive(ctx, t, 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) @@ -1000,7 +1437,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", @@ -1016,10 +1453,68 @@ 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() + + 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) + + // 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{ + "mcp_server_config:create", + "organization:read", + }, + }) + scopedClient := codersdk.New(client.URL) + scopedClient.SetSessionToken(token) + var registrationRequests atomic.Int64 + + created, err := scopedClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "Create Only Discovery", + Slug: "create-only-discovery", + Transport: "streamable_http", + URL: newMCPDiscoveryServer(t, ®istrationRequests), + AuthType: "oauth2", + Availability: "default_on", + Enabled: true, + }) + require.NoError(t, err) + require.Equal(t, "discovered-client-id", created.OAuth2ClientID) + require.EqualValues(t, 1, registrationRequests.Load()) + }) + + 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, + }) + 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()) + }) + t.Run("PathAwareTakesPriority", func(t *testing.T) { t.Parallel() @@ -1111,9 +1606,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", @@ -1187,9 +1682,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", @@ -1265,9 +1760,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", @@ -1361,11 +1856,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", @@ -1427,10 +1922,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", @@ -1462,10 +1957,14 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) { })) t.Cleanup(mcpServer.Close) - client := newMCPClient(t) - _ = coderdtest.CreateFirstUser(t, client) + 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, codersdk.CreateMCPServerConfigRequest{ + _, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ DisplayName: "Will Fail", Slug: "discovery-fail", Transport: "streamable_http", @@ -1481,6 +1980,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) { @@ -1488,10 +1993,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", @@ -1555,9 +2060,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", @@ -1576,7 +2081,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) @@ -1596,7 +2101,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", @@ -1618,12 +2123,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, @@ -1693,7 +2194,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", @@ -1790,7 +2291,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", @@ -1856,8 +2357,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{ @@ -1879,7 +2380,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) @@ -1959,9 +2460,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", @@ -2002,9 +2503,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", @@ -2078,9 +2579,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", @@ -2161,9 +2662,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", @@ -2237,9 +2738,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", @@ -2309,9 +2810,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", @@ -2383,11 +2884,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", @@ -2425,7 +2926,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", @@ -2457,7 +2958,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) @@ -2479,14 +2980,14 @@ 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) 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()) @@ -2520,7 +3021,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) @@ -2545,7 +3046,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", @@ -2572,7 +3073,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/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..0414707fe6c70 100644 --- a/coderd/rbac/regosql/configs.go +++ b/coderd/rbac/regosql/configs.go @@ -74,6 +74,22 @@ func ChatNoACLConverter() *sqltypes.VariableConverter { return matcher } +// 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(), + 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..4798fefccfcd2 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -476,6 +476,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // Allow auditors to query deployment stats and insights. ResourceDeploymentStats.Type: {policy.ActionRead}, ResourceDeploymentConfig.Type: {policy.ActionRead}, + ResourceMCPServerConfig.Type: {policy.ActionRead}, // Allow auditors to query AI Bridge interceptions. ResourceAibridgeInterception.Type: {policy.ActionRead}, // Allow auditors to read boundary logs. @@ -611,6 +612,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ResourceGroupMember.Type: {policy.ActionRead}, ResourceOrganization.Type: {policy.ActionRead}, ResourceOrganizationMember.Type: {policy.ActionRead}, + ResourceMCPServerConfig.Type: {policy.ActionRead}, }), Member: []Permission{}, }, @@ -1157,6 +1159,9 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions { ResourceOrganization.Type: {policy.ActionRead}, // Can read available roles. ResourceAssignOrgRole.Type: {policy.ActionRead}, + // TODO(mafredri): Remove once CODAGT-712 replaces this grant with + // per-config ACL evaluation. + ResourceMCPServerConfig.Type: {policy.ActionRead}, } // In all modes of workspace sharing but `none`, members need to @@ -1234,6 +1239,9 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions { ResourceOrganization.Type: {policy.ActionRead}, // Can read available roles. ResourceAssignOrgRole.Type: {policy.ActionRead}, + // TODO(mafredri): Remove once CODAGT-712 replaces this grant with + // per-config ACL evaluation. + 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..fae1db672a46f 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -824,6 +824,24 @@ func TestRolePermissions(t *testing.T) { false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, + { + 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/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/chatd_test.go b/coderd/x/chatd/chatd_test.go index 11865529fb139..c623356d351ad 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", @@ -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) @@ -1061,18 +1064,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) @@ -1191,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, @@ -1200,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, @@ -10459,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)) @@ -10541,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)) @@ -10601,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) { @@ -10658,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) { @@ -10719,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) { @@ -10775,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)) @@ -10903,11 +10917,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) @@ -11065,6 +11080,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, @@ -11164,6 +11180,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, @@ -11364,6 +11381,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, @@ -11492,6 +11510,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/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") } diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index f9085157de85b..b61491d3b4f3e 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 @@ -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. @@ -910,3 +910,19 @@ func latestAssistantText(messages []database.ChatMessage) string { } return "" } + +func enabledMCPServerConfigsForChatOrg( + ctx context.Context, + db database.Store, + organizationID uuid.UUID, + ids []uuid.UUID, +) ([]database.MCPServerConfig, error) { + configs, err := db.GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx, database.GetEnabledMCPServerConfigsByOrganizationAndIDsParams{ + OrganizationID: organizationID, + IDs: ids, + }) + if err != nil { + return nil, xerrors.Errorf("get enabled MCP server configs for organization: %w", err) + } + return configs, nil +} diff --git a/coderd/x/chatd/generation_preparer_internal_test.go b/coderd/x/chatd/generation_preparer_internal_test.go index 83fd496739bc8..400c66217a006 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,130 @@ func TestShouldCompactPromptUsage(t *testing.T) { contextLimit, 80)) }) } + +func TestEnabledMCPServerConfigsForChatOrg(t *testing.T) { + t.Parallel() + + 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: true, + }) + return org, cfg + } + + t.Run("DefaultOrgConfigExcluded", 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) + + // 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, + }) + + configs, err := enabledMCPServerConfigsForChatOrg(ctx, db, chatOrg.ID, []uuid.UUID{chatOrgCfg.ID, defaultOrgCfg.ID}) + require.NoError(t, err) + require.Len(t, configs, 1) + require.Equal(t, chatOrgCfg.ID, configs[0].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) + _, foreignCfg := newOrgWithConfig(t, db) + + 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{ + ID: uuid.New(), + 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) + + // 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, + }) + + // 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{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} + 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("ChatOrgWithNoConfigs", 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) + + 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.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/coderd/x/chatd/subagent_internal_test.go b/coderd/x/chatd/subagent_internal_test.go index 2774d90f9cba6..b46f62cd5f42a 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{ @@ -3359,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} @@ -3410,19 +3415,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/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/mcp.go b/codersdk/mcp.go index ed68bba704dca..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 @@ -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 } @@ -186,8 +187,8 @@ func (c *Client) MCPServerConfigs(ctx context.Context) ([]MCPServerConfig, error 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 } @@ -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 } @@ -212,8 +213,8 @@ func (c *Client) CreateMCPServerConfig(ctx context.Context, req CreateMCPServerC 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 } @@ -225,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/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/ai-coder/agents/platform-controls/mcp-servers.md b/docs/ai-coder/agents/platform-controls/mcp-servers.md index e957f09d2fc6d..a44319a19d011 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,13 @@ 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. +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. 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. 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/enterprise/coderd/mcp_test.go b/enterprise/coderd/mcp_test.go new file mode 100644 index 0000000000000..aad14dd9c5fcf --- /dev/null +++ b/enterprise/coderd/mcp_test.go @@ -0,0 +1,138 @@ +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, + path string, + body any, + wantStatus int, +) { + t.Helper() + + res, err := client.Request( + testutil.Context(t, testutil.WaitLong), + method, + path, + 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") + 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 + path string + body any + wantStatus int + }{ + {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, path: frozenPath + "/oauth2/disconnect", wantStatus: http.StatusOK}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + wantStatus := test.wantStatus + if wantStatus == 0 { + wantStatus = http.StatusNotFound + } + requireMCPServerConfigRequestStatus(t, otherClient, test.method, test.path, test.body, wantStatus) + }) + } +} diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index 6c9150f17a33f..60f3f92d1432d 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. @@ -713,8 +714,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) GetMCPServerConfigByIDForUpdate(ctx context.Context, id uuid.UUID) (database.MCPServerConfig, error) { + cfg, err := db.Store.GetMCPServerConfigByIDForUpdate(ctx, id) if err != nil { return database.MCPServerConfig{}, err } @@ -724,8 +725,32 @@ func (db *dbCrypt) GetMCPServerConfigBySlug(ctx context.Context, slug string) (d return cfg, nil } -func (db *dbCrypt) GetMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { - cfgs, err := db.Store.GetMCPServerConfigs(ctx) +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 + } + if err := db.decryptMCPServerConfig(&cfg); err != nil { + return database.MCPServerConfig{}, err + } + return cfg, nil +} + +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 + } + for i := range cfgs { + if err := db.decryptMCPServerConfig(&cfgs[i]); err != nil { + return nil, err + } + } + return cfgs, nil +} + +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 } @@ -737,8 +762,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) GetEnabledMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.MCPServerConfig, error) { + cfgs, err := db.Store.GetEnabledMCPServerConfigsByOrganization(ctx, organizationID) if err != nil { return nil, err } @@ -750,8 +775,8 @@ func (db *dbCrypt) GetMCPServerConfigsByIDs(ctx context.Context, ids []uuid.UUID return cfgs, nil } -func (db *dbCrypt) GetEnabledMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { - cfgs, err := db.Store.GetEnabledMCPServerConfigs(ctx) +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 } @@ -763,8 +788,8 @@ func (db *dbCrypt) GetEnabledMCPServerConfigs(ctx context.Context) ([]database.M return cfgs, nil } -func (db *dbCrypt) GetForcedMCPServerConfigs(ctx context.Context) ([]database.MCPServerConfig, error) { - cfgs, err := db.Store.GetForcedMCPServerConfigs(ctx) +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 } diff --git a/enterprise/dbcrypt/dbcrypt_internal_test.go b/enterprise/dbcrypt/dbcrypt_internal_test.go index d37fbbacf88b8..5ecb4a7324688 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() @@ -961,59 +973,97 @@ func TestMCPServerConfigs(t *testing.T) { requireMCPServerConfigRawEncrypted(ctx, t, db, cfg.ID, ciphers, oauthSecret, apiKeyValue, customHeaders) }) - t.Run("GetMCPServerConfigBySlug", func(t *testing.T) { + t.Run("GetMCPServerConfigByIDForUpdate", 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.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("GetMCPServerConfigs", func(t *testing.T) { + t.Run("GetMCPServerConfigByOrganizationAndSlug", func(t *testing.T) { t.Parallel() db, crypt, ciphers := setup(t) cfg := insertConfig(t, crypt, ciphers) - cfgs, err := crypt.GetMCPServerConfigs(ctx) + 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("GetMCPServerConfigsByOrganization", func(t *testing.T) { + t.Parallel() + db, crypt, ciphers := setup(t) + cfg := insertConfig(t, crypt, ciphers) + + 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("GetAuthorizedMCPServerConfigs", func(t *testing.T) { + t.Parallel() + db, crypt, ciphers := setup(t) + cfg := insertConfig(t, crypt, ciphers) + + 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) 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("GetEnabledMCPServerConfigsByOrganizationAndIDs", 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.GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx, database.GetEnabledMCPServerConfigsByOrganizationAndIDsParams{ + 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/api.ts b/site/src/api/api.ts index 624925d9dbf4f..c8a54b696bf08 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -359,7 +359,14 @@ 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 = (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; @@ -3905,37 +3912,43 @@ 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; }; updateMCPServerConfig = async ( + organization: string, id: string, req: TypesGen.UpdateMCPServerConfigRequest, ): Promise => { const response = await this.axios.patch( - `${mcpServerConfigsPath}/${encodeURIComponent(id)}`, + mcpServerConfigPath(organization, id), req, ); return response.data; }; - deleteMCPServerConfig = async (id: string): Promise => { - await this.axios.delete( - `${mcpServerConfigsPath}/${encodeURIComponent(id)}`, - ); + deleteMCPServerConfig = async ( + organization: string, + id: string, + ): Promise => { + await this.axios.delete(mcpServerConfigPath(organization, id)); }; disconnectMCPServerOAuth2 = async ( @@ -3943,7 +3956,7 @@ class ExperimentalApiMethods { ): Promise => { const response = await this.axios.delete( - `${mcpServerConfigsPath}/${encodeURIComponent(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 198573ab74923..d9f347d914279 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -2326,21 +2326,26 @@ export const updateChatModelOverride = ( // ── MCP Server Configs ─────────────────────────────────────── -export const mcpServersKey = ["mcp", "servers"] as const; +const mcpServersKey = ["mcp", "servers"] as const; +export 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); }, @@ -2351,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/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..8c93fd9e509db 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", @@ -5945,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; @@ -7750,6 +7761,7 @@ export type RBACResource = | "idpsync_settings" | "inbox_notification" | "license" + | "mcp_server_config" | "notification_message" | "notification_preference" | "notification_template" @@ -7803,6 +7815,7 @@ export const RBACResources: RBACResource[] = [ "idpsync_settings", "inbox_notification", "license", + "mcp_server_config", "notification_message", "notification_preference", "notification_template", 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 60f4988510d93..11148d461253f 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 { + getDefaultOrganizationId, + 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 = getDefaultOrganizationId(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.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/AISettingsPage/MCPServersPage/MCPServersPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx index 2d070c6e29839..75c43196d9be4 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 { + getDefaultOrganizationId, + 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 = getDefaultOrganizationId(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..6289fbcc82103 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -10,19 +10,33 @@ import { } from "#/api/queries/chats"; import { Loader } from "#/components/Loader/Loader"; import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { + getDefaultOrganizationId, + 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 = getDefaultOrganizationId(organizations); const { serverId } = useParams<{ serverId: string }>(); const queryClient = useQueryClient(); const navigate = useNavigate(); - const serversQuery = useQuery(mcpServerConfigs()); - const updateMutation = useMutation(updateMCPServerConfig(queryClient)); - const deleteMutation = useMutation(deleteMCPServerConfig(queryClient)); + const serversQuery = useQuery({ + ...mcpServerConfigs(organization), + enabled: Boolean(organization), + }); 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 9c17715e2fe04..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, - mcpServersKey, + mcpServerConfigsKey, 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: mcpServerConfigsKey(chat.organization_id), + 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 4ea32c0957c38..9d2b6dabe856d 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,14 @@ 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 isDefaultChatOrganization = organizations.some( + (organization) => + organization.id === chatOrganizationId && organization.is_default, + ); const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 })); const workspaceOptions = getWorkspaceOptionsWithLinkedWorkspace( workspacesQuery.data?.workspaces ?? [], @@ -941,7 +949,9 @@ const AgentChatPage: FC = () => { const handleMCPSelectionChange = (ids: string[]) => { setSelectedMCPServerIds(ids); - saveMCPSelection(ids); + if (chatOrganizationId) { + saveMCPSelection(chatOrganizationId, ids); + } }; const handleMCPAuthComplete = (_serverId: string) => { @@ -1096,7 +1106,13 @@ 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, + isDefaultChatOrganization, + ) + : null; if (saved !== null) { return saved; } diff --git a/site/src/pages/AgentsPage/AgentCreatePage.tsx b/site/src/pages/AgentsPage/AgentCreatePage.tsx index 38f019e9cd867..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"; @@ -54,7 +53,6 @@ const AgentCreatePage: FC = () => { userChatPersonalModelOverrides(), ); const preferencesQuery = useQuery(preferenceSettings()); - const mcpServersQuery = useQuery(mcpServerConfigs()); const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 })); const createMutation = useMutation(createChat(queryClient)); const webPush = useWebpushNotifications(); @@ -179,8 +177,6 @@ const AgentCreatePage: FC = () => { isModelConfigsLoading={chatModelConfigsQuery.isLoading} rootPersonalModelOverride={rootPersonalModelOverride} isPersonalModelOverridesLoading={personalModelOverridesQuery.isLoading} - 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/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index c452dd94aea35..97ca4121941f2 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 mockDefaultOrganizationMCPServer: TypesGen.MCPServerConfig = { + ...MockMCPServerConfig, + id: "mcp-default-organization", + display_name: "Default organization MCP", + slug: "default-organization-mcp", +}; + +const mockSecondOrganizationMCPServer: 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", owner_id: "me" }, + action: "create", + }).queryKey, + data: [MockDefaultOrganization, MockOrganization2], + }, + ], + }, + beforeEach: () => { + spyOn(API.experimental, "getMCPServerConfigs").mockImplementation( + async (organization) => + organization === MockDefaultOrganization.id + ? [mockDefaultOrganizationMCPServer] + : [mockSecondOrganizationMCPServer], + ); + }, + 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..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(), }; @@ -773,6 +774,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/organizations/org-1/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/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 8eb20650f94de..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.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 59ed37449314b..254111d06bf6a 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", @@ -127,6 +129,7 @@ const meta: Meta = { }, beforeEach: () => { localStorage.clear(); + spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([]); }, }; @@ -896,7 +899,9 @@ export const ForbiddenErrorWithRole: Story = { await expect(canvas.getByText("Forbidden.")).toBeInTheDocument(); // The textbox should remain enabled since the user has the role. const textbox = canvas.getByRole("textbox"); - await expect(textbox).not.toHaveAttribute("aria-disabled", "true"); + await waitFor(() => + expect(textbox).not.toHaveAttribute("aria-disabled", "true"), + ); }, }; @@ -913,17 +918,30 @@ export const WithOrganizationPicker: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const organizationPicker = canvas.getByRole("button", { - name: "Organization: My Organization", + 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}`, + }); + await userEvent.click(organizationSelector); + await userEvent.click( + await body.findByRole("option", { name: MockOrganization2.display_name }), + ); + await waitFor(() => { + expect(API.experimental.getMCPServerConfigs).toHaveBeenCalledWith( + MockOrganization2.id, + ); }); - await expect(organizationPicker).toBeVisible(); - 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(); }, @@ -1627,3 +1645,63 @@ 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.getByRole("textbox"); + 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(); + }, +}; + +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.getByRole("textbox"); + 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 5b3f382214524..ef910038639c3 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"; @@ -139,8 +140,6 @@ interface AgentCreateFormProps { isModelConfigsLoading: boolean; rootPersonalModelOverride?: TypesGen.ChatPersonalModelOverride; isPersonalModelOverridesLoading?: boolean; - mcpServers?: readonly TypesGen.MCPServerConfig[]; - onMCPAuthComplete?: (serverId: string) => void; workspaceCount: number | undefined; workspaceOptions: readonly TypesGen.Workspace[]; workspacesError: unknown; @@ -165,8 +164,6 @@ export const AgentCreateForm: FC = ({ isModelConfigsLoading, rootPersonalModelOverride, isPersonalModelOverridesLoading = false, - mcpServers, - onMCPAuthComplete, workspaceCount: _workspaceCount, workspaceOptions, workspacesError, @@ -283,6 +280,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 @@ -332,6 +332,16 @@ export const AgentCreateForm: FC = ({ initialOrg ?? null); const organizationId = effectiveOrg?.id ?? ""; + const mcpServersQuery = useQuery({ + ...mcpServerConfigs(organizationId), + enabled: Boolean(organizationId), + }); + const mcpServers = mcpServersQuery.data ?? []; + // 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.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 ( @@ -354,6 +364,7 @@ export const AgentCreateForm: FC = ({ setLastSettledOrgId(organizationId); if (lastSettledOrgId !== null) { setSelectedWorkspaceId(null); + setUserMCPServerIds(null); } } useEffect(() => { @@ -395,18 +406,19 @@ 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, + effectiveOrg?.is_default, + ); if (saved !== null) { return saved; } - return getDefaultMCPSelection(mcpServers ?? []); + return getDefaultMCPSelection(mcpServers); })(); const handleWorkspaceChange = (value: string | null) => { if (value === null) { @@ -418,6 +430,11 @@ export const AgentCreateForm: FC = ({ localStorage.setItem(selectedWorkspaceIdStorageKey, value); }; + const selectOrganization = (organization: TypesGen.Organization) => { + setUserMCPServerIds(null); + setSelectedOrg(organization); + }; + const handleModelChange = (value: string) => { setHasUserSelectedModel(true); setUserSelectedModel(value); @@ -565,6 +582,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 && @@ -581,6 +601,8 @@ export const AgentCreateForm: FC = ({ } if (orgChanged) { handleWorkspaceChange(null); + selectOrganization(newOrg); + return; } setSelectedOrg(newOrg); }} @@ -598,6 +620,7 @@ export const AgentCreateForm: FC = ({ !organizationAdopted || workspaceValidationPending || isPersonalModelOverridesLoading || + isMCPSelectionUnresolved || !hasModelOptions || Boolean(aiGatewayDisabled) } @@ -624,12 +647,13 @@ export const AgentCreateForm: FC = ({ previewUrls={previewUrls} textContents={textContents} mcpServers={mcpServers} + chatOrganizationId={organizationId} selectedMCPServerIds={effectiveMCPServerIds} onMCPSelectionChange={(ids) => { setUserMCPServerIds(ids); - saveMCPSelection(ids); + saveMCPSelection(organizationId, ids); }} - onMCPAuthComplete={onMCPAuthComplete} + onMCPAuthComplete={() => void mcpServersQuery.refetch()} workspaceOptions={filteredWorkspaces} selectedWorkspaceId={effectiveWorkspaceId} // Do not persist a workspace until its organization is authorized. @@ -671,7 +695,7 @@ export const AgentCreateForm: FC = ({ } resetAttachments(); handleWorkspaceChange(null); - setSelectedOrg(pendingOrgChange); + selectOrganization(pendingOrgChange); }} onClose={() => setPendingOrgChange(null)} /> 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/pages/AgentsPage/components/MCPServerPicker.stories.tsx b/site/src/pages/AgentsPage/components/MCPServerPicker.stories.tsx index beeb2464bb1d1..e3e7ea6d3372e 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"; @@ -118,6 +118,7 @@ const meta: Meta = { title: "pages/AgentsPage/MCPServerPicker", component: MCPServerPicker, args: { + organizationId: "org-1", onSelectionChange: fn(), onAuthComplete: fn(), }, @@ -181,6 +182,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/organizations/org-1/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 533cce5e9b937..c54cd654d1d4f 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,30 @@ 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( + "[]", + ); + }); + + 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"])); }); }); @@ -45,34 +62,119 @@ 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("heals a legacy selection into the organization-scoped key on read", () => { + localStorage.setItem( + "agents.selected-mcp-server-ids", + JSON.stringify(["s3"]), + ); + + expect(getSavedMCPSelection(organizationId, servers, true)).toEqual([ + "s3", + "s1", + ]); + 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("heals an empty legacy selection without enabling default-on servers", () => { + localStorage.setItem("agents.selected-mcp-server-ids", "[]"); + + expect(getSavedMCPSelection(organizationId, servers, true)).toEqual([ + "s1", + ]); + + 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", () => { - 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 +184,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..a145f13a6e8f9 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. */ @@ -91,17 +93,32 @@ 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 = "agents.selected-mcp-server-ids"; +export const mcpSelectionStorageKey = (organizationId: string) => + `${legacyMCPSelectionStorageKey}.${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). + * + * 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 => { - const raw = localStorage.getItem(mcpSelectionStorageKey); + 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; } @@ -135,16 +152,24 @@ export const mcpSelectionStorageKey = "agents.selected-mcp-server-ids"; restored.push(id); } } + if (fromLegacy) { + saveMCPSelection(organizationId, restored); + localStorage.removeItem(legacyMCPSelectionStorageKey); + } return restored; } catch { return null; } }; -/** - * 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 ───────────────────── @@ -184,6 +209,7 @@ const TriggerIconStack: FC<{ // ── Component ────────────────────────────────────────────────── export const MCPServerPicker: FC = ({ + organizationId, servers, selectedServerIds, onSelectionChange, @@ -254,7 +280,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", 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: "",