From 7bb86bb64ae803d7747276edf8e81c5b422e4180 Mon Sep 17 00:00:00 2001 From: Omer Cohen Date: Mon, 29 Jun 2026 13:17:17 +0000 Subject: [PATCH] feat(mgmt): add MCP server management API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new MCPServer management manager covering the managementservice MCP endpoints the Go SDK was missing — full server lifecycle plus client management: - Servers: Create, Update, Delete, DeleteBatch, Load, LoadAll - Clients: CreateClient, UpdateClient, DeleteClient, DeleteClients, LoadClient, GetClientSecret, RotateClientSecret, SearchClients Includes the MCPServer / MCPServerClient types and their nested config types, route wiring, manager registration, mock implementation, README section, and unit tests. The OIDC dynamic-client-registration protocol endpoint (mcp/client/{projectId}/{mcpServerId}/register) is intentionally excluded as it is an OAuth/OIDC protocol endpoint rather than a management CRUD operation. Co-Authored-By: Claude Opus 4.8 --- README.md | 57 ++++ descope/api/client.go | 85 ++++++ descope/internal/mgmt/mcpserver.go | 241 +++++++++++++++++ descope/internal/mgmt/mcpserver_test.go | 288 +++++++++++++++++++++ descope/internal/mgmt/mgmt.go | 7 + descope/sdk/mgmt.go | 61 +++++ descope/tests/mocks/mgmt/managementmock.go | 156 +++++++++++ descope/types.go | 131 ++++++++++ 8 files changed, 1026 insertions(+) create mode 100644 descope/internal/mgmt/mcpserver.go create mode 100644 descope/internal/mgmt/mcpserver_test.go diff --git a/README.md b/README.md index 13c557fd..318bec9a 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ These sections show how to use the SDK to perform API management functions. Befo 18. [Manage Descopers](#manage-descopers) 19. [Manage Engines](#manage-engines) 20. [Manage JWT Templates](#manage-jwt-templates) +21. [Manage MCP Servers](#manage-mcp-servers) If you wish to run any of our code samples and play with them, check out our [Code Examples](#code-examples) section. @@ -2321,6 +2322,62 @@ tmpl, err = descopeClient.Management.JWTTemplate().ApplyFromLibrary(context.Back err = descopeClient.Management.JWTTemplate().Delete(context.Background(), "template-id") ``` +### Manage MCP Servers + +You can create, update, delete, load and list MCP servers, and manage their clients: + +```go +// Create a new MCP server. The returned server includes its generated ID. +server, err := descopeClient.Management.MCPServer().Create(context.Background(), &descope.MCPServer{ + Name: "my-mcp-server", + Description: "Example MCP server", +}) + +// Update an existing MCP server (identified by its ID). All fields are overridden. +server, err = descopeClient.Management.MCPServer().Update(context.Background(), &descope.MCPServer{ + ID: "server-id", + Name: "renamed-server", +}) + +// Load a server by ID, or list all servers in the project. +server, err = descopeClient.Management.MCPServer().Load(context.Background(), "server-id") +servers, err := descopeClient.Management.MCPServer().LoadAll(context.Background()) + +// Delete one or more servers by ID. +err = descopeClient.Management.MCPServer().Delete(context.Background(), "server-id") +err = descopeClient.Management.MCPServer().DeleteBatch(context.Background(), []string{"id1", "id2"}) + +// Manage clients registered against an MCP server. +// CreateClient returns the generated client secret (Cleartext) — only available at creation time. +created, err := descopeClient.Management.MCPServer().CreateClient(context.Background(), &descope.MCPServerClientRequest{ + MCPServerID: "server-id", + Name: "my-client", +}) +if err == nil { + fmt.Println("client id:", created.ClientID) + fmt.Println("client secret (store securely!):", created.Cleartext) +} + +client, err := descopeClient.Management.MCPServer().UpdateClient(context.Background(), &descope.MCPServerClientRequest{ + ID: created.ID, + MCPServerID: "server-id", + Name: "renamed-client", +}) + +// Load a client, fetch or rotate its secret, and search clients. +client, err = descopeClient.Management.MCPServer().LoadClient(context.Background(), "server-id", created.ID, "") +secret, err := descopeClient.Management.MCPServer().GetClientSecret(context.Background(), "server-id", created.ID) +secret, err = descopeClient.Management.MCPServer().RotateClientSecret(context.Background(), "server-id", created.ID) +clients, total, err := descopeClient.Management.MCPServer().SearchClients(context.Background(), &descope.MCPServerClientSearchOptions{ + MCPServerID: "server-id", + Limit: 100, +}) + +// Delete one or more clients within a server. +err = descopeClient.Management.MCPServer().DeleteClient(context.Background(), "server-id", created.ID) +err = descopeClient.Management.MCPServer().DeleteClients(context.Background(), "server-id", []string{"id1", "id2"}) +``` + ## Code Examples You can find various usage examples in the [examples folder](https://github.com/descope/go-sdk/blob/main/examples). diff --git a/descope/api/client.go b/descope/api/client.go index a70ce99f..e6958798 100644 --- a/descope/api/client.go +++ b/descope/api/client.go @@ -328,6 +328,20 @@ var ( jwtTemplateLibraryList: "mgmt/jwt/templates/library/list", jwtTemplateLibraryLoad: "mgmt/jwt/templates/library/load", jwtTemplateLibraryApply: "mgmt/jwt/templates/library/apply", + mcpServerCreate: "mgmt/mcp/server/create", + mcpServerUpdate: "mgmt/mcp/server/update", + mcpServerDelete: "mgmt/mcp/server/delete", + mcpServerLoad: "mgmt/mcp/server/load", + mcpServersLoadAll: "mgmt/mcp/servers/all", + mcpServersDelete: "mgmt/mcp/servers/delete", + mcpServerClientCreate: "mgmt/mcp/server/client/create", + mcpServerClientUpdate: "mgmt/mcp/server/client/update", + mcpServerClientDelete: "mgmt/mcp/server/client/delete", + mcpServerClientLoad: "mgmt/mcp/server/client/load", + mcpServerClientSecret: "mgmt/mcp/server/client/secret", + mcpServerClientSecretRotate: "mgmt/mcp/server/client/secret/rotate", + mcpServerClientsDelete: "mgmt/mcp/server/clients/delete", + mcpServerClientsSearch: "mgmt/mcp/server/clients/search", scopeClaimMappingGet: "mgmt/scopeClaimMapping/get", scopeClaimMappingSet: "mgmt/scopeClaimMapping/set", scopeClaimMappingDelete: "mgmt/scopeClaimMapping/delete", @@ -680,6 +694,21 @@ type mgmtEndpoints struct { jwtTemplateLibraryLoad string jwtTemplateLibraryApply string + mcpServerCreate string + mcpServerUpdate string + mcpServerDelete string + mcpServerLoad string + mcpServersLoadAll string + mcpServersDelete string + mcpServerClientCreate string + mcpServerClientUpdate string + mcpServerClientDelete string + mcpServerClientLoad string + mcpServerClientSecret string + mcpServerClientSecretRotate string + mcpServerClientsDelete string + mcpServerClientsSearch string + scopeClaimMappingGet string scopeClaimMappingSet string scopeClaimMappingDelete string @@ -1872,6 +1901,62 @@ func (e *endpoints) ManagementJWTTemplateLibraryApply() string { return path.Join(e.version, e.mgmt.jwtTemplateLibraryApply) } +func (e *endpoints) ManagementMCPServerCreate() string { + return path.Join(e.version, e.mgmt.mcpServerCreate) +} + +func (e *endpoints) ManagementMCPServerUpdate() string { + return path.Join(e.version, e.mgmt.mcpServerUpdate) +} + +func (e *endpoints) ManagementMCPServerDelete() string { + return path.Join(e.version, e.mgmt.mcpServerDelete) +} + +func (e *endpoints) ManagementMCPServerLoad() string { + return path.Join(e.version, e.mgmt.mcpServerLoad) +} + +func (e *endpoints) ManagementMCPServersLoadAll() string { + return path.Join(e.version, e.mgmt.mcpServersLoadAll) +} + +func (e *endpoints) ManagementMCPServersDelete() string { + return path.Join(e.version, e.mgmt.mcpServersDelete) +} + +func (e *endpoints) ManagementMCPServerClientCreate() string { + return path.Join(e.version, e.mgmt.mcpServerClientCreate) +} + +func (e *endpoints) ManagementMCPServerClientUpdate() string { + return path.Join(e.version, e.mgmt.mcpServerClientUpdate) +} + +func (e *endpoints) ManagementMCPServerClientDelete() string { + return path.Join(e.version, e.mgmt.mcpServerClientDelete) +} + +func (e *endpoints) ManagementMCPServerClientLoad() string { + return path.Join(e.version, e.mgmt.mcpServerClientLoad) +} + +func (e *endpoints) ManagementMCPServerClientSecret() string { + return path.Join(e.version, e.mgmt.mcpServerClientSecret) +} + +func (e *endpoints) ManagementMCPServerClientSecretRotate() string { + return path.Join(e.version, e.mgmt.mcpServerClientSecretRotate) +} + +func (e *endpoints) ManagementMCPServerClientsDelete() string { + return path.Join(e.version, e.mgmt.mcpServerClientsDelete) +} + +func (e *endpoints) ManagementMCPServerClientsSearch() string { + return path.Join(e.version, e.mgmt.mcpServerClientsSearch) +} + func (e *endpoints) ManagementScopeClaimMappingGet() string { return path.Join(e.version, e.mgmt.scopeClaimMappingGet) } diff --git a/descope/internal/mgmt/mcpserver.go b/descope/internal/mgmt/mcpserver.go new file mode 100644 index 00000000..7622baba --- /dev/null +++ b/descope/internal/mgmt/mcpserver.go @@ -0,0 +1,241 @@ +package mgmt + +import ( + "context" + + "github.com/descope/go-sdk/descope" + "github.com/descope/go-sdk/descope/api" + "github.com/descope/go-sdk/descope/internal/utils" + "github.com/descope/go-sdk/descope/sdk" +) + +type mcpServer struct { + managementBase +} + +var _ sdk.MCPServer = &mcpServer{} + +func (m *mcpServer) Create(ctx context.Context, server *descope.MCPServer) (*descope.MCPServer, error) { + if server == nil { + return nil, utils.NewInvalidArgumentError("server") + } + res, err := m.client.DoPostRequest(ctx, api.Routes.ManagementMCPServerCreate(), server, nil, "") + if err != nil { + return nil, err + } + return unmarshalMCPServerResponse(res) +} + +func (m *mcpServer) Update(ctx context.Context, server *descope.MCPServer) (*descope.MCPServer, error) { + if server == nil { + return nil, utils.NewInvalidArgumentError("server") + } + if server.ID == "" { + return nil, utils.NewInvalidArgumentError("server.ID") + } + body := map[string]any{"server": server} + res, err := m.client.DoPostRequest(ctx, api.Routes.ManagementMCPServerUpdate(), body, nil, "") + if err != nil { + return nil, err + } + return unmarshalMCPServerResponse(res) +} + +func (m *mcpServer) Delete(ctx context.Context, id string) error { + if id == "" { + return utils.NewInvalidArgumentError("id") + } + body := map[string]any{"id": id} + _, err := m.client.DoPostRequest(ctx, api.Routes.ManagementMCPServerDelete(), body, nil, "") + return err +} + +func (m *mcpServer) DeleteBatch(ctx context.Context, ids []string) error { + if len(ids) == 0 { + return utils.NewInvalidArgumentError("ids") + } + body := map[string]any{"ids": ids} + _, err := m.client.DoPostRequest(ctx, api.Routes.ManagementMCPServersDelete(), body, nil, "") + return err +} + +func (m *mcpServer) Load(ctx context.Context, id string) (*descope.MCPServer, error) { + if id == "" { + return nil, utils.NewInvalidArgumentError("id") + } + body := map[string]any{"id": id} + res, err := m.client.DoPostRequest(ctx, api.Routes.ManagementMCPServerLoad(), body, nil, "") + if err != nil { + return nil, err + } + return unmarshalMCPServerResponse(res) +} + +func (m *mcpServer) LoadAll(ctx context.Context) ([]*descope.MCPServer, error) { + res, err := m.client.DoPostRequest(ctx, api.Routes.ManagementMCPServersLoadAll(), map[string]any{}, nil, "") + if err != nil { + return nil, err + } + lres := struct { + Servers []*descope.MCPServer + }{} + if err := utils.Unmarshal([]byte(res.BodyStr), &lres); err != nil { + return nil, err + } + return lres.Servers, nil +} + +func (m *mcpServer) CreateClient(ctx context.Context, client *descope.MCPServerClientRequest) (*descope.MCPServerClientCreateResponse, error) { + if client == nil { + return nil, utils.NewInvalidArgumentError("client") + } + if client.MCPServerID == "" { + return nil, utils.NewInvalidArgumentError("client.MCPServerID") + } + res, err := m.client.DoPostRequest(ctx, api.Routes.ManagementMCPServerClientCreate(), client, nil, "") + if err != nil { + return nil, err + } + cres := &descope.MCPServerClientCreateResponse{} + if err := utils.Unmarshal([]byte(res.BodyStr), cres); err != nil { + return nil, err + } + return cres, nil +} + +func (m *mcpServer) UpdateClient(ctx context.Context, client *descope.MCPServerClientRequest) (*descope.MCPServerClient, error) { + if client == nil { + return nil, utils.NewInvalidArgumentError("client") + } + if client.ID == "" { + return nil, utils.NewInvalidArgumentError("client.ID") + } + if client.MCPServerID == "" { + return nil, utils.NewInvalidArgumentError("client.MCPServerID") + } + res, err := m.client.DoPostRequest(ctx, api.Routes.ManagementMCPServerClientUpdate(), client, nil, "") + if err != nil { + return nil, err + } + return unmarshalMCPServerClientResponse(res) +} + +func (m *mcpServer) DeleteClient(ctx context.Context, mcpServerID, id string) error { + if mcpServerID == "" { + return utils.NewInvalidArgumentError("mcpServerID") + } + if id == "" { + return utils.NewInvalidArgumentError("id") + } + body := map[string]any{"id": id, "mcpServerId": mcpServerID} + _, err := m.client.DoPostRequest(ctx, api.Routes.ManagementMCPServerClientDelete(), body, nil, "") + return err +} + +func (m *mcpServer) DeleteClients(ctx context.Context, mcpServerID string, ids []string) error { + if mcpServerID == "" { + return utils.NewInvalidArgumentError("mcpServerID") + } + if len(ids) == 0 { + return utils.NewInvalidArgumentError("ids") + } + body := map[string]any{"ids": ids, "mcpServerId": mcpServerID} + _, err := m.client.DoPostRequest(ctx, api.Routes.ManagementMCPServerClientsDelete(), body, nil, "") + return err +} + +func (m *mcpServer) LoadClient(ctx context.Context, mcpServerID, id, clientID string) (*descope.MCPServerClient, error) { + if mcpServerID == "" { + return nil, utils.NewInvalidArgumentError("mcpServerID") + } + if id == "" && clientID == "" { + return nil, utils.NewInvalidArgumentError("id") + } + body := map[string]any{"id": id, "clientId": clientID, "mcpServerId": mcpServerID} + res, err := m.client.DoPostRequest(ctx, api.Routes.ManagementMCPServerClientLoad(), body, nil, "") + if err != nil { + return nil, err + } + return unmarshalMCPServerClientResponse(res) +} + +func (m *mcpServer) GetClientSecret(ctx context.Context, mcpServerID, id string) (string, error) { + if mcpServerID == "" { + return "", utils.NewInvalidArgumentError("mcpServerID") + } + if id == "" { + return "", utils.NewInvalidArgumentError("id") + } + body := map[string]any{"id": id, "mcpServerId": mcpServerID} + res, err := m.client.DoPostRequest(ctx, api.Routes.ManagementMCPServerClientSecret(), body, nil, "") + if err != nil { + return "", err + } + return unmarshalMCPServerClientSecret(res) +} + +func (m *mcpServer) RotateClientSecret(ctx context.Context, mcpServerID, id string) (string, error) { + if mcpServerID == "" { + return "", utils.NewInvalidArgumentError("mcpServerID") + } + if id == "" { + return "", utils.NewInvalidArgumentError("id") + } + body := map[string]any{"id": id, "mcpServerId": mcpServerID} + res, err := m.client.DoPostRequest(ctx, api.Routes.ManagementMCPServerClientSecretRotate(), body, nil, "") + if err != nil { + return "", err + } + return unmarshalMCPServerClientSecret(res) +} + +func (m *mcpServer) SearchClients(ctx context.Context, options *descope.MCPServerClientSearchOptions) ([]*descope.MCPServerClient, int, error) { + if options == nil { + options = &descope.MCPServerClientSearchOptions{} + } + if options.MCPServerID == "" { + return nil, 0, utils.NewInvalidArgumentError("options.MCPServerID") + } + res, err := m.client.DoPostRequest(ctx, api.Routes.ManagementMCPServerClientsSearch(), options, nil, "") + if err != nil { + return nil, 0, err + } + sres := struct { + Clients []*descope.MCPServerClient + Total int + }{} + if err := utils.Unmarshal([]byte(res.BodyStr), &sres); err != nil { + return nil, 0, err + } + return sres.Clients, sres.Total, nil +} + +func unmarshalMCPServerResponse(res *api.HTTPResponse) (*descope.MCPServer, error) { + sres := struct { + Server *descope.MCPServer + }{} + if err := utils.Unmarshal([]byte(res.BodyStr), &sres); err != nil { + return nil, err + } + return sres.Server, nil +} + +func unmarshalMCPServerClientResponse(res *api.HTTPResponse) (*descope.MCPServerClient, error) { + cres := struct { + Client *descope.MCPServerClient + }{} + if err := utils.Unmarshal([]byte(res.BodyStr), &cres); err != nil { + return nil, err + } + return cres.Client, nil +} + +func unmarshalMCPServerClientSecret(res *api.HTTPResponse) (string, error) { + sres := struct { + Cleartext string + }{} + if err := utils.Unmarshal([]byte(res.BodyStr), &sres); err != nil { + return "", err + } + return sres.Cleartext, nil +} diff --git a/descope/internal/mgmt/mcpserver_test.go b/descope/internal/mgmt/mcpserver_test.go new file mode 100644 index 00000000..5274a24d --- /dev/null +++ b/descope/internal/mgmt/mcpserver_test.go @@ -0,0 +1,288 @@ +package mgmt + +import ( + "context" + "net/http" + "testing" + + "github.com/descope/go-sdk/descope" + "github.com/descope/go-sdk/descope/tests/helpers" + "github.com/stretchr/testify/require" +) + +func TestMCPServerCreateSuccess(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOkWithBody(func(r *http.Request) { + require.Equal(t, r.Header.Get("Authorization"), "Bearer a:key") + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + require.Equal(t, "my-server", req["name"]) + }, map[string]any{"server": map[string]any{"id": "M1", "name": "my-server"}})) + res, err := mgmt.MCPServer().Create(context.Background(), &descope.MCPServer{Name: "my-server"}) + require.NoError(t, err) + require.Equal(t, "M1", res.ID) + require.Equal(t, "my-server", res.Name) +} + +func TestMCPServerCreateError(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(nil)) + _, err := mgmt.MCPServer().Create(context.Background(), nil) + require.Error(t, err) +} + +func TestMCPServerUpdateSuccess(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOkWithBody(func(r *http.Request) { + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + server, ok := req["server"].(map[string]any) + require.True(t, ok) + require.Equal(t, "M1", server["id"]) + }, map[string]any{"server": map[string]any{"id": "M1", "name": "renamed"}})) + res, err := mgmt.MCPServer().Update(context.Background(), &descope.MCPServer{ID: "M1", Name: "renamed"}) + require.NoError(t, err) + require.Equal(t, "renamed", res.Name) +} + +func TestMCPServerUpdateError(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(nil)) + _, err := mgmt.MCPServer().Update(context.Background(), nil) + require.Error(t, err) + _, err = mgmt.MCPServer().Update(context.Background(), &descope.MCPServer{Name: "no-id"}) + require.Error(t, err) +} + +func TestMCPServerDeleteSuccess(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(func(r *http.Request) { + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + require.Equal(t, "M1", req["id"]) + })) + err := mgmt.MCPServer().Delete(context.Background(), "M1") + require.NoError(t, err) +} + +func TestMCPServerDeleteError(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(nil)) + err := mgmt.MCPServer().Delete(context.Background(), "") + require.Error(t, err) +} + +func TestMCPServerDeleteBatchSuccess(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(func(r *http.Request) { + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + ids, ok := req["ids"].([]any) + require.True(t, ok) + require.Len(t, ids, 2) + })) + err := mgmt.MCPServer().DeleteBatch(context.Background(), []string{"M1", "M2"}) + require.NoError(t, err) +} + +func TestMCPServerDeleteBatchError(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(nil)) + err := mgmt.MCPServer().DeleteBatch(context.Background(), nil) + require.Error(t, err) +} + +func TestMCPServerLoadSuccess(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOkWithBody(func(r *http.Request) { + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + require.Equal(t, "M1", req["id"]) + }, map[string]any{"server": map[string]any{"id": "M1", "name": "a"}})) + res, err := mgmt.MCPServer().Load(context.Background(), "M1") + require.NoError(t, err) + require.Equal(t, "M1", res.ID) +} + +func TestMCPServerLoadError(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(nil)) + _, err := mgmt.MCPServer().Load(context.Background(), "") + require.Error(t, err) +} + +func TestMCPServerLoadAllSuccess(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOkWithBody(func(r *http.Request) { + require.Equal(t, r.Header.Get("Authorization"), "Bearer a:key") + }, map[string]any{"servers": []map[string]any{ + {"id": "M1", "name": "a"}, + {"id": "M2", "name": "b"}, + }, "total": 2})) + res, err := mgmt.MCPServer().LoadAll(context.Background()) + require.NoError(t, err) + require.Len(t, res, 2) + require.Equal(t, "b", res[1].Name) +} + +func TestMCPServerLoadAllError(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoBadRequest(nil)) + res, err := mgmt.MCPServer().LoadAll(context.Background()) + require.Error(t, err) + require.Nil(t, res) +} + +func TestMCPServerCreateClientSuccess(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOkWithBody(func(r *http.Request) { + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + require.Equal(t, "M1", req["mcpServerId"]) + require.Equal(t, "my-client", req["name"]) + }, map[string]any{"id": "C1", "clientId": "client-123", "cleartext": "secret-xyz"})) + res, err := mgmt.MCPServer().CreateClient(context.Background(), &descope.MCPServerClientRequest{ + MCPServerID: "M1", + Name: "my-client", + }) + require.NoError(t, err) + require.Equal(t, "C1", res.ID) + require.Equal(t, "client-123", res.ClientID) + require.Equal(t, "secret-xyz", res.Cleartext) +} + +func TestMCPServerCreateClientError(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(nil)) + _, err := mgmt.MCPServer().CreateClient(context.Background(), nil) + require.Error(t, err) + _, err = mgmt.MCPServer().CreateClient(context.Background(), &descope.MCPServerClientRequest{Name: "no-server"}) + require.Error(t, err) +} + +func TestMCPServerUpdateClientSuccess(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOkWithBody(func(r *http.Request) { + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + require.Equal(t, "C1", req["id"]) + require.Equal(t, "M1", req["mcpServerId"]) + }, map[string]any{"client": map[string]any{"id": "C1", "name": "renamed"}})) + res, err := mgmt.MCPServer().UpdateClient(context.Background(), &descope.MCPServerClientRequest{ + ID: "C1", + MCPServerID: "M1", + Name: "renamed", + }) + require.NoError(t, err) + require.Equal(t, "renamed", res.Name) +} + +func TestMCPServerUpdateClientError(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(nil)) + _, err := mgmt.MCPServer().UpdateClient(context.Background(), &descope.MCPServerClientRequest{MCPServerID: "M1"}) + require.Error(t, err) + _, err = mgmt.MCPServer().UpdateClient(context.Background(), &descope.MCPServerClientRequest{ID: "C1"}) + require.Error(t, err) +} + +func TestMCPServerDeleteClientSuccess(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(func(r *http.Request) { + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + require.Equal(t, "C1", req["id"]) + require.Equal(t, "M1", req["mcpServerId"]) + })) + err := mgmt.MCPServer().DeleteClient(context.Background(), "M1", "C1") + require.NoError(t, err) +} + +func TestMCPServerDeleteClientError(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(nil)) + err := mgmt.MCPServer().DeleteClient(context.Background(), "", "C1") + require.Error(t, err) + err = mgmt.MCPServer().DeleteClient(context.Background(), "M1", "") + require.Error(t, err) +} + +func TestMCPServerDeleteClientsSuccess(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(func(r *http.Request) { + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + require.Equal(t, "M1", req["mcpServerId"]) + ids, ok := req["ids"].([]any) + require.True(t, ok) + require.Len(t, ids, 2) + })) + err := mgmt.MCPServer().DeleteClients(context.Background(), "M1", []string{"C1", "C2"}) + require.NoError(t, err) +} + +func TestMCPServerDeleteClientsError(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(nil)) + err := mgmt.MCPServer().DeleteClients(context.Background(), "M1", nil) + require.Error(t, err) + err = mgmt.MCPServer().DeleteClients(context.Background(), "", []string{"C1"}) + require.Error(t, err) +} + +func TestMCPServerLoadClientSuccess(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOkWithBody(func(r *http.Request) { + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + require.Equal(t, "M1", req["mcpServerId"]) + require.Equal(t, "C1", req["id"]) + }, map[string]any{"client": map[string]any{"id": "C1", "clientId": "client-123"}})) + res, err := mgmt.MCPServer().LoadClient(context.Background(), "M1", "C1", "") + require.NoError(t, err) + require.Equal(t, "client-123", res.ClientID) +} + +func TestMCPServerLoadClientError(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(nil)) + _, err := mgmt.MCPServer().LoadClient(context.Background(), "", "C1", "") + require.Error(t, err) + _, err = mgmt.MCPServer().LoadClient(context.Background(), "M1", "", "") + require.Error(t, err) +} + +func TestMCPServerGetClientSecretSuccess(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOkWithBody(func(r *http.Request) { + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + require.Equal(t, "C1", req["id"]) + require.Equal(t, "M1", req["mcpServerId"]) + }, map[string]any{"cleartext": "secret-xyz"})) + secret, err := mgmt.MCPServer().GetClientSecret(context.Background(), "M1", "C1") + require.NoError(t, err) + require.Equal(t, "secret-xyz", secret) +} + +func TestMCPServerGetClientSecretError(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(nil)) + _, err := mgmt.MCPServer().GetClientSecret(context.Background(), "M1", "") + require.Error(t, err) + _, err = mgmt.MCPServer().GetClientSecret(context.Background(), "", "C1") + require.Error(t, err) +} + +func TestMCPServerRotateClientSecretSuccess(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOkWithBody(func(r *http.Request) { + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + require.Equal(t, "C1", req["id"]) + }, map[string]any{"cleartext": "new-secret"})) + secret, err := mgmt.MCPServer().RotateClientSecret(context.Background(), "M1", "C1") + require.NoError(t, err) + require.Equal(t, "new-secret", secret) +} + +func TestMCPServerRotateClientSecretError(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(nil)) + _, err := mgmt.MCPServer().RotateClientSecret(context.Background(), "M1", "") + require.Error(t, err) +} + +func TestMCPServerSearchClientsSuccess(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOkWithBody(func(r *http.Request) { + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + require.Equal(t, "M1", req["mcpServerId"]) + }, map[string]any{"clients": []map[string]any{ + {"id": "C1", "name": "a"}, + }, "total": 1})) + res, total, err := mgmt.MCPServer().SearchClients(context.Background(), &descope.MCPServerClientSearchOptions{MCPServerID: "M1"}) + require.NoError(t, err) + require.Len(t, res, 1) + require.Equal(t, 1, total) +} + +func TestMCPServerSearchClientsError(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(nil)) + _, _, err := mgmt.MCPServer().SearchClients(context.Background(), nil) + require.Error(t, err) +} diff --git a/descope/internal/mgmt/mgmt.go b/descope/internal/mgmt/mgmt.go index 4d6283f7..6b973edc 100644 --- a/descope/internal/mgmt/mgmt.go +++ b/descope/internal/mgmt/mgmt.go @@ -45,6 +45,7 @@ type managementService struct { engine sdk.Engine scopeClaimMapping sdk.ScopeClaimMapping jwtTemplate sdk.JWTTemplate + mcpServer sdk.MCPServer } func NewManagement(conf ManagementParams, provider *auth.Provider, c *api.Client) *managementService { @@ -74,6 +75,7 @@ func NewManagement(conf ManagementParams, provider *auth.Provider, c *api.Client service.engine = &engine{managementBase: base} service.scopeClaimMapping = &scopeClaimMapping{managementBase: base} service.jwtTemplate = &jwtTemplate{managementBase: base} + service.mcpServer = &mcpServer{managementBase: base} return service } @@ -197,6 +199,11 @@ func (mgmt *managementService) JWTTemplate() sdk.JWTTemplate { return mgmt.jwtTemplate } +func (mgmt *managementService) MCPServer() sdk.MCPServer { + mgmt.ensureManagementKey() + return mgmt.mcpServer +} + func (mgmt *managementService) ensureManagementKey() { if mgmt.client.Conf.ManagementKey == "" { logger.LogInfo("Management key is missing, make sure to add it in the Config struct or the environment variable \"%s\"", descope.EnvironmentVariableManagementKey) // notest diff --git a/descope/sdk/mgmt.go b/descope/sdk/mgmt.go index 0586f6af..63c4b7a1 100644 --- a/descope/sdk/mgmt.go +++ b/descope/sdk/mgmt.go @@ -1498,6 +1498,67 @@ type Management interface { // Provides functions for managing JWT templates in a project. JWTTemplate() JWTTemplate + + // Provides functions for managing MCP servers and their clients in a project. + MCPServer() MCPServer +} + +// MCPServer provides functions for managing MCP servers and their clients in a project. +type MCPServer interface { + // Create a new MCP server and return the created server. + Create(ctx context.Context, server *descope.MCPServer) (*descope.MCPServer, error) + + // Update an existing MCP server (identified by its ID) and return the updated server. + // + // IMPORTANT: All fields will override whatever values are currently set + // in the existing server. Use carefully. + Update(ctx context.Context, server *descope.MCPServer) (*descope.MCPServer, error) + + // Delete an existing MCP server by id. + // + // IMPORTANT: This action is irreversible. Use carefully. + Delete(ctx context.Context, id string) error + + // DeleteBatch deletes multiple MCP servers by id in a single request. + // + // IMPORTANT: This action is irreversible. Use carefully. + DeleteBatch(ctx context.Context, ids []string) error + + // Load an MCP server by id. + Load(ctx context.Context, id string) (*descope.MCPServer, error) + + // LoadAll loads all MCP servers in the project. + LoadAll(ctx context.Context) ([]*descope.MCPServer, error) + + // CreateClient creates a new client for an MCP server. The returned response includes + // the generated client secret (Cleartext), which is only available at creation time. + CreateClient(ctx context.Context, client *descope.MCPServerClientRequest) (*descope.MCPServerClientCreateResponse, error) + + // UpdateClient updates an existing MCP server client and returns the updated client. + UpdateClient(ctx context.Context, client *descope.MCPServerClientRequest) (*descope.MCPServerClient, error) + + // DeleteClient deletes an MCP server client by id within the given MCP server. + // + // IMPORTANT: This action is irreversible. Use carefully. + DeleteClient(ctx context.Context, mcpServerID, id string) error + + // DeleteClients deletes multiple MCP server clients by id within the given MCP server. + // + // IMPORTANT: This action is irreversible. Use carefully. + DeleteClients(ctx context.Context, mcpServerID string, ids []string) error + + // LoadClient loads an MCP server client by its id or client id within the given MCP server. + LoadClient(ctx context.Context, mcpServerID, id, clientID string) (*descope.MCPServerClient, error) + + // GetClientSecret returns the cleartext secret of an MCP server client. + GetClientSecret(ctx context.Context, mcpServerID, id string) (string, error) + + // RotateClientSecret rotates the secret of an MCP server client and returns the new + // cleartext secret. The previous secret is immediately invalidated. + RotateClientSecret(ctx context.Context, mcpServerID, id string) (string, error) + + // SearchClients searches MCP server clients and returns the matching clients and the total count. + SearchClients(ctx context.Context, options *descope.MCPServerClientSearchOptions) ([]*descope.MCPServerClient, int, error) } // JWTTemplate provides functions for managing JWT templates in a project. diff --git a/descope/tests/mocks/mgmt/managementmock.go b/descope/tests/mocks/mgmt/managementmock.go index 6a3ad20d..0734039e 100644 --- a/descope/tests/mocks/mgmt/managementmock.go +++ b/descope/tests/mocks/mgmt/managementmock.go @@ -33,6 +33,7 @@ type MockManagement struct { *MockEngine *MockScopeClaimMapping *MockJWTTemplate + *MockMCPServer } func (m *MockManagement) JWT() sdk.JWT { @@ -131,6 +132,10 @@ func (m *MockManagement) JWTTemplate() sdk.JWTTemplate { return m.MockJWTTemplate } +func (m *MockManagement) MCPServer() sdk.MCPServer { + return m.MockMCPServer +} + // Mock JWT type MockJWT struct { @@ -2948,3 +2953,154 @@ func (m *MockJWTTemplate) ApplyFromLibrary(_ context.Context, request *descope.A } return m.ApplyFromLibraryResponse, m.ApplyFromLibraryError } + +// Mock MCP Server + +type MockMCPServer struct { + CreateAssert func(server *descope.MCPServer) + CreateResponse *descope.MCPServer + CreateError error + + UpdateAssert func(server *descope.MCPServer) + UpdateResponse *descope.MCPServer + UpdateError error + + DeleteAssert func(id string) + DeleteError error + + DeleteBatchAssert func(ids []string) + DeleteBatchError error + + LoadAssert func(id string) + LoadResponse *descope.MCPServer + LoadError error + + LoadAllResponse []*descope.MCPServer + LoadAllError error + + CreateClientAssert func(client *descope.MCPServerClientRequest) + CreateClientResponse *descope.MCPServerClientCreateResponse + CreateClientError error + + UpdateClientAssert func(client *descope.MCPServerClientRequest) + UpdateClientResponse *descope.MCPServerClient + UpdateClientError error + + DeleteClientAssert func(mcpServerID, id string) + DeleteClientError error + + DeleteClientsAssert func(mcpServerID string, ids []string) + DeleteClientsError error + + LoadClientAssert func(mcpServerID, id, clientID string) + LoadClientResponse *descope.MCPServerClient + LoadClientError error + + GetClientSecretAssert func(mcpServerID, id string) + GetClientSecretResponse string + GetClientSecretError error + + RotateClientSecretAssert func(mcpServerID, id string) + RotateClientSecretResponse string + RotateClientSecretError error + + SearchClientsAssert func(options *descope.MCPServerClientSearchOptions) + SearchClientsResponse []*descope.MCPServerClient + SearchClientsTotalResponse int + SearchClientsError error +} + +func (m *MockMCPServer) Create(_ context.Context, server *descope.MCPServer) (*descope.MCPServer, error) { + if m.CreateAssert != nil { + m.CreateAssert(server) + } + return m.CreateResponse, m.CreateError +} + +func (m *MockMCPServer) Update(_ context.Context, server *descope.MCPServer) (*descope.MCPServer, error) { + if m.UpdateAssert != nil { + m.UpdateAssert(server) + } + return m.UpdateResponse, m.UpdateError +} + +func (m *MockMCPServer) Delete(_ context.Context, id string) error { + if m.DeleteAssert != nil { + m.DeleteAssert(id) + } + return m.DeleteError +} + +func (m *MockMCPServer) DeleteBatch(_ context.Context, ids []string) error { + if m.DeleteBatchAssert != nil { + m.DeleteBatchAssert(ids) + } + return m.DeleteBatchError +} + +func (m *MockMCPServer) Load(_ context.Context, id string) (*descope.MCPServer, error) { + if m.LoadAssert != nil { + m.LoadAssert(id) + } + return m.LoadResponse, m.LoadError +} + +func (m *MockMCPServer) LoadAll(_ context.Context) ([]*descope.MCPServer, error) { + return m.LoadAllResponse, m.LoadAllError +} + +func (m *MockMCPServer) CreateClient(_ context.Context, client *descope.MCPServerClientRequest) (*descope.MCPServerClientCreateResponse, error) { + if m.CreateClientAssert != nil { + m.CreateClientAssert(client) + } + return m.CreateClientResponse, m.CreateClientError +} + +func (m *MockMCPServer) UpdateClient(_ context.Context, client *descope.MCPServerClientRequest) (*descope.MCPServerClient, error) { + if m.UpdateClientAssert != nil { + m.UpdateClientAssert(client) + } + return m.UpdateClientResponse, m.UpdateClientError +} + +func (m *MockMCPServer) DeleteClient(_ context.Context, mcpServerID, id string) error { + if m.DeleteClientAssert != nil { + m.DeleteClientAssert(mcpServerID, id) + } + return m.DeleteClientError +} + +func (m *MockMCPServer) DeleteClients(_ context.Context, mcpServerID string, ids []string) error { + if m.DeleteClientsAssert != nil { + m.DeleteClientsAssert(mcpServerID, ids) + } + return m.DeleteClientsError +} + +func (m *MockMCPServer) LoadClient(_ context.Context, mcpServerID, id, clientID string) (*descope.MCPServerClient, error) { + if m.LoadClientAssert != nil { + m.LoadClientAssert(mcpServerID, id, clientID) + } + return m.LoadClientResponse, m.LoadClientError +} + +func (m *MockMCPServer) GetClientSecret(_ context.Context, mcpServerID, id string) (string, error) { + if m.GetClientSecretAssert != nil { + m.GetClientSecretAssert(mcpServerID, id) + } + return m.GetClientSecretResponse, m.GetClientSecretError +} + +func (m *MockMCPServer) RotateClientSecret(_ context.Context, mcpServerID, id string) (string, error) { + if m.RotateClientSecretAssert != nil { + m.RotateClientSecretAssert(mcpServerID, id) + } + return m.RotateClientSecretResponse, m.RotateClientSecretError +} + +func (m *MockMCPServer) SearchClients(_ context.Context, options *descope.MCPServerClientSearchOptions) ([]*descope.MCPServerClient, int, error) { + if m.SearchClientsAssert != nil { + m.SearchClientsAssert(options) + } + return m.SearchClientsResponse, m.SearchClientsTotalResponse, m.SearchClientsError +} diff --git a/descope/types.go b/descope/types.go index 0889dc78..d1b0362b 100644 --- a/descope/types.go +++ b/descope/types.go @@ -1977,3 +1977,134 @@ type ApplyJWTTemplateFromLibraryRequest struct { TagsOverride []string `json:"tagsOverride,omitempty"` TemplateOverride map[string]any `json:"templateOverride,omitempty"` } + +// MCPServerDynamicClientRegistration configures dynamic client registration for an MCP server. +type MCPServerDynamicClientRegistration struct { + Enabled bool `json:"enabled,omitempty"` + DisableApprovedScopesAsDefault bool `json:"disableApprovedScopesAsDefault,omitempty"` + FlowID string `json:"flowId,omitempty"` +} + +// MCPApplicationScope is a single scope offered by an MCP server. +type MCPApplicationScope struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Optional bool `json:"optional,omitempty"` + Values []string `json:"values,omitempty"` +} + +// MCPApprovedScopes groups the scopes approved for dynamically registered MCP clients. +type MCPApprovedScopes struct { + PermissionsScopes []*MCPApplicationScope `json:"permissionsScopes,omitempty"` + AttributesScopes []*MCPApplicationScope `json:"attributesScopes,omitempty"` + ConnectionsScopes []*MCPApplicationScope `json:"connectionsScopes,omitempty"` +} + +// MCPSessionSettings configures session/token behavior for an MCP server. +type MCPSessionSettings struct { + Enabled bool `json:"enabled,omitempty"` + RefreshTokenExpiration int32 `json:"refreshTokenExpiration,omitempty"` + RefreshTokenExpirationUnit string `json:"refreshTokenExpirationUnit,omitempty"` + SessionTokenExpiration int32 `json:"sessionTokenExpiration,omitempty"` + SessionTokenExpirationUnit string `json:"sessionTokenExpirationUnit,omitempty"` + UserTemplateID string `json:"userTemplateId,omitempty"` + KeyTemplateID string `json:"keyTemplateId,omitempty"` + KeySessionTokenExpiration int32 `json:"keySessionTokenExpiration,omitempty"` + KeySessionTokenExpirationUnit string `json:"keySessionTokenExpirationUnit,omitempty"` +} + +// MCPCIMDDomainPolicy is a single client-identity-managed-domain policy entry. +type MCPCIMDDomainPolicy struct { + DomainPattern string `json:"domainPattern,omitempty"` + Enabled bool `json:"enabled,omitempty"` +} + +// MCPCIMDDomainPolicies groups CIMD domain policies. +type MCPCIMDDomainPolicies struct { + Policies []*MCPCIMDDomainPolicy `json:"policies,omitempty"` +} + +// MCPCIMDSettings configures client-identity-managed-domain behavior for an MCP server. +type MCPCIMDSettings struct { + Enabled bool `json:"enabled,omitempty"` + DomainPolicies *MCPCIMDDomainPolicies `json:"domainPolicies,omitempty"` +} + +// MCPServer is an MCP server definition in a project. It is used both as input to +// Create/Update and as the value returned by Load/LoadAll. +type MCPServer struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + DynamicRegistration *MCPServerDynamicClientRegistration `json:"dynamicRegistration,omitempty"` + AudienceWhitelist []string `json:"audienceWhitelist,omitempty"` + ApprovedScopes *MCPApprovedScopes `json:"approvedScopes,omitempty"` + ApprovedCallbackUrls []string `json:"approvedCallbackUrls,omitempty"` + LoginPageURL string `json:"loginPageURL,omitempty"` + SessionSettings *MCPSessionSettings `json:"sessionSettings,omitempty"` + Tags []string `json:"tags,omitempty"` + Logo string `json:"logo,omitempty"` + CIMDSettings *MCPCIMDSettings `json:"cimdSettings,omitempty"` + SkipConsentScreen bool `json:"skipConsentScreen,omitempty"` + ConsentFlowID string `json:"consentFlowId,omitempty"` + ConsentFlowHostingURL string `json:"consentFlowHostingURL,omitempty"` + ForceAddAllAuthorizationInfo bool `json:"forceAddAllAuthorizationInfo,omitempty"` +} + +// MCPServerClient is a client registered against an MCP server. +type MCPServerClient struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + ClientID string `json:"clientId,omitempty"` + MCPServerID string `json:"mcpServerId,omitempty"` + ApprovedCallbackUrls []string `json:"approvedCallbackUrls,omitempty"` + Scopes []string `json:"scopes,omitempty"` + Tags []string `json:"tags,omitempty"` + Logo string `json:"logo,omitempty"` + RegistrationType string `json:"registrationType,omitempty"` + Status string `json:"status,omitempty"` + ForceAddAllAuthorizationInfo bool `json:"forceAddAllAuthorizationInfo,omitempty"` + AllowedTenants []string `json:"allowedTenants,omitempty"` +} + +// MCPServerClientRequest is the input for creating or updating an MCP server client. +// ID is required for updates and ignored on create. +type MCPServerClientRequest struct { + ID string `json:"id,omitempty"` + MCPServerID string `json:"mcpServerId,omitempty"` + Name string `json:"name,omitempty"` + ApprovedCallbackUrls []string `json:"approvedCallbackUrls,omitempty"` + Scopes []string `json:"scopes,omitempty"` + Tags []string `json:"tags,omitempty"` + Logo string `json:"logo,omitempty"` + ForceAddAllAuthorizationInfo bool `json:"forceAddAllAuthorizationInfo,omitempty"` + AllowedTenants []string `json:"allowedTenants,omitempty"` +} + +// MCPServerClientCreateResponse is returned when creating an MCP server client. +// Cleartext is the generated client secret and is only returned on create. +type MCPServerClientCreateResponse struct { + ID string `json:"id,omitempty"` + ClientID string `json:"clientId,omitempty"` + Cleartext string `json:"cleartext,omitempty"` +} + +// MCPSortField specifies a sort field and direction for MCP client searches. +type MCPSortField struct { + Field string `json:"field,omitempty"` + Desc bool `json:"desc,omitempty"` +} + +// MCPServerClientSearchOptions are the filters for searching MCP server clients. +type MCPServerClientSearchOptions struct { + MCPServerID string `json:"mcpServerId,omitempty"` + Page int32 `json:"page,omitempty"` + Limit int32 `json:"limit,omitempty"` + Text string `json:"text,omitempty"` + Name string `json:"name,omitempty"` + ClientID string `json:"clientId,omitempty"` + Status string `json:"status,omitempty"` + RegistrationMethod string `json:"registrationMethod,omitempty"` + Tag string `json:"tag,omitempty"` + Sort []*MCPSortField `json:"sort,omitempty"` +}