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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ For summary details and quick references, see [README.md](README.md).

| Version | Release Date | Summary of Key Changes |
| :--- | :--- | :--- |
| **`v4.22.1`** | 2026-08-21 | 4.22.0 |
| **`v4.20.3`** | 2026-08-21 | fix: resolve typescript ERESOLVE in frontend dependencies |
| **`v4.20.2`** | 2026-08-21 | chore(deps): bump dependencies (npm, nuget, github-actions) and fix SQLCipher version conflict |
| **`v4.20.1`** | 2026-08-21 | fix(e2e): fix backend DI scope and SQLite schema syntax errors, and add comprehensive Playwright E2E coverage for User Credentials across Vault and SQLite |
Expand Down
1 change: 1 addition & 0 deletions Components/Servers/McpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public class McpServer
public string? HeadersJson { get; set; } // JSON dictionary of custom headers
public bool AutoDiscovered { get; set; } = false;
public bool AllowPassThroughAuth { get; set; } = false;
public string? DynamicAuthPrompt { get; set; }
}

public class BackendStatus
Expand Down
17 changes: 14 additions & 3 deletions Components/Servers/ServerEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public static IEndpointRouteBuilder MapServerEndpoints(this IEndpointRouteBuilde
try
{
using var conn = dbFactory.CreateConnection();
var rawServers = (await conn.QueryAsync(@"SELECT Id, DisplayName, Url, Enabled, Hidden, Type, Categories, SecretProvider, SecretItemKey, AuthShape, CustomHeaderName, ApiKey, HeadersJson FROM Servers")).ToList();
var rawServers = (await conn.QueryAsync(@"SELECT Id, DisplayName, Url, Enabled, Hidden, Type, Categories, SecretProvider, SecretItemKey, AuthShape, CustomHeaderName, ApiKey, HeadersJson, AllowPassThroughAuth, DynamicAuthPrompt FROM Servers")).ToList();
var statuses = sessionManager.BackendStatuses;

var sanitized = rawServers.Select(s =>
Expand All @@ -40,6 +40,11 @@ public static IEndpointRouteBuilder MapServerEndpoints(this IEndpointRouteBuilde
else if (s.Enabled is bool boolEnabled) isEnabled = boolEnabled;
else if (s.Enabled != null) isEnabled = Convert.ToBoolean(s.Enabled);

bool isAllowPass = false;
if (s.AllowPassThroughAuth is long longAllowPass) isAllowPass = longAllowPass != 0L;
else if (s.AllowPassThroughAuth is bool boolAllowPass) isAllowPass = boolAllowPass;
else if (s.AllowPassThroughAuth != null) isAllowPass = Convert.ToBoolean(s.AllowPassThroughAuth);

bool isHidden = false;
if (s.Hidden is long longHidden) isHidden = longHidden != 0L;
else if (s.Hidden is bool boolHidden) isHidden = boolHidden;
Expand Down Expand Up @@ -69,6 +74,8 @@ public static IEndpointRouteBuilder MapServerEndpoints(this IEndpointRouteBuilde
CustomHeaderName = (string?)s.CustomHeaderName,
HeadersJson = (string?)s.HeadersJson,
HasApiKey = !string.IsNullOrEmpty((string?)s.ApiKey),
AllowPassThroughAuth = isAllowPass,
DynamicAuthPrompt = (string?)s.DynamicAuthPrompt,
ConnectionStatus = isEnabled ? (status?.Status ?? "Disconnected") : "Disabled",
ConnectionAttempts = status?.Attempts ?? 0,
ConnectionError = status?.Error ?? string.Empty
Expand Down Expand Up @@ -173,7 +180,7 @@ public static IEndpointRouteBuilder MapServerEndpoints(this IEndpointRouteBuilde
var catJson = JsonSerializer.Serialize(server.Categories ?? new());
await conn.ExecuteAsync(@"UPDATE Servers SET DisplayName = @DisplayName, Url = @Url, Enabled = @Enabled, Hidden = @Hidden, Type = @Type,
SecretProvider = @SecretProvider, SecretItemKey = @SecretItemKey, AuthShape = @AuthShape, CustomHeaderName = @CustomHeaderName,
Categories = @Categories, ApiKey = @ApiKey, HeadersJson = @HeadersJson WHERE Id = @Id",
Categories = @Categories, ApiKey = @ApiKey, HeadersJson = @HeadersJson, AllowPassThroughAuth = @AllowPassThroughAuth, DynamicAuthPrompt = @DynamicAuthPrompt WHERE Id = @Id",
new
{
server.DisplayName,
Expand All @@ -187,6 +194,8 @@ await conn.ExecuteAsync(@"UPDATE Servers SET DisplayName = @DisplayName, Url = @
server.CustomHeaderName,
Categories = catJson,
server.ApiKey,
AllowPassThroughAuth = server.AllowPassThroughAuth ? 1 : 0,
server.DynamicAuthPrompt,
server.HeadersJson,
server.Id
});
Expand Down Expand Up @@ -240,7 +249,7 @@ await conn.ExecuteAsync(@"UPDATE Servers SET DisplayName = @DisplayName, Url = @
var catJson = JsonSerializer.Serialize(server.Categories ?? new());
var dbStart = sw.ElapsedMilliseconds;
await conn.ExecuteAsync(@"INSERT INTO Servers (Id, DisplayName, Url, Enabled, Hidden, Type, SecretProvider, SecretItemKey, AuthShape, CustomHeaderName, Categories, ApiKey, HeadersJson)
VALUES (@Id, @DisplayName, @Url, @Enabled, @Hidden, @Type, @SecretProvider, @SecretItemKey, @AuthShape, @CustomHeaderName, @Categories, @ApiKey, @HeadersJson)",
VALUES (@Id, @DisplayName, @Url, @Enabled, @Hidden, @Type, @SecretProvider, @SecretItemKey, @AuthShape, @CustomHeaderName, @Categories, @ApiKey, @HeadersJson, @AllowPassThroughAuth, @DynamicAuthPrompt)",
new
{
server.Id,
Expand All @@ -255,6 +264,8 @@ await conn.ExecuteAsync(@"INSERT INTO Servers (Id, DisplayName, Url, Enabled, Hi
server.CustomHeaderName,
Categories = catJson,
server.ApiKey,
AllowPassThroughAuth = server.AllowPassThroughAuth ? 1 : 0,
server.DynamicAuthPrompt,
server.HeadersJson
});
logger.LogInformation("DB Insert finished after {ms}ms", sw.ElapsedMilliseconds - dbStart);
Expand Down
4 changes: 2 additions & 2 deletions Core/Routing/BackendConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,9 @@ public void StartReader(Func<JsonRpcMessage, Task> onMessageReceived)
});
}

public async Task<JsonRpcResponse> SendRequestAsync(string method, string bodyJson)
public async Task<JsonRpcResponse> SendRequestAsync(string method, string bodyJson, string? targetAuthToken = null)
{
return await _transport.SendRequestAsync(method, bodyJson);
return await _transport.SendRequestAsync(method, bodyJson, targetAuthToken);
}

public async Task<JsonRpcResponse> CallMethodAsync(string method, object parameters, string? overrideId = null)
Expand Down
40 changes: 36 additions & 4 deletions Core/Routing/ToolRoutingManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ public static List<object> GetMetaModeTools()
properties = new
{
name = new { type = "string", description = "The exact name of the tool to execute (e.g., 'docker/list_containers')." },
arguments = new { type = "object", description = "The arguments JSON object expected by the target tool." }
arguments = new { type = "object", description = "The arguments JSON object expected by the target tool." },
target_auth_token = new { type = "string", description = "Optional authentication token if the backend tool requires dynamic pass-through authorization." }
},
required = new[] { "name", "arguments" }
}
Expand Down Expand Up @@ -143,7 +144,17 @@ public async Task PopulateToolsCacheAsync(string body, IEnumerable<KeyValuePair<
{
toolDict["name"] = exposedName;
if (toolDict.TryGetValue("description", out var desc))
{
toolDict["description"] = $"[{item.ServerId}] " + desc;
}

var srv = servers.FirstOrDefault(s => s.Id == item.ServerId);
if (srv != null && (srv.AllowPassThroughAuth || !string.IsNullOrEmpty(srv.DynamicAuthPrompt)))
{
var authPrompt = !string.IsNullOrEmpty(srv.DynamicAuthPrompt) ? srv.DynamicAuthPrompt : "This tool requires a target authentication token. Call with target_auth_token parameter.";
toolDict["description"] = $"{toolDict["description"]}\n\nAUTH REQUIRED: {authPrompt}";
}

serverTools.Add(toolDict);
allTools.Add(toolDict);
}
Expand Down Expand Up @@ -270,6 +281,7 @@ private async Task<object> CallToolInternalAsync(

string targetName = "";
JsonElement targetArgs = default;
string? targetAuthToken = null;

if (root.TryGetProperty("params", out var paramsProp) &&
paramsProp.TryGetProperty("arguments", out var argsProp))
Expand All @@ -282,6 +294,10 @@ private async Task<object> CallToolInternalAsync(
{
targetArgs = targetArgsProp.Clone();
}
if (argsProp.TryGetProperty("target_auth_token", out var targetAuthTokenProp))
{
targetAuthToken = targetAuthTokenProp.GetString();
}
}

if (string.IsNullOrEmpty(targetName))
Expand Down Expand Up @@ -327,7 +343,7 @@ private async Task<object> CallToolInternalAsync(

try
{
var result = await ExecuteTargetToolAsync(targetName, targetBody, dbFactory, backendConnections, servers, logger, httpClient, ensureBackendsInitializedAsync, rewriteRequestJson, cancellationToken, sessionManager, clientSessionId);
var result = await ExecuteTargetToolAsync(targetName, targetBody, targetAuthToken, dbFactory, backendConnections, servers, logger, httpClient, ensureBackendsInitializedAsync, rewriteRequestJson, cancellationToken, sessionManager, clientSessionId);
return result;
}
catch (Exception ex)
Expand All @@ -345,12 +361,13 @@ private async Task<object> CallToolInternalAsync(
}
}

return await ExecuteTargetToolAsync(toolName, body, dbFactory, backendConnections, servers, logger, httpClient, ensureBackendsInitializedAsync, rewriteRequestJson, cancellationToken, sessionManager, clientSessionId);
return await ExecuteTargetToolAsync(toolName, body, null, dbFactory, backendConnections, servers, logger, httpClient, ensureBackendsInitializedAsync, rewriteRequestJson, cancellationToken, sessionManager, clientSessionId);
}

private async Task<object> ExecuteTargetToolAsync(
string toolName,
string body,
string? targetAuthToken,
IDbConnectionFactory dbFactory,
ConcurrentDictionary<string, BackendConnection> backendConnections,
IEnumerable<McpServer> servers,
Expand Down Expand Up @@ -389,7 +406,7 @@ private async Task<object> ExecuteTargetToolAsync(

try
{
var resp = await conn.SendRequestAsync("tools/call", routingBody);
var resp = await conn.SendRequestAsync("tools/call", routingBody, targetAuthToken);
if (resp.Error != null)
{
var transformed = ToolErrorFormatter.TransformError(resp.Error, toolName, serverId);
Expand All @@ -406,6 +423,21 @@ private async Task<object> ExecuteTargetToolAsync(
}
return resp;
}
catch (System.Net.Http.HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
var srv = servers.FirstOrDefault(s => s.Id == serverId);
var prompt = (srv != null && !string.IsNullOrEmpty(srv.DynamicAuthPrompt)) ? srv.DynamicAuthPrompt : "401 Unauthorized. Please provide a valid target_auth_token via execute_tool.";
return new
{
isError = true,
content = new[] {
new {
type = "text",
text = prompt
}
}
};
}
catch (Exception ex)
{
var transformed = ToolErrorFormatter.TransformException(ex, toolName, serverId);
Expand Down
24 changes: 20 additions & 4 deletions Infrastructure/Persistence/DatabaseSeederService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ private static void ApplySqliteMigrations(IDbConnection conn, ILogger logger)
if (!serversCols.Contains("ApiKey")) conn.Execute("ALTER TABLE Servers ADD COLUMN ApiKey TEXT NULL;");
if (!serversCols.Contains("HeadersJson")) conn.Execute("ALTER TABLE Servers ADD COLUMN HeadersJson TEXT NULL;");
if (!serversCols.Contains("AutoDiscovered")) conn.Execute("ALTER TABLE Servers ADD COLUMN AutoDiscovered INTEGER DEFAULT 0;");
if (!serversCols.Contains("AllowPassThroughAuth")) conn.Execute("ALTER TABLE Servers ADD COLUMN AllowPassThroughAuth INTEGER DEFAULT 0;");
if (!serversCols.Contains("DynamicAuthPrompt")) conn.Execute("ALTER TABLE Servers ADD COLUMN DynamicAuthPrompt TEXT NULL;");
}
}

Expand Down Expand Up @@ -219,7 +221,9 @@ [CustomHeaderName] VARCHAR(100) NULL,
[Categories] NVARCHAR(MAX) NOT NULL DEFAULT '[]',
[ApiKey] NVARCHAR(MAX) NULL,
[HeadersJson] NVARCHAR(MAX) NULL,
[AutoDiscovered] BIT NOT NULL DEFAULT 0
[AutoDiscovered] BIT NOT NULL DEFAULT 0,
[AllowPassThroughAuth] BIT NOT NULL DEFAULT 0,
[DynamicAuthPrompt] NVARCHAR(MAX) NULL
);
END;

Expand Down Expand Up @@ -263,6 +267,10 @@ IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('dbo.Server
ALTER TABLE [dbo].[Servers] ADD [HeadersJson] NVARCHAR(MAX) NULL;
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('dbo.Servers') AND name = 'AutoDiscovered')
ALTER TABLE [dbo].[Servers] ADD [AutoDiscovered] BIT NOT NULL DEFAULT 0;
IF NOT EXISTS (SELECT * FROM sys.columns WHERE Name = N'AllowPassThroughAuth' AND Object_ID = Object_ID(N'dbo.Servers'))
ALTER TABLE [dbo].[Servers] ADD [AllowPassThroughAuth] BIT NOT NULL DEFAULT 0;
IF NOT EXISTS (SELECT * FROM sys.columns WHERE Name = N'DynamicAuthPrompt' AND Object_ID = Object_ID(N'dbo.Servers'))
ALTER TABLE [dbo].[Servers] ADD [DynamicAuthPrompt] NVARCHAR(MAX) NULL;
END;

IF OBJECT_ID('dbo.Settings', 'U') IS NOT NULL
Expand Down Expand Up @@ -473,6 +481,8 @@ SELECT COLUMN_NAME FROM information_schema.columns
if (!serversCols.Contains("ApiKey")) conn.Execute("ALTER TABLE `Servers` ADD COLUMN `ApiKey` LONGTEXT NULL;");
if (!serversCols.Contains("HeadersJson")) conn.Execute("ALTER TABLE `Servers` ADD COLUMN `HeadersJson` LONGTEXT NULL;");
if (!serversCols.Contains("AutoDiscovered")) conn.Execute("ALTER TABLE `Servers` ADD COLUMN `AutoDiscovered` TINYINT(1) NOT NULL DEFAULT 0;");
if (!serversCols.Contains("AllowPassThroughAuth")) conn.Execute("ALTER TABLE Servers ADD COLUMN AllowPassThroughAuth INTEGER DEFAULT 0;");
if (!serversCols.Contains("DynamicAuthPrompt")) conn.Execute("ALTER TABLE Servers ADD COLUMN DynamicAuthPrompt TEXT NULL;");
}

var settingsTableExists = conn.ExecuteScalar<int>(@"
Expand Down Expand Up @@ -629,7 +639,9 @@ CREATE TABLE IF NOT EXISTS Servers (
Categories TEXT DEFAULT '[]',
ApiKey TEXT,
HeadersJson TEXT,
AutoDiscovered INTEGER DEFAULT 0
AutoDiscovered INTEGER DEFAULT 0,
AllowPassThroughAuth INTEGER DEFAULT 0,
DynamicAuthPrompt TEXT
);

CREATE TABLE IF NOT EXISTS Settings (
Expand Down Expand Up @@ -759,7 +771,9 @@ [CustomHeaderName] VARCHAR(100) NULL,
[Categories] NVARCHAR(MAX) NOT NULL DEFAULT '[]',
[ApiKey] NVARCHAR(MAX) NULL,
[HeadersJson] NVARCHAR(MAX) NULL,
[AutoDiscovered] BIT NOT NULL DEFAULT 0
[AutoDiscovered] BIT NOT NULL DEFAULT 0,
[AllowPassThroughAuth] BIT NOT NULL DEFAULT 0,
[DynamicAuthPrompt] NVARCHAR(MAX) NULL
);
END;

Expand Down Expand Up @@ -907,7 +921,9 @@ [UpdatedAt] DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
`Categories` LONGTEXT NOT NULL,
`ApiKey` LONGTEXT NULL,
`HeadersJson` LONGTEXT NULL,
`AutoDiscovered` TINYINT(1) NOT NULL DEFAULT 0
`AutoDiscovered` TINYINT(1) NOT NULL DEFAULT 0,
`AllowPassThroughAuth` TINYINT(1) NOT NULL DEFAULT 0,
`DynamicAuthPrompt` LONGTEXT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `Settings` (
Expand Down
7 changes: 6 additions & 1 deletion Infrastructure/Transports/HttpTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,17 @@ public void StartReader(Func<JsonRpcMessage, Task> onMessageReceived)
// HTTP transport has no background reader
}

public async Task<JsonRpcResponse> SendRequestAsync(string method, string bodyJson)
public async Task<JsonRpcResponse> SendRequestAsync(string method, string bodyJson, string? targetAuthToken = null)
{
_logger.LogDebug("[JSON-RPC Gateway -> Backend {ServerId}] {Payload}", _server.Id, PiiSanitizer.SanitizePayload(bodyJson));
var content = new StringContent(bodyJson, Encoding.UTF8, "application/json");
content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
using var req = new HttpRequestMessage(HttpMethod.Post, _server.Url) { Content = content };

if (!string.IsNullOrEmpty(targetAuthToken))
{
req.Headers.Add("X-Target-Auth", targetAuthToken);
}
req.Headers.Host = "localhost";
req.Headers.Accept.Clear();
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
Expand Down
2 changes: 1 addition & 1 deletion Infrastructure/Transports/ITransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public interface ITransport : IDisposable
{
Task ConnectAsync();
void StartReader(Func<JsonRpcMessage, Task> onMessageReceived);
Task<JsonRpcResponse> SendRequestAsync(string method, string body);
Task<JsonRpcResponse> SendRequestAsync(string method, string body, string? targetAuthToken = null);
Task SendNotificationAsync(string method, string body);
Task SendResponseAsync(string responseJson);
TimeSpan RequestTimeout { get; set; }
Expand Down
Loading
Loading