Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 69 additions & 6 deletions Integrations/MCP/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Original file line number Diff line number Diff line change
@@ -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<IOidcTokenService>();
tokenService
.GetAccessTokenAsync(Arg.Any<CancellationToken>())
.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<IOidcTokenService>();
tokenService.GetAccessTokenAsync(Arg.Any<CancellationToken>()).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<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken
)
{
CapturedRequest = request;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
}
173 changes: 173 additions & 0 deletions Integrations/MCP/ScriptBee.MCP.Tests/Auth/OidcTokenServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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<ILogger<OidcTokenService>>();
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<ILogger<OidcTokenService>>();
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<ILogger<OidcTokenService>>();

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<ILogger<OidcTokenService>>();

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<HttpRequestMessage, HttpResponseMessage> sendHandler
) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken
)
{
return Task.FromResult(sendHandler(request));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>ScriptBee.MCP.Tests</RootNamespace>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\ScriptBee.MCP\ScriptBee.MCP.csproj" />
</ItemGroup>
</Project>
20 changes: 20 additions & 0 deletions Integrations/MCP/ScriptBee.MCP/Auth/GatewayAuthHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Net.Http.Headers;

namespace ScriptBee.MCP.Auth;

public sealed class GatewayAuthHandler(IOidcTokenService tokenService) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> 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);
}
}
6 changes: 6 additions & 0 deletions Integrations/MCP/ScriptBee.MCP/Auth/IOidcTokenService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace ScriptBee.MCP.Auth;

public interface IOidcTokenService
{
Task<string?> GetAccessTokenAsync(CancellationToken cancellationToken);
}
21 changes: 21 additions & 0 deletions Integrations/MCP/ScriptBee.MCP/Auth/OidcTokenResponse.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
Loading
Loading