From ae04aec729df7264679c9665f6c98e5b3b0bb0c3 Mon Sep 17 00:00:00 2001 From: Andrei Timar Date: Wed, 26 Aug 2026 17:43:49 +0300 Subject: [PATCH] feat(mcp): add login via oauth2 and via token --- Integrations/MCP/README.md | 75 +++++- .../Auth/GatewayAuthHandlerTests.cs | 76 ++++++ .../Auth/OidcTokenServiceTests.cs | 173 ++++++++++++ .../ScriptBee.MCP.Tests.csproj | 9 + .../ScriptBee.MCP/Auth/GatewayAuthHandler.cs | 20 ++ .../ScriptBee.MCP/Auth/IOidcTokenService.cs | 6 + .../ScriptBee.MCP/Auth/OidcTokenResponse.cs | 21 ++ .../ScriptBee.MCP/Auth/OidcTokenService.cs | 173 ++++++++++++ .../MCP/ScriptBee.MCP/Config/AuthConfig.cs | 14 + .../ScriptBee.MCP/Generated/GatewayApi.g.cs | 216 +++++++++++++++ Integrations/MCP/ScriptBee.MCP/Program.cs | 71 ++++- .../MCP/ScriptBee.MCP/ScriptBee.MCP.csproj | 1 + ScriptBee.slnx | 1 + docs/.vitepress/config.js | 1 + docs/installation/mcp.md | 72 ++++- docs/installation/vs_code_extension.md | 24 +- docs/public/gateway_swagger.json | 249 ++++++++++++++++++ 17 files changed, 1169 insertions(+), 33 deletions(-) create mode 100644 Integrations/MCP/ScriptBee.MCP.Tests/Auth/GatewayAuthHandlerTests.cs create mode 100644 Integrations/MCP/ScriptBee.MCP.Tests/Auth/OidcTokenServiceTests.cs create mode 100644 Integrations/MCP/ScriptBee.MCP.Tests/ScriptBee.MCP.Tests.csproj create mode 100644 Integrations/MCP/ScriptBee.MCP/Auth/GatewayAuthHandler.cs create mode 100644 Integrations/MCP/ScriptBee.MCP/Auth/IOidcTokenService.cs create mode 100644 Integrations/MCP/ScriptBee.MCP/Auth/OidcTokenResponse.cs create mode 100644 Integrations/MCP/ScriptBee.MCP/Auth/OidcTokenService.cs create mode 100644 Integrations/MCP/ScriptBee.MCP/Config/AuthConfig.cs diff --git a/Integrations/MCP/README.md b/Integrations/MCP/README.md index 91c66b32..a9df49f8 100644 --- a/Integrations/MCP/README.md +++ b/Integrations/MCP/README.md @@ -114,11 +114,74 @@ After building or publishing the project, point directly to the output executabl - **load-and-link-context**: Guides the AI through the context ingestion workflow. - **run-analysis**: A workflow for the AI to run an analysis and monitor it until completion. -## Configuration +## Authentication + +When the ScriptBee Gateway requires authentication, the MCP server can authenticate in several ways: + +### 1. Direct Bearer Token (VS Code / Copilot / Standalone) + +Provide a pre-existing JWT access token using the `--token` CLI argument or the `Authentication__AccessToken` / `ScriptBee__Authentication__AccessToken` environment variable. + +```json +{ + "mcpServers": { + "scriptbee": { + "command": "dotnet", + "args": [ + "run", + "--project", + "C:/Absolute/Path/To/ScriptBee.MCP.csproj", + "--", + "--stdio", + "--token", + "ey...", + "--gateway-url", + "http://localhost:5117" + ] + } + } +} +``` -The ScriptBee MCP server can be configured via command-line arguments or environment variables. Below are the available -options: +### 2. OIDC Client Credentials (Machine-to-Machine / CI) + +Configure the MCP server to automatically acquire and refresh access tokens from an OpenID Connect (OIDC) identity provider (e.g. Keycloak): + +```json +{ + "mcpServers": { + "scriptbee": { + "command": "dotnet", + "args": [ + "run", + "--project", + "C:/Absolute/Path/To/ScriptBee.MCP.csproj", + "--", + "--stdio", + "--authority", + "http://localhost:8080/realms/scriptbee", + "--client-id", + "scriptbee-mcp", + "--client-secret", + "secret-key", + "--gateway-url", + "http://localhost:5117" + ] + } + } +} +``` + +## Configuration -| Option | Description | Default | -|---------------|---------------------------------------|-------------------------| -| GatewayApiUrl | The URL of the ScriptBee Gateway API. | `http://localhost:5117` | +The ScriptBee MCP server can be configured via command-line arguments or environment variables. Below are the available options: + +| Option | CLI Argument | Description | Default | +|---------------------------------|-------------------|------------------------------------------------------------|-------------------------| +| `GatewayApiUrl` | `--gateway-url` | The URL of the ScriptBee Gateway API. | `http://localhost:5117` | +| `Authentication:AccessToken` | `--token` | Static JWT access token for authenticating with Gateway. | `null` | +| `Authentication:Authority` | `--authority` | OIDC Identity Provider authority URL. | `null` | +| `Authentication:ClientId` | `--client-id` | OIDC client ID for client credentials flow. | `null` | +| `Authentication:ClientSecret` | `--client-secret` | OIDC client secret for client credentials flow. | `null` | +| `Authentication:Scope` | `--scope` | OIDC scope requested during token acquisition. | `null` | +| `Authentication:TokenEndpoint` | `--token-endpoint`| Explicit token endpoint URL (overrides OIDC discovery). | `null` | diff --git a/Integrations/MCP/ScriptBee.MCP.Tests/Auth/GatewayAuthHandlerTests.cs b/Integrations/MCP/ScriptBee.MCP.Tests/Auth/GatewayAuthHandlerTests.cs new file mode 100644 index 00000000..08cba007 --- /dev/null +++ b/Integrations/MCP/ScriptBee.MCP.Tests/Auth/GatewayAuthHandlerTests.cs @@ -0,0 +1,76 @@ +using System.Net; +using NSubstitute; +using ScriptBee.MCP.Auth; + +namespace ScriptBee.MCP.Tests.Auth; + +public class GatewayAuthHandlerTests +{ + [Fact] + public async Task SendAsync_AttachesBearerToken_WhenTokenIsAvailable() + { + // Arrange + var tokenService = Substitute.For(); + tokenService + .GetAccessTokenAsync(Arg.Any()) + .Returns("sample-valid-token"); + + var innerHandler = new EchoHandler(); + var handler = new GatewayAuthHandler(tokenService) { InnerHandler = innerHandler }; + + using var client = new HttpClient(handler); + using var request = new HttpRequestMessage( + HttpMethod.Get, + "http://localhost:5117/api/projects" + ); + + // Act + var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.OK); + innerHandler.CapturedRequest.ShouldNotBeNull(); + innerHandler.CapturedRequest.Headers.Authorization.ShouldNotBeNull(); + innerHandler.CapturedRequest.Headers.Authorization.Scheme.ShouldBe("Bearer"); + innerHandler.CapturedRequest.Headers.Authorization.Parameter.ShouldBe("sample-valid-token"); + } + + [Fact] + public async Task SendAsync_DoesNotAttachAuthorizationHeader_WhenNoTokenIsAvailable() + { + // Arrange + var tokenService = Substitute.For(); + tokenService.GetAccessTokenAsync(Arg.Any()).Returns((string?)null); + + var innerHandler = new EchoHandler(); + var handler = new GatewayAuthHandler(tokenService) { InnerHandler = innerHandler }; + + using var client = new HttpClient(handler); + using var request = new HttpRequestMessage( + HttpMethod.Get, + "http://localhost:5117/api/projects" + ); + + // Act + var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.OK); + innerHandler.CapturedRequest.ShouldNotBeNull(); + innerHandler.CapturedRequest.Headers.Authorization.ShouldBeNull(); + } + + private sealed class EchoHandler : HttpMessageHandler + { + public HttpRequestMessage? CapturedRequest { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + CapturedRequest = request; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + } +} diff --git a/Integrations/MCP/ScriptBee.MCP.Tests/Auth/OidcTokenServiceTests.cs b/Integrations/MCP/ScriptBee.MCP.Tests/Auth/OidcTokenServiceTests.cs new file mode 100644 index 00000000..29ecb03b --- /dev/null +++ b/Integrations/MCP/ScriptBee.MCP.Tests/Auth/OidcTokenServiceTests.cs @@ -0,0 +1,173 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using ScriptBee.MCP.Auth; +using ScriptBee.MCP.Config; + +namespace ScriptBee.MCP.Tests.Auth; + +public class OidcTokenServiceTests +{ + [Fact] + public async Task GetAccessTokenAsync_ReturnsStaticAccessToken_WhenConfigured() + { + // Arrange + var config = new AuthConfig { AccessToken = "static-token-123" }; + var options = Options.Create(config); + var logger = Substitute.For>(); + using var httpClient = new HttpClient( + new TestHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)) + ); + using var service = new OidcTokenService(options, httpClient, logger); + + // Act + var token = await service.GetAccessTokenAsync(TestContext.Current.CancellationToken); + + // Assert + token.ShouldBe("static-token-123"); + } + + [Fact] + public async Task GetAccessTokenAsync_ReturnsNull_WhenAuthConfigIsMissingCredentials() + { + // Arrange + var config = new AuthConfig(); + var options = Options.Create(config); + var logger = Substitute.For>(); + using var httpClient = new HttpClient( + new TestHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)) + ); + using var service = new OidcTokenService(options, httpClient, logger); + + // Act + var token = await service.GetAccessTokenAsync(TestContext.Current.CancellationToken); + + // Assert + token.ShouldBeNull(); + } + + [Fact] + public async Task GetAccessTokenAsync_AcquiresAndCachesToken_ViaClientCredentials() + { + // Arrange + var config = new AuthConfig + { + Authority = "https://auth.example.com/realms/scriptbee", + ClientId = "test-client", + ClientSecret = "test-secret", + Scope = "api", + }; + var options = Options.Create(config); + var logger = Substitute.For>(); + + var requestCount = 0; + var handler = new TestHttpMessageHandler(req => + { + requestCount++; + if (req.RequestUri!.PathAndQuery.Contains(".well-known/openid-configuration")) + { + var discoveryDoc = JsonSerializer.Serialize( + new + { + token_endpoint = "https://auth.example.com/realms/scriptbee/protocol/openid-connect/token", + } + ); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(discoveryDoc, Encoding.UTF8, "application/json"), + }; + } + + if (req.RequestUri!.PathAndQuery.Contains("protocol/openid-connect/token")) + { + var tokenDoc = JsonSerializer.Serialize( + new OidcTokenResponse + { + AccessToken = "acquired-access-token", + ExpiresIn = 3600, + TokenType = "Bearer", + } + ); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(tokenDoc, Encoding.UTF8, "application/json"), + }; + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + using var httpClient = new HttpClient(handler); + using var service = new OidcTokenService(options, httpClient, logger); + + // Act + var firstToken = await service.GetAccessTokenAsync(TestContext.Current.CancellationToken); + var secondToken = await service.GetAccessTokenAsync(TestContext.Current.CancellationToken); + + // Assert + firstToken.ShouldBe("acquired-access-token"); + secondToken.ShouldBe("acquired-access-token"); + requestCount.ShouldBe(2); + } + + [Fact] + public async Task GetAccessTokenAsync_UsesDirectTokenEndpoint_WhenExplicitlyConfigured() + { + // Arrange + var config = new AuthConfig + { + Authority = "https://auth.example.com", + ClientId = "test-client", + ClientSecret = "test-secret", + TokenEndpoint = "https://auth.example.com/oauth/token", + }; + var options = Options.Create(config); + var logger = Substitute.For>(); + + var handler = new TestHttpMessageHandler(req => + { + if (req.RequestUri!.ToString() == "https://auth.example.com/oauth/token") + { + var tokenDoc = JsonSerializer.Serialize( + new OidcTokenResponse + { + AccessToken = "token-from-direct-endpoint", + ExpiresIn = 3600, + TokenType = "Bearer", + } + ); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(tokenDoc, Encoding.UTF8, "application/json"), + }; + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + using var httpClient = new HttpClient(handler); + using var service = new OidcTokenService(options, httpClient, logger); + + // Act + var token = await service.GetAccessTokenAsync(TestContext.Current.CancellationToken); + + // Assert + token.ShouldBe("token-from-direct-endpoint"); + } + + private sealed class TestHttpMessageHandler( + Func sendHandler + ) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + return Task.FromResult(sendHandler(request)); + } + } +} diff --git a/Integrations/MCP/ScriptBee.MCP.Tests/ScriptBee.MCP.Tests.csproj b/Integrations/MCP/ScriptBee.MCP.Tests/ScriptBee.MCP.Tests.csproj new file mode 100644 index 00000000..e3e0a3f9 --- /dev/null +++ b/Integrations/MCP/ScriptBee.MCP.Tests/ScriptBee.MCP.Tests.csproj @@ -0,0 +1,9 @@ + + + ScriptBee.MCP.Tests + + + + + + diff --git a/Integrations/MCP/ScriptBee.MCP/Auth/GatewayAuthHandler.cs b/Integrations/MCP/ScriptBee.MCP/Auth/GatewayAuthHandler.cs new file mode 100644 index 00000000..5e56b056 --- /dev/null +++ b/Integrations/MCP/ScriptBee.MCP/Auth/GatewayAuthHandler.cs @@ -0,0 +1,20 @@ +using System.Net.Http.Headers; + +namespace ScriptBee.MCP.Auth; + +public sealed class GatewayAuthHandler(IOidcTokenService tokenService) : DelegatingHandler +{ + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + var token = await tokenService.GetAccessTokenAsync(cancellationToken); + if (!string.IsNullOrWhiteSpace(token)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + return await base.SendAsync(request, cancellationToken); + } +} diff --git a/Integrations/MCP/ScriptBee.MCP/Auth/IOidcTokenService.cs b/Integrations/MCP/ScriptBee.MCP/Auth/IOidcTokenService.cs new file mode 100644 index 00000000..54b50863 --- /dev/null +++ b/Integrations/MCP/ScriptBee.MCP/Auth/IOidcTokenService.cs @@ -0,0 +1,6 @@ +namespace ScriptBee.MCP.Auth; + +public interface IOidcTokenService +{ + Task GetAccessTokenAsync(CancellationToken cancellationToken); +} diff --git a/Integrations/MCP/ScriptBee.MCP/Auth/OidcTokenResponse.cs b/Integrations/MCP/ScriptBee.MCP/Auth/OidcTokenResponse.cs new file mode 100644 index 00000000..b9640884 --- /dev/null +++ b/Integrations/MCP/ScriptBee.MCP/Auth/OidcTokenResponse.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace ScriptBee.MCP.Auth; + +public sealed class OidcTokenResponse +{ + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = string.Empty; + + [JsonPropertyName("token_type")] + public string? TokenType { get; set; } + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; set; } + + [JsonPropertyName("scope")] + public string? Scope { get; set; } +} diff --git a/Integrations/MCP/ScriptBee.MCP/Auth/OidcTokenService.cs b/Integrations/MCP/ScriptBee.MCP/Auth/OidcTokenService.cs new file mode 100644 index 00000000..1a04ed23 --- /dev/null +++ b/Integrations/MCP/ScriptBee.MCP/Auth/OidcTokenService.cs @@ -0,0 +1,173 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using ScriptBee.MCP.Config; + +namespace ScriptBee.MCP.Auth; + +public sealed class OidcTokenService : IOidcTokenService, IDisposable +{ + private readonly AuthConfig _config; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly SemaphoreSlim _semaphore = new(1, 1); + + private string? _cachedToken; + private DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue; + private string? _discoveredTokenEndpoint; + + public OidcTokenService( + IOptions authConfigOptions, + HttpClient httpClient, + ILogger logger + ) + { + _config = authConfigOptions.Value; + _httpClient = httpClient; + _logger = logger; + } + + public async Task GetAccessTokenAsync(CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(_config.AccessToken)) + { + return _config.AccessToken; + } + + if ( + string.IsNullOrWhiteSpace(_config.ClientId) + || string.IsNullOrWhiteSpace(_config.ClientSecret) + || string.IsNullOrWhiteSpace(_config.Authority) + ) + { + return null; + } + + if (_cachedToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + { + return _cachedToken; + } + + await _semaphore.WaitAsync(cancellationToken); + try + { + if (_cachedToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + { + return _cachedToken; + } + + var tokenEndpoint = await ResolveTokenEndpointAsync(cancellationToken); + var tokenResponse = await RequestClientCredentialsTokenAsync( + tokenEndpoint, + cancellationToken + ); + + _cachedToken = tokenResponse.AccessToken; + var expiresInSeconds = tokenResponse.ExpiresIn > 0 ? tokenResponse.ExpiresIn : 3600; + _tokenExpiry = DateTimeOffset.UtcNow.AddSeconds(Math.Max(expiresInSeconds - 60, 30)); + + return _cachedToken; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to acquire OIDC token using client credentials."); + throw; + } + finally + { + _semaphore.Release(); + } + } + + private async Task ResolveTokenEndpointAsync(CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(_discoveredTokenEndpoint)) + { + return _discoveredTokenEndpoint; + } + + if (!string.IsNullOrWhiteSpace(_config.TokenEndpoint)) + { + _discoveredTokenEndpoint = _config.TokenEndpoint; + return _discoveredTokenEndpoint; + } + + var authority = _config.Authority!.TrimEnd('/'); + var discoveryUrl = $"{authority}/.well-known/openid-configuration"; + + try + { + var discoveryDoc = await _httpClient.GetFromJsonAsync( + discoveryUrl, + cancellationToken + ); + + if ( + discoveryDoc != null + && discoveryDoc.RootElement.TryGetProperty( + "token_endpoint", + out var tokenEndpointProp + ) + ) + { + var endpoint = tokenEndpointProp.GetString(); + if (!string.IsNullOrWhiteSpace(endpoint)) + { + _discoveredTokenEndpoint = endpoint; + return _discoveredTokenEndpoint; + } + } + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to fetch OpenID discovery document from {DiscoveryUrl}, falling back to default token endpoint.", + discoveryUrl + ); + } + + _discoveredTokenEndpoint = $"{authority}/protocol/openid-connect/token"; + return _discoveredTokenEndpoint; + } + + private async Task RequestClientCredentialsTokenAsync( + string tokenEndpoint, + CancellationToken cancellationToken + ) + { + var parameters = new Dictionary + { + { "grant_type", "client_credentials" }, + { "client_id", _config.ClientId! }, + { "client_secret", _config.ClientSecret! }, + }; + + if (!string.IsNullOrWhiteSpace(_config.Scope)) + { + parameters.Add("scope", _config.Scope); + } + + using var requestContent = new FormUrlEncodedContent(parameters); + using var response = await _httpClient.PostAsync( + tokenEndpoint, + requestContent, + cancellationToken + ); + + response.EnsureSuccessStatusCode(); + + var tokenResponse = await response.Content.ReadFromJsonAsync( + cancellationToken: cancellationToken + ); + + return tokenResponse + ?? throw new InvalidOperationException( + "Received empty token response from authorization server." + ); + } + + public void Dispose() + { + _semaphore.Dispose(); + } +} diff --git a/Integrations/MCP/ScriptBee.MCP/Config/AuthConfig.cs b/Integrations/MCP/ScriptBee.MCP/Config/AuthConfig.cs new file mode 100644 index 00000000..5502be7d --- /dev/null +++ b/Integrations/MCP/ScriptBee.MCP/Config/AuthConfig.cs @@ -0,0 +1,14 @@ +namespace ScriptBee.MCP.Config; + +public sealed class AuthConfig +{ + public const string SectionName = "Authentication"; + + public string? Authority { get; set; } + public string? ClientId { get; set; } + public string? ClientSecret { get; set; } + public string? Scope { get; set; } + public string? AccessToken { get; set; } + public string? TokenEndpoint { get; set; } + public bool RequireHttpsMetadata { get; set; } = true; +} diff --git a/Integrations/MCP/ScriptBee.MCP/Generated/GatewayApi.g.cs b/Integrations/MCP/ScriptBee.MCP/Generated/GatewayApi.g.cs index 2bb99a37..77ec01f9 100644 --- a/Integrations/MCP/ScriptBee.MCP/Generated/GatewayApi.g.cs +++ b/Integrations/MCP/ScriptBee.MCP/Generated/GatewayApi.g.cs @@ -94,6 +94,69 @@ public partial interface IGatewayApi [Get("/api/projects/{projectId}/permissions")] Task Permissions(string projectId, CancellationToken cancellationToken = default); + /// Get project members + /// Returns all users and groups with their roles for the project. + /// projectId parameter + /// The cancellation token to cancel the request. + /// OK + /// Thrown when the request returns a non-success status code. + [Headers("Accept: application/json")] + [Get("/api/projects/{projectId}/members")] + Task MembersGet(string projectId, CancellationToken cancellationToken = default); + + /// Update project member role + /// Assigns or updates the role for a user or group on the project. + /// projectId parameter + /// memberId parameter + /// body parameter + /// The cancellation token to cancel the request. + /// A that completes when the request is finished. + /// + /// Thrown when the request returns a non-success status code: + /// + /// + /// Status + /// Description + /// + /// + /// 400 + /// Bad Request + /// + /// + /// + [Headers("Accept: application/problem+json", "Content-Type: application/json")] + [Put("/api/projects/{projectId}/members/{memberId}")] + Task MembersPut(string projectId, string memberId, [Body] UpdateProjectMemberCommand body, CancellationToken cancellationToken = default); + + /// Remove project member + /// Removes a user or group's access from the project. + /// projectId parameter + /// memberId parameter + /// memberType parameter + /// The cancellation token to cancel the request. + /// A that completes when the request is finished. + /// Thrown when the request returns a non-success status code. + [Delete("/api/projects/{projectId}/members/{memberId}")] + Task MembersDelete(string projectId, string memberId, [Query] string memberType, CancellationToken cancellationToken = default); + + /// Get all users + /// Returns all registered users for search-ahead. + /// The cancellation token to cancel the request. + /// OK + /// Thrown when the request returns a non-success status code. + [Headers("Accept: application/json")] + [Get("/api/users")] + Task Users(CancellationToken cancellationToken = default); + + /// Get available roles + /// Returns all roles available for assignment, as defined in the authorization system. + /// The cancellation token to cancel the request. + /// OK + /// Thrown when the request returns a non-success status code. + [Headers("Accept: application/json")] + [Get("/api/roles")] + Task Roles(CancellationToken cancellationToken = default); + /// Create a new script /// Creates a new script within the specified project. /// projectId parameter @@ -1561,6 +1624,25 @@ public IDictionary AdditionalProperties } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class GetAllUsersResponse + { + + [JsonPropertyName("users")] + [System.ComponentModel.DataAnnotations.Required] + public ICollection Users { get; set; } = new System.Collections.ObjectModel.Collection(); + + private IDictionary _additionalProperties; + + [JsonExtensionData] + public IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new Dictionary()); } + set { _additionalProperties = value; } + } + + } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial class GetAnalysisResultConsole { @@ -1645,6 +1727,25 @@ public IDictionary AdditionalProperties } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class GetAvailableRolesResponse + { + + [JsonPropertyName("roles")] + [System.ComponentModel.DataAnnotations.Required] + public ICollection Roles { get; set; } = new System.Collections.ObjectModel.Collection(); + + private IDictionary _additionalProperties; + + [JsonExtensionData] + public IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new Dictionary()); } + set { _additionalProperties = value; } + } + + } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial class GetAvailableScriptTypesResponse { @@ -1796,6 +1897,25 @@ public IDictionary AdditionalProperties } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class GetProjectMembersResponse + { + + [JsonPropertyName("members")] + [System.ComponentModel.DataAnnotations.Required] + public ICollection Members { get; set; } = new System.Collections.ObjectModel.Collection(); + + private IDictionary _additionalProperties; + + [JsonExtensionData] + public IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new Dictionary()); } + set { _additionalProperties = value; } + } + + } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial class GetScriptDataResponse { @@ -2564,6 +2684,56 @@ public IDictionary AdditionalProperties } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ProjectMember + { + + [JsonPropertyName("memberId")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string MemberId { get; set; } + + [JsonPropertyName("memberType")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string MemberType { get; set; } + + [JsonPropertyName("role")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Role { get; set; } + + private IDictionary _additionalProperties; + + [JsonExtensionData] + public IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class RoleInfo + { + + [JsonPropertyName("id")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Id { get; set; } + + [JsonPropertyName("description")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Description { get; set; } + + private IDictionary _additionalProperties; + + [JsonExtensionData] + public IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new Dictionary()); } + set { _additionalProperties = value; } + } + + } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial class ScriptData { @@ -2671,6 +2841,29 @@ public IDictionary AdditionalProperties } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class UpdateProjectMemberCommand + { + + [JsonPropertyName("role")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Role { get; set; } + + [JsonPropertyName("memberType")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string MemberType { get; set; } + + private IDictionary _additionalProperties; + + [JsonExtensionData] + public IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new Dictionary()); } + set { _additionalProperties = value; } + } + + } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial class UpdateScriptCommand { @@ -2715,6 +2908,29 @@ public IDictionary AdditionalProperties } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class UserInfo + { + + [JsonPropertyName("id")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Id { get; set; } + + [JsonPropertyName("name")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Name { get; set; } + + private IDictionary _additionalProperties; + + [JsonExtensionData] + public IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new Dictionary()); } + set { _additionalProperties = value; } + } + + } + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] [JsonConverter(typeof(JsonStringEnumConverter))] public enum InstalledGatewayPluginExtensionPointOutletBaseInstalledGatewayPluginFilePreviewerOutletType diff --git a/Integrations/MCP/ScriptBee.MCP/Program.cs b/Integrations/MCP/ScriptBee.MCP/Program.cs index ea1f12a1..23da10d7 100644 --- a/Integrations/MCP/ScriptBee.MCP/Program.cs +++ b/Integrations/MCP/ScriptBee.MCP/Program.cs @@ -1,22 +1,39 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; using Refit; +using ScriptBee.MCP.Auth; +using ScriptBee.MCP.Config; using ScriptBee.MCP.Gateway.Generated; using ScriptBee.MCP.Prompts; using ScriptBee.MCP.Resources; using ScriptBee.MCP.Tools; using Serilog; +var switchMappings = new Dictionary +{ + { "--token", "Authentication:AccessToken" }, + { "--client-id", "Authentication:ClientId" }, + { "--client-secret", "Authentication:ClientSecret" }, + { "--authority", "Authentication:Authority" }, + { "--scope", "Authentication:Scope" }, + { "--token-endpoint", "Authentication:TokenEndpoint" }, + { "--gateway-url", "GatewayApiUrl" }, +}; + if (args.Contains("--stdio")) { - await RunAsStdioServer(args); + await RunAsStdioServer(args, switchMappings); return; } -await RunAsHttpServer(args); +await RunAsHttpServer(args, switchMappings); return; -static async Task RunAsStdioServer(string[] args) +static async Task RunAsStdioServer(string[] args, Dictionary switchMappings) { var host = Host.CreateApplicationBuilder(args); + host.Configuration.AddCommandLine(args, switchMappings); + host.Configuration.AddEnvironmentVariables("ScriptBee__"); host.Logging.ClearProviders(); host.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Warning); @@ -26,11 +43,13 @@ static async Task RunAsStdioServer(string[] args) await host.Build().RunAsync(); } -static async Task RunAsHttpServer(string[] args) +static async Task RunAsHttpServer(string[] args, Dictionary switchMappings) { Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateBootstrapLogger(); var builder = WebApplication.CreateBuilder(args); + builder.Configuration.AddCommandLine(args, switchMappings); + builder.Configuration.AddEnvironmentVariables("ScriptBee__"); builder.Host.UseSerilog( (ctx, _, config) => config.ReadFrom.Configuration(ctx.Configuration).WriteTo.Console() @@ -38,25 +57,57 @@ static async Task RunAsHttpServer(string[] args) builder.Services.AddServices(builder.Configuration); + var authConfig = builder.Configuration.GetSection(AuthConfig.SectionName).Get(); var app = builder.Build(); - app.MapMcp("/mcp"); + if (!string.IsNullOrWhiteSpace(authConfig?.Authority)) + { + app.UseAuthentication(); + app.UseAuthorization(); + app.MapMcp("/mcp").RequireAuthorization(); + } + else + { + app.MapMcp("/mcp"); + } await app.RunAsync(); } internal static class ServicesExtensions { - public static void AddServices( - this IServiceCollection services, - ConfigurationManager configuration - ) + public static void AddServices(this IServiceCollection services, IConfiguration configuration) { + services.Configure(configuration.GetSection(AuthConfig.SectionName)); + + var authConfig = configuration.GetSection(AuthConfig.SectionName).Get(); + if (!string.IsNullOrWhiteSpace(authConfig?.Authority)) + { + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = authConfig.Authority; + options.RequireHttpsMetadata = authConfig.RequireHttpsMetadata; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = false, + ValidIssuer = authConfig.Authority, + }; + }); + services.AddAuthorization(); + } + + services.AddHttpClient(); + services.AddTransient(); + services .AddRefitClient() .ConfigureHttpClient(c => c.BaseAddress = new Uri(configuration["GatewayApiUrl"] ?? "http://localhost:5117") - ); + ) + .AddHttpMessageHandler(); services .AddMcpServer() diff --git a/Integrations/MCP/ScriptBee.MCP/ScriptBee.MCP.csproj b/Integrations/MCP/ScriptBee.MCP/ScriptBee.MCP.csproj index 701a8b5b..804c1857 100644 --- a/Integrations/MCP/ScriptBee.MCP/ScriptBee.MCP.csproj +++ b/Integrations/MCP/ScriptBee.MCP/ScriptBee.MCP.csproj @@ -5,6 +5,7 @@ + diff --git a/ScriptBee.slnx b/ScriptBee.slnx index 1fe836a0..21db3190 100644 --- a/ScriptBee.slnx +++ b/ScriptBee.slnx @@ -9,6 +9,7 @@ + diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index 67ea204b..13df8ea4 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -28,6 +28,7 @@ export default defineConfig({ { text: 'Docker Compose', link: '/installation/docker_installation' }, { text: 'Kubernetes', link: '/installation/kubernetes_installation' }, { text: 'VS Code', link: '/installation/vs_code_extension' }, + { text: 'MCP Server', link: '/installation/mcp' }, ], }, ], diff --git a/docs/installation/mcp.md b/docs/installation/mcp.md index f423be4d..8f9ea150 100644 --- a/docs/installation/mcp.md +++ b/docs/installation/mcp.md @@ -1,7 +1,7 @@ # Model Context Protocol (MCP) Integration ScriptBee provides an official MCP Server that exposes its capabilities—like project management, data context loading, -and script analysis—to AI clients (e.g. Claude Code, Cursor, and VS Code). +and script analysis—to AI clients (e.g. Claude Code, Cursor, GitHub Copilot, and VS Code). ## Requirements @@ -33,15 +33,15 @@ _(Replace `5094` with the actual port the MCP server is running on)_ ### Using Docker Transport (For IDEs and Claude Code) If you prefer not to manage a local .NET runtime or compile the executable manually, you can run the ScriptBee MCP -server directly from its official Docker image (dxworks/scriptbee-mcp). +server directly from its official Docker image (`dxworks/scriptbee-mcp`). Since local IDE clients and Claude Code spawn the MCP server as a subprocess via standard input/output, you must run the -Docker container in interactive mode (-i) and pass the --stdio flag to the container. +Docker container in interactive mode (`-i`) and pass the `--stdio` flag to the container. -#### Configuration (mcp.json) +#### Configuration (`mcp.json`) -Add the following configuration to your mcp.json file. This tells your client to spin up the Docker container on demand, -while passing the GatewayApiUrl environment variable to point the MCP server to your ScriptBee backend: +Add the following configuration to your `mcp.json` file. This tells your client to spin up the Docker container on demand, +while passing the `GatewayApiUrl` environment variable to point the MCP server to your ScriptBee backend: ```json { @@ -64,7 +64,7 @@ while passing the GatewayApiUrl environment variable to point the MCP server to > [!NOTE] > Networking Note: If your ScriptBee Gateway API is also running inside a Docker container on the same machine, -> replacing localhost with host.docker.internal (e.g., -e GatewayApiUrl=http://host.docker.internal:5117) will allow the +> replacing `localhost` with `host.docker.internal` (e.g., `-e GatewayApiUrl=http://host.docker.internal:5117`) will allow the > MCP container to communicate with it. ### Using Stdio Transport (For IDEs and Claude Code) @@ -84,7 +84,7 @@ equivalent configuration file: "mcpServers": { "scriptbee": { "command": "/absolute/path/to/ScriptBee.MCP.exe", - "args": ["--stdio"] + "args": ["--stdio", "--gateway-url", "http://localhost:5117"] } } } @@ -93,6 +93,50 @@ equivalent configuration file: _(On macOS/Linux, the command would be the path to the `ScriptBee.MCP` binary without the `.exe` extension, or `dotnet` with the path to `ScriptBee.MCP.dll` as the first argument)._ +## Authentication + +When the ScriptBee Gateway requires authentication, the MCP server can authenticate in several ways: + +### 1. Direct Bearer Token (VS Code / Copilot / Standalone) + +Provide a pre-existing JWT access token using the `--token` CLI argument or the `Authentication__AccessToken` / `ScriptBee__Authentication__AccessToken` environment variable: + +```json +{ + "mcpServers": { + "scriptbee": { + "command": "/absolute/path/to/ScriptBee.MCP.exe", + "args": ["--stdio", "--token", "ey...", "--gateway-url", "http://localhost:5117"] + } + } +} +``` + +### 2. OIDC Client Credentials (Machine-to-Machine / CI) + +Configure the MCP server to automatically acquire and refresh access tokens from an OpenID Connect (OIDC) identity provider (e.g. Keycloak): + +```json +{ + "mcpServers": { + "scriptbee": { + "command": "/absolute/path/to/ScriptBee.MCP.exe", + "args": [ + "--stdio", + "--authority", + "http://localhost:8080/realms/scriptbee", + "--client-id", + "scriptbee-mcp", + "--client-secret", + "your-client-secret", + "--gateway-url", + "http://localhost:5117" + ] + } + } +} +``` + ## Capabilities The ScriptBee MCP server exposes the following primitives to the AI: @@ -109,6 +153,12 @@ The ScriptBee MCP server exposes the following primitives to the AI: The ScriptBee MCP server can be configured via command-line arguments or environment variables. Below are the available options: -| Option | Description | Default | -| ------------- | ------------------------------------- | ----------------------- | -| GatewayApiUrl | The URL of the ScriptBee Gateway API. | `http://localhost:5117` | +| Option | CLI Argument | Description | Default | +| ------------------------------ | ------------------ | -------------------------------------------------------- | ----------------------- | +| `GatewayApiUrl` | `--gateway-url` | The URL of the ScriptBee Gateway API. | `http://localhost:5117` | +| `Authentication:AccessToken` | `--token` | Static JWT access token for authenticating with Gateway. | `null` | +| `Authentication:Authority` | `--authority` | OIDC Identity Provider authority URL. | `null` | +| `Authentication:ClientId` | `--client-id` | OIDC client ID for client credentials flow. | `null` | +| `Authentication:ClientSecret` | `--client-secret` | OIDC client secret for client credentials flow. | `null` | +| `Authentication:Scope` | `--scope` | OIDC scope requested during token acquisition. | `null` | +| `Authentication:TokenEndpoint` | `--token-endpoint` | Explicit token endpoint URL (overrides OIDC discovery). | `null` | diff --git a/docs/installation/vs_code_extension.md b/docs/installation/vs_code_extension.md index 796d9651..52e68253 100644 --- a/docs/installation/vs_code_extension.md +++ b/docs/installation/vs_code_extension.md @@ -45,12 +45,24 @@ A dedicated **ScriptBee panel** is added to the VS Code Activity Bar. From here You can define multiple connections to different ScriptBee backends (e.g., local development, staging, production). -| Action | How | -| ------------------------ | --------------------------------------------------- | -| Add a connection | Click the `+` icon in the ScriptBee panel title bar | -| Edit a connection | Click the pencil icon next to a connection | -| Switch active connection | Click the checkmark icon next to a connection | -| Delete a connection | Click the trash icon next to a connection | +| Action | How | +| ------------------------ | ------------------------------------------------------ | +| Add a connection | Click the `+` icon in the ScriptBee panel title bar | +| Edit a connection | Click the pencil icon next to a connection | +| Switch active connection | Click the checkmark icon next to a connection | +| Delete a connection | Click the trash icon next to a connection | +| Login (OIDC / PKCE) | Right-click connection and select **Login** | +| Set Access Token | Right-click connection and select **Set Access Token** | +| Logout | Right-click connection and select **Logout** | + +### Authentication + +The extension supports connecting to ScriptBee instances configured with OpenID Connect (OIDC) authentication: + +- **Interactive Login (PKCE Flow)**: Run `ScriptBee: Login` or select Login from the connection context menu. The extension starts a local loopback server and opens the browser to authenticate with the configured Identity Provider (e.g. Keycloak). +- **Secure Token Storage**: Access and refresh tokens are stored securely in VS Code's `SecretStorage` and refreshed automatically. +- **Manual Token Entry**: Use `ScriptBee: Set Access Token` to specify a pre-generated Bearer token. +- **Development Mode**: If the ScriptBee backend runs in development mode (`AuthMode: Development`), authentication is automatically bypassed. ### Project Selection diff --git a/docs/public/gateway_swagger.json b/docs/public/gateway_swagger.json index 0147f406..614bb2fd 100644 --- a/docs/public/gateway_swagger.json +++ b/docs/public/gateway_swagger.json @@ -165,6 +165,159 @@ } } }, + "/api/projects/{projectId}/members": { + "get": { + "tags": ["Projects", "Access"], + "summary": "Get project members", + "description": "Returns all users and groups with their roles for the project.", + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetProjectMembersResponse" + } + } + } + } + } + } + }, + "/api/projects/{projectId}/members/{memberId}": { + "put": { + "tags": ["Projects", "Access"], + "summary": "Update project member role", + "description": "Assigns or updates the role for a user or group on the project.", + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "memberId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProjectMemberCommand" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": ["Projects", "Access"], + "summary": "Remove project member", + "description": "Removes a user or group's access from the project.", + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "memberId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "memberType", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/api/users": { + "get": { + "tags": ["Users"], + "summary": "Get all users", + "description": "Returns all registered users for search-ahead.", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAllUsersResponse" + } + } + } + } + } + } + }, + "/api/roles": { + "get": { + "tags": ["Roles"], + "summary": "Get available roles", + "description": "Returns all roles available for assignment, as defined in the authorization system.", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAvailableRolesResponse" + } + } + } + } + } + } + }, "/api/projects/{projectId}/scripts": { "post": { "tags": ["Scripts"], @@ -2664,6 +2817,18 @@ } } }, + "GetAllUsersResponse": { + "required": ["users"], + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserInfo" + } + } + } + }, "GetAnalysisResultConsole": { "required": ["content"], "type": "object", @@ -2712,6 +2877,18 @@ } } }, + "GetAvailableRolesResponse": { + "required": ["roles"], + "type": "object", + "properties": { + "roles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RoleInfo" + } + } + } + }, "GetAvailableScriptTypesResponse": { "required": ["data"], "type": "object", @@ -2814,6 +2991,18 @@ } } }, + "GetProjectMembersResponse": { + "required": ["members"], + "type": "object", + "properties": { + "members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectMember" + } + } + } + }, "GetScriptDataResponse": { "required": ["data"], "type": "object", @@ -3381,6 +3570,33 @@ }, "description": "Represents an instance of a project execution." }, + "ProjectMember": { + "required": ["memberId", "memberType", "role"], + "type": "object", + "properties": { + "memberId": { + "type": "string" + }, + "memberType": { + "type": "string" + }, + "role": { + "type": "string" + } + } + }, + "RoleInfo": { + "required": ["id", "description"], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, "ScriptData": { "required": ["id", "name", "path", "scriptLanguage", "parameters"], "type": "object", @@ -3444,6 +3660,18 @@ }, "description": "Command used to trigger a script analysis." }, + "UpdateProjectMemberCommand": { + "required": ["role", "memberType"], + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "memberType": { + "type": "string" + } + } + }, "UpdateScriptCommand": { "required": ["name", "parameters"], "type": "object", @@ -3473,6 +3701,18 @@ } } } + }, + "UserInfo": { + "required": ["id", "name"], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } } } }, @@ -3483,6 +3723,15 @@ { "name": "Permissions" }, + { + "name": "Access" + }, + { + "name": "Users" + }, + { + "name": "Roles" + }, { "name": "Scripts" },