From 8ce1ab8a53c8339002c04d73465a052c9b289f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Mon, 4 May 2026 18:22:49 +0400 Subject: [PATCH] Add SCIM tools to MCP API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SCIM operations (configuration, bridge, events) were only available through the GraphQL Connect API. This adds the equivalent MCP tools so MCP clients can manage SCIM provisioning: get/create/delete configuration, regenerate token, get/update bridge, and list events. Signed-off-by: Émile Ré --- pkg/server/api/mcp/v1/schema.resolvers.go | 114 +++++ pkg/server/api/mcp/v1/specification.yaml | 398 ++++++++++++++++++ .../api/mcp/v1/types/scim_configuration.go | 92 ++++ 3 files changed, 604 insertions(+) create mode 100644 pkg/server/api/mcp/v1/types/scim_configuration.go diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 1ce5c1b66..b4d5e3095 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -5107,3 +5107,117 @@ func (r *Resolver) PublishRiskListTool(ctx context.Context, req *mcp.CallToolReq DocumentVersionID: documentVersion.ID, }, nil } + +func (r *Resolver) GetSCIMConfigurationTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetSCIMConfigurationInput) (*mcp.CallToolResult, types.GetSCIMConfigurationOutput, error) { + r.MustAuthorize(ctx, input.OrganizationID, iam.ActionSCIMConfigurationGet) + + config, err := r.iamSvc.OrganizationService.GetSCIMConfiguration(ctx, input.OrganizationID) + if err != nil { + var errNotFound *iam.ErrNoSCIMConfigurationFound + if errors.As(err, &errNotFound) { + return nil, types.GetSCIMConfigurationOutput{}, fmt.Errorf("SCIM configuration not found for organization %s", input.OrganizationID) + } + panic(fmt.Errorf("cannot get SCIM configuration: %w", err)) + } + + return nil, types.GetSCIMConfigurationOutput{ScimConfiguration: types.NewSCIMConfiguration(config)}, nil +} + +func (r *Resolver) CreateSCIMConfigurationTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CreateSCIMConfigurationInput) (*mcp.CallToolResult, types.CreateSCIMConfigurationOutput, error) { + r.MustAuthorize(ctx, input.OrganizationID, iam.ActionSCIMConfigurationCreate) + + config, token, err := r.iamSvc.OrganizationService.CreateSCIMConfiguration(ctx, input.OrganizationID) + if err != nil { + return nil, types.CreateSCIMConfigurationOutput{}, fmt.Errorf("cannot create SCIM configuration: %w", err) + } + + output := types.CreateSCIMConfigurationOutput{ + ScimConfiguration: types.NewSCIMConfiguration(config), + Token: token, + } + + if input.ConnectorID != nil { + bridge, err := r.iamSvc.OrganizationService.CreateSCIMBridge(ctx, input.OrganizationID, config.ID, *input.ConnectorID) + if err != nil { + return nil, types.CreateSCIMConfigurationOutput{}, fmt.Errorf("cannot create SCIM bridge: %w", err) + } + output.ScimBridge = types.NewSCIMBridge(bridge) + } + + return nil, output, nil +} + +func (r *Resolver) DeleteSCIMConfigurationTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteSCIMConfigurationInput) (*mcp.CallToolResult, types.DeleteSCIMConfigurationOutput, error) { + r.MustAuthorize(ctx, input.OrganizationID, iam.ActionSCIMConfigurationDelete) + + err := r.iamSvc.OrganizationService.DeleteSCIMConfiguration(ctx, input.OrganizationID, input.ScimConfigurationID) + if err != nil { + return nil, types.DeleteSCIMConfigurationOutput{}, fmt.Errorf("cannot delete SCIM configuration: %w", err) + } + + return nil, types.DeleteSCIMConfigurationOutput{DeletedScimConfigurationID: input.ScimConfigurationID}, nil +} + +func (r *Resolver) RegenerateSCIMTokenTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RegenerateSCIMTokenInput) (*mcp.CallToolResult, types.RegenerateSCIMTokenOutput, error) { + r.MustAuthorize(ctx, input.ScimConfigurationID, iam.ActionSCIMConfigurationUpdate) + + config, token, err := r.iamSvc.OrganizationService.RegenerateSCIMToken(ctx, input.OrganizationID, input.ScimConfigurationID) + if err != nil { + return nil, types.RegenerateSCIMTokenOutput{}, fmt.Errorf("cannot regenerate SCIM token: %w", err) + } + + return nil, types.RegenerateSCIMTokenOutput{ + ScimConfiguration: types.NewSCIMConfiguration(config), + Token: token, + }, nil +} + +func (r *Resolver) GetSCIMBridgeTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetSCIMBridgeInput) (*mcp.CallToolResult, types.GetSCIMBridgeOutput, error) { + r.MustAuthorize(ctx, input.ID, iam.ActionSCIMBridgeGet) + + bridge, err := r.iamSvc.OrganizationService.GetSCIMBridgeByID(ctx, input.ID) + if err != nil { + var errNotFound *iam.ErrSCIMBridgeNotFound + if errors.As(err, &errNotFound) { + return nil, types.GetSCIMBridgeOutput{}, fmt.Errorf("SCIM bridge %s not found", input.ID) + } + panic(fmt.Errorf("cannot get SCIM bridge: %w", err)) + } + + return nil, types.GetSCIMBridgeOutput{ScimBridge: types.NewSCIMBridge(bridge)}, nil +} + +func (r *Resolver) UpdateSCIMBridgeTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateSCIMBridgeInput) (*mcp.CallToolResult, types.UpdateSCIMBridgeOutput, error) { + r.MustAuthorize(ctx, input.ScimBridgeID, iam.ActionSCIMBridgeUpdate) + + bridge, err := r.iamSvc.OrganizationService.UpdateSCIMBridge(ctx, input.OrganizationID, input.ScimBridgeID, input.ExcludedUserNames) + if err != nil { + return nil, types.UpdateSCIMBridgeOutput{}, fmt.Errorf("cannot update SCIM bridge: %w", err) + } + + return nil, types.UpdateSCIMBridgeOutput{ScimBridge: types.NewSCIMBridge(bridge)}, nil +} + +func (r *Resolver) ListSCIMEventsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListSCIMEventsInput) (*mcp.CallToolResult, types.ListSCIMEventsOutput, error) { + r.MustAuthorize(ctx, input.ScimConfigurationID, iam.ActionSCIMEventList) + + pageOrderBy := page.OrderBy[coredata.SCIMEventOrderField]{ + Field: coredata.SCIMEventOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if input.OrderBy != nil { + pageOrderBy = page.OrderBy[coredata.SCIMEventOrderField]{ + Field: input.OrderBy.Field, + Direction: input.OrderBy.Direction, + } + } + + cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy) + + p, err := r.iamSvc.OrganizationService.ListSCIMEventsByConfigID(ctx, input.ScimConfigurationID, cursor) + if err != nil { + panic(fmt.Errorf("cannot list SCIM events: %w", err)) + } + + return nil, types.NewListSCIMEventsOutput(p), nil +} diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 498b34433..d0d85de71 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -9879,6 +9879,344 @@ components: cookie_consent_record: $ref: "#/components/schemas/CookieConsentRecord" + SCIMBridgeType: + type: string + enum: + - GOOGLE_WORKSPACE + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.SCIMBridgeType + + SCIMBridgeState: + type: string + enum: + - PENDING + - ACTIVE + - SYNCING + - FAILED + - DISABLED + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.SCIMBridgeState + + SCIMEventOrderField: + type: string + enum: + - CREATED_AT + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.SCIMEventOrderField + + SCIMEventOrderBy: + type: object + required: + - field + - direction + properties: + field: + $ref: "#/components/schemas/SCIMEventOrderField" + description: SCIM event order field + direction: + $ref: "#/components/schemas/OrderDirection" + description: SCIM event order direction + + SCIMConfiguration: + type: object + required: + - id + - organization_id + - created_at + - updated_at + properties: + id: + $ref: "#/components/schemas/GID" + description: SCIM configuration ID + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + bridge_id: + type: + - string + - "null" + description: Associated SCIM bridge ID, if any + created_at: + type: string + format: date-time + description: Creation timestamp + updated_at: + type: string + format: date-time + description: Last update timestamp + + SCIMBridge: + type: object + required: + - id + - organization_id + - scim_configuration_id + - type + - state + - excluded_user_names + - created_at + - updated_at + properties: + id: + $ref: "#/components/schemas/GID" + description: SCIM bridge ID + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + scim_configuration_id: + $ref: "#/components/schemas/GID" + description: SCIM configuration ID + connector_id: + type: + - string + - "null" + description: Connector ID, if any + type: + $ref: "#/components/schemas/SCIMBridgeType" + description: Bridge type + state: + $ref: "#/components/schemas/SCIMBridgeState" + description: Bridge state + excluded_user_names: + type: array + items: + type: string + description: User names excluded from SCIM sync + last_synced_at: + type: + - string + - "null" + format: date-time + description: Last successful sync timestamp + created_at: + type: string + format: date-time + description: Creation timestamp + updated_at: + type: string + format: date-time + description: Last update timestamp + + SCIMEvent: + type: object + required: + - id + - organization_id + - scim_configuration_id + - method + - path + - status_code + - user_name + - ip_address + - created_at + properties: + id: + $ref: "#/components/schemas/GID" + description: SCIM event ID + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + scim_configuration_id: + $ref: "#/components/schemas/GID" + description: SCIM configuration ID + method: + type: string + description: HTTP method + path: + type: string + description: Request path + status_code: + type: integer + description: HTTP status code + request_body: + type: + - string + - "null" + description: Request body + response_body: + type: + - string + - "null" + description: Response body + error_message: + type: + - string + - "null" + description: Error message, if any + user_name: + type: string + description: SCIM user name + ip_address: + type: string + description: Client IP address + created_at: + type: string + format: date-time + description: Creation timestamp + + GetSCIMConfigurationInput: + type: object + required: + - organization_id + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + + GetSCIMConfigurationOutput: + type: object + required: + - scim_configuration + properties: + scim_configuration: + $ref: "#/components/schemas/SCIMConfiguration" + + CreateSCIMConfigurationInput: + type: object + required: + - organization_id + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + connector_id: + $ref: "#/components/schemas/GID" + description: Optional connector ID to create a SCIM bridge + + CreateSCIMConfigurationOutput: + type: object + required: + - scim_configuration + - token + properties: + scim_configuration: + $ref: "#/components/schemas/SCIMConfiguration" + scim_bridge: + $ref: "#/components/schemas/SCIMBridge" + description: SCIM bridge, if a connector ID was provided + token: + type: string + description: Plaintext SCIM bearer token (only returned at creation time) + + DeleteSCIMConfigurationInput: + type: object + required: + - organization_id + - scim_configuration_id + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + scim_configuration_id: + $ref: "#/components/schemas/GID" + description: SCIM configuration ID to delete + + DeleteSCIMConfigurationOutput: + type: object + required: + - deleted_scim_configuration_id + properties: + deleted_scim_configuration_id: + $ref: "#/components/schemas/GID" + description: Deleted SCIM configuration ID + + RegenerateSCIMTokenInput: + type: object + required: + - organization_id + - scim_configuration_id + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + scim_configuration_id: + $ref: "#/components/schemas/GID" + description: SCIM configuration ID + + RegenerateSCIMTokenOutput: + type: object + required: + - scim_configuration + - token + properties: + scim_configuration: + $ref: "#/components/schemas/SCIMConfiguration" + token: + type: string + description: New plaintext SCIM bearer token + + GetSCIMBridgeInput: + type: object + required: + - id + properties: + id: + $ref: "#/components/schemas/GID" + description: SCIM bridge ID + + GetSCIMBridgeOutput: + type: object + required: + - scim_bridge + properties: + scim_bridge: + $ref: "#/components/schemas/SCIMBridge" + + UpdateSCIMBridgeInput: + type: object + required: + - organization_id + - scim_bridge_id + - excluded_user_names + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + scim_bridge_id: + $ref: "#/components/schemas/GID" + description: SCIM bridge ID + excluded_user_names: + type: array + items: + type: string + description: User names to exclude from SCIM sync + + UpdateSCIMBridgeOutput: + type: object + required: + - scim_bridge + properties: + scim_bridge: + $ref: "#/components/schemas/SCIMBridge" + + ListSCIMEventsInput: + type: object + required: + - scim_configuration_id + properties: + scim_configuration_id: + $ref: "#/components/schemas/GID" + description: SCIM configuration ID + order_by: + $ref: "#/components/schemas/SCIMEventOrderBy" + description: SCIM event order by + size: + type: integer + description: Page size + cursor: + $ref: "#/components/schemas/CursorKey" + description: Page cursor + + ListSCIMEventsOutput: + type: object + required: + - scim_events + properties: + next_cursor: + $ref: "#/components/schemas/CursorKey" + description: Next cursor + scim_events: + type: array + items: + $ref: "#/components/schemas/SCIMEvent" + tools: - name: listOrganizations description: List all organizations the user has access to @@ -11769,3 +12107,63 @@ tools: $ref: "#/components/schemas/GetCookieConsentRecordInput" outputSchema: $ref: "#/components/schemas/GetCookieConsentRecordOutput" + - name: getSCIMConfiguration + description: Get the SCIM configuration for an organization + hints: + readonly: true + idempotent: true + inputSchema: + $ref: "#/components/schemas/GetSCIMConfigurationInput" + outputSchema: + $ref: "#/components/schemas/GetSCIMConfigurationOutput" + - name: createSCIMConfiguration + description: Create a SCIM configuration for an organization. Optionally provide a connector ID to also create a SCIM bridge. + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/CreateSCIMConfigurationInput" + outputSchema: + $ref: "#/components/schemas/CreateSCIMConfigurationOutput" + - name: deleteSCIMConfiguration + description: Delete a SCIM configuration and its associated bridge + hints: + readonly: false + destructive: true + inputSchema: + $ref: "#/components/schemas/DeleteSCIMConfigurationInput" + outputSchema: + $ref: "#/components/schemas/DeleteSCIMConfigurationOutput" + - name: regenerateSCIMToken + description: Regenerate the bearer token for a SCIM configuration + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/RegenerateSCIMTokenInput" + outputSchema: + $ref: "#/components/schemas/RegenerateSCIMTokenOutput" + - name: getSCIMBridge + description: Get a SCIM bridge by ID + hints: + readonly: true + idempotent: true + inputSchema: + $ref: "#/components/schemas/GetSCIMBridgeInput" + outputSchema: + $ref: "#/components/schemas/GetSCIMBridgeOutput" + - name: updateSCIMBridge + description: Update a SCIM bridge's excluded user names + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/UpdateSCIMBridgeInput" + outputSchema: + $ref: "#/components/schemas/UpdateSCIMBridgeOutput" + - name: listSCIMEvents + description: List SCIM events for a configuration + hints: + readonly: true + idempotent: true + inputSchema: + $ref: "#/components/schemas/ListSCIMEventsInput" + outputSchema: + $ref: "#/components/schemas/ListSCIMEventsOutput" diff --git a/pkg/server/api/mcp/v1/types/scim_configuration.go b/pkg/server/api/mcp/v1/types/scim_configuration.go new file mode 100644 index 000000000..d29be66aa --- /dev/null +++ b/pkg/server/api/mcp/v1/types/scim_configuration.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package types + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/page" +) + +func NewSCIMConfiguration(c *coredata.SCIMConfiguration) *SCIMConfiguration { + var bridgeID *string + if c.BridgeID != nil { + s := c.BridgeID.String() + bridgeID = &s + } + + return &SCIMConfiguration{ + ID: c.ID, + OrganizationID: c.OrganizationID, + BridgeID: bridgeID, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } +} + +func NewSCIMBridge(b *coredata.SCIMBridge) *SCIMBridge { + var connectorID *string + if b.ConnectorID != nil { + s := b.ConnectorID.String() + connectorID = &s + } + + return &SCIMBridge{ + ID: b.ID, + OrganizationID: b.OrganizationID, + ScimConfigurationID: b.ScimConfigurationID, + ConnectorID: connectorID, + Type: b.Type, + State: b.State, + ExcludedUserNames: b.ExcludedUserNames, + LastSyncedAt: b.LastSyncedAt, + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, + } +} + +func NewSCIMEvent(e *coredata.SCIMEvent) *SCIMEvent { + return &SCIMEvent{ + ID: e.ID, + OrganizationID: e.OrganizationID, + ScimConfigurationID: e.SCIMConfigurationID, + Method: e.Method, + Path: e.Path, + StatusCode: e.StatusCode, + RequestBody: e.RequestBody, + ResponseBody: e.ResponseBody, + ErrorMessage: e.ErrorMessage, + UserName: e.UserName, + IPAddress: e.IPAddress.String(), + CreatedAt: e.CreatedAt, + } +} + +func NewListSCIMEventsOutput(p *page.Page[*coredata.SCIMEvent, coredata.SCIMEventOrderField]) ListSCIMEventsOutput { + events := make([]*SCIMEvent, 0, len(p.Data)) + for _, e := range p.Data { + events = append(events, NewSCIMEvent(e)) + } + + var nextCursor *page.CursorKey + if len(p.Data) > 0 { + cursorKey := p.Data[len(p.Data)-1].CursorKey(p.Cursor.OrderBy.Field) + nextCursor = &cursorKey + } + + return ListSCIMEventsOutput{ + NextCursor: nextCursor, + ScimEvents: events, + } +}