Remote MCP server: Streamable HTTP transport, Container App deployment, MCP user access control

- MCP server: stdio → Streamable HTTP (Express) with /health endpoint
- Auth: device code flow → token pass-through (JWT from Claude Code client)
- Dockerfile (multi-stage, node:22-slim) + .dockerignore
- Bicep template: ACR + Container App Environment + Container App (scale-to-zero)
- GitHub Actions: deploy-mcp.yml workflow, all backend workflows migrated to SP auth
- Removed unused dev/main deploy workflows
- Backend: McpAccessPolicy enum (Disabled/WhitelistOnly/AllMembers)
- Backend: McpUsers table + McpUserService (5min cache) + McpUserFunction (CRUD + access check)
- Backend: 6 endpoints registered in EndpointAccessPolicyCatalog
- Web UI: MCP Users management section at /settings/access/mcp-users
- Web UI: user list, add/remove/enable/disable, policy info banner

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-03-28 08:09:58 +01:00
parent 72d000e9a8
commit 8a4a7cf255
33 changed files with 1495 additions and 461 deletions
+5 -1
View File
@@ -11,6 +11,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Login to Azure
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
@@ -30,4 +35,3 @@ jobs:
with:
app-name: autopilotmonitor-api
package: ./publish
publish-profile: ${{ secrets.AZURE_FUNCTIONAPP_PUBLISH_PROFILE }}
-35
View File
@@ -1,35 +0,0 @@
name: Deploy DEV Backend (Azure Functions)
on:
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
name: Build and Deploy DEV Backend
steps:
- uses: actions/checkout@v4
with:
ref: dev
- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj
- name: Build
run: dotnet build src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj --configuration Release --no-restore
- name: Publish
run: dotnet publish src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj --configuration Release --output ./publish --no-build
- name: Deploy to Azure Functions
uses: Azure/functions-action@v1
with:
app-name: autopilotmonitor-api
package: ./publish
publish-profile: ${{ secrets.AZURE_FUNCTIONAPP_PUBLISH_PROFILE }}
-34
View File
@@ -1,34 +0,0 @@
name: Deploy DEV Web (Static Web App)
on:
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
name: Build and Deploy DEV Web
steps:
- uses: actions/checkout@v4
with:
ref: dev
submodules: true
lfs: false
- name: Build And Deploy
id: builddeploy
uses: Azure/static-web-apps-deploy@v1
env:
NEXT_PUBLIC_ENTRA_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_CLIENT_ID }}
NEXT_PUBLIC_ENTRA_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_REDIRECT_URI }}
NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI }}
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL: ${{ vars.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL }}
NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING: ${{ secrets.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING }}
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_BRAVE_GRASS_0070B5703 }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
action: "upload"
app_location: "src/Web/autopilot-monitor-web"
api_location: ""
output_location: ""
-35
View File
@@ -1,35 +0,0 @@
name: Deploy MAIN Backend - Rollback (Azure Functions)
on:
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
name: Build and Deploy MAIN Backend
steps:
- uses: actions/checkout@v4
with:
ref: main
- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj
- name: Build
run: dotnet build src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj --configuration Release --no-restore
- name: Publish
run: dotnet publish src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj --configuration Release --output ./publish --no-build
- name: Deploy to Azure Functions
uses: Azure/functions-action@v1
with:
app-name: autopilotmonitor-api
package: ./publish
publish-profile: ${{ secrets.AZURE_FUNCTIONAPP_PUBLISH_PROFILE }}
-33
View File
@@ -1,33 +0,0 @@
name: Deploy MAIN Web - Rollback (Static Web App)
on:
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
name: Build and Deploy MAIN Web
steps:
- uses: actions/checkout@v4
with:
ref: main
lfs: false
- name: Build And Deploy
id: builddeploy
uses: Azure/static-web-apps-deploy@v1
env:
NEXT_PUBLIC_ENTRA_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_CLIENT_ID }}
NEXT_PUBLIC_ENTRA_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_REDIRECT_URI }}
NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI }}
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL: ${{ vars.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL }}
NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING: ${{ secrets.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING }}
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_BRAVE_GRASS_0070B5703 }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
action: "upload"
app_location: "src/Web/autopilot-monitor-web"
api_location: ""
output_location: ""
+42
View File
@@ -0,0 +1,42 @@
name: Deploy MCP Server (Container App)
on:
workflow_dispatch:
env:
ACR_NAME: autopilotmonitoracr
CONTAINER_APP_NAME: autopilotmonitor-mcp
RESOURCE_GROUP: ${{ secrets.AZURE_MCP_RESOURCE_GROUP }}
IMAGE_TAG: ${{ github.sha }}
jobs:
build-and-deploy:
runs-on: ubuntu-latest
name: Build and Deploy MCP Server
steps:
- uses: actions/checkout@v4
- name: Login to Azure
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Login to ACR
run: az acr login --name ${{ env.ACR_NAME }}
- name: Build and push container image
run: |
docker build \
-f src/McpServer/autopilot-monitor-mcp/Dockerfile \
-t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ env.IMAGE_TAG }} \
-t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:latest \
.
docker push ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }} --all-tags
- name: Deploy to Container App
run: |
az containerapp update \
--name ${{ env.CONTAINER_APP_NAME }} \
--resource-group ${{ env.RESOURCE_GROUP }} \
--image ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ env.IMAGE_TAG }}
+2 -5
View File
@@ -1,11 +1,8 @@
{
"mcpServers": {
"autopilot-monitor": {
"command": "node",
"args": ["src/McpServer/autopilot-monitor-mcp/dist/index.js"],
"env": {
"AUTOPILOT_API_URL": "https://autopilotmonitor-api.azurewebsites.net"
}
"type": "streamable-http",
"url": "https://autopilotmonitor-mcp.azurecontainerapps.io/mcp"
}
}
}
+159
View File
@@ -0,0 +1,159 @@
// Autopilot-Monitor MCP Server — Azure Container App Infrastructure
//
// Deploy:
// az deployment group create \
// --resource-group <rg-name> \
// --template-file infra/mcp-server.bicep \
// --parameters apiUrl=https://autopilotmonitor-api.azurewebsites.net
@description('Azure region for all resources')
param location string = resourceGroup().location
@description('Name of the Container App')
param containerAppName string = 'autopilotmonitor-mcp'
@description('Name of the Container App Environment')
param environmentName string = 'autopilotmonitor-env'
@description('Name of the Log Analytics Workspace (dedicated for Container Apps)')
param logAnalyticsName string = 'autopilotmonitor-container-logs'
@description('Name of the Azure Container Registry')
param acrName string = 'autopilotmonitoracr'
@description('Backend API URL')
param apiUrl string = 'https://autopilotmonitor-api.azurewebsites.net'
@description('Container image tag')
param imageTag string = 'latest'
// --- Azure Container Registry ---
resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' = {
name: acrName
location: location
sku: {
name: 'Basic'
}
properties: {
adminUserEnabled: true
}
}
// --- Log Analytics Workspace (required by Container App Environment) ---
resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
name: logAnalyticsName
location: location
properties: {
sku: {
name: 'PerGB2018'
}
retentionInDays: 30
}
}
// --- Container App Environment ---
resource environment 'Microsoft.App/managedEnvironments@2024-03-01' = {
name: environmentName
location: location
properties: {
appLogsConfiguration: {
destination: 'log-analytics'
logAnalyticsConfiguration: {
customerId: logAnalytics.properties.customerId
sharedKey: logAnalytics.listKeys().primarySharedKey
}
}
}
}
// --- Container App ---
resource containerApp 'Microsoft.App/containerApps@2024-03-01' = {
name: containerAppName
location: location
properties: {
managedEnvironmentId: environment.id
configuration: {
ingress: {
external: true
targetPort: 8080
transport: 'auto'
allowInsecure: false
}
registries: [
{
server: acr.properties.loginServer
username: acr.listCredentials().username
passwordSecretRef: 'acr-password'
}
]
secrets: [
{
name: 'acr-password'
value: acr.listCredentials().passwords[0].value
}
]
activeRevisionsMode: 'Single'
}
template: {
containers: [
{
name: 'mcp-server'
image: '${acr.properties.loginServer}/${containerAppName}:${imageTag}'
resources: {
cpu: json('0.25')
memory: '0.5Gi'
}
env: [
{ name: 'AUTOPILOT_API_URL', value: apiUrl }
{ name: 'PORT', value: '8080' }
{ name: 'SEARCH_BACKEND', value: 'vector' }
]
probes: [
{
type: 'Liveness'
httpGet: {
path: '/health'
port: 8080
}
periodSeconds: 30
initialDelaySeconds: 10
}
{
type: 'Readiness'
httpGet: {
path: '/health'
port: 8080
}
periodSeconds: 10
initialDelaySeconds: 5
}
]
}
]
scale: {
minReplicas: 0
maxReplicas: 1
rules: [
{
name: 'http-scaler'
http: {
metadata: {
concurrentRequests: '10'
}
}
}
]
}
}
}
}
// --- Outputs ---
output containerAppUrl string = 'https://${containerApp.properties.configuration.ingress.fqdn}'
output mcpEndpoint string = 'https://${containerApp.properties.configuration.ingress.fqdn}/mcp'
output acrLoginServer string = acr.properties.loginServer
@@ -1 +1 @@
660
662
@@ -15,6 +15,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{
private readonly TableClient _globalAdminsTableClient;
private readonly TableClient _tenantAdminsTableClient;
private readonly TableClient _mcpUsersTableClient;
private readonly ILogger<TableAdminRepository> _logger;
public TableAdminRepository(
@@ -24,6 +25,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
_logger = logger;
_globalAdminsTableClient = storage.GetTableClient(Constants.TableNames.GlobalAdmins);
_tenantAdminsTableClient = storage.GetTableClient(Constants.TableNames.TenantAdmins);
_mcpUsersTableClient = storage.GetTableClient(Constants.TableNames.McpUsers);
}
// --- Global Admins ---
@@ -116,6 +118,96 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
// --- MCP Users ---
public async Task<bool> IsMcpUserAsync(string upn)
{
if (string.IsNullOrWhiteSpace(upn))
return false;
try
{
var normalizedUpn = upn.ToLowerInvariant();
var entity = await _mcpUsersTableClient.GetEntityAsync<McpUserEntity>(
"McpUsers", normalizedUpn);
return entity?.Value != null && entity.Value.IsEnabled;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking MCP user status for {Upn}", upn);
return false;
}
}
public async Task<List<McpUserEntry>> GetAllMcpUsersAsync()
{
var users = new List<McpUserEntry>();
await foreach (var entity in _mcpUsersTableClient.QueryAsync<McpUserEntity>(
filter: $"PartitionKey eq 'McpUsers'"))
{
users.Add(new McpUserEntry
{
Upn = entity.Upn,
IsEnabled = entity.IsEnabled,
AddedAt = entity.AddedDate,
AddedBy = entity.AddedBy
});
}
return users;
}
public async Task<bool> AddMcpUserAsync(string upn, string addedBy)
{
upn = upn.ToLowerInvariant();
addedBy = addedBy.ToLowerInvariant();
var entity = new McpUserEntity
{
PartitionKey = "McpUsers",
RowKey = upn,
Upn = upn,
IsEnabled = true,
AddedDate = DateTime.UtcNow,
AddedBy = addedBy
};
await _mcpUsersTableClient.UpsertEntityAsync(entity);
return true;
}
public async Task<bool> RemoveMcpUserAsync(string upn)
{
upn = upn.ToLowerInvariant();
await _mcpUsersTableClient.DeleteEntityAsync("McpUsers", upn);
return true;
}
public async Task<bool> SetMcpUserEnabledAsync(string upn, bool isEnabled)
{
upn = upn.ToLowerInvariant();
try
{
var result = await _mcpUsersTableClient.GetEntityAsync<McpUserEntity>(
"McpUsers", upn);
var entity = result.Value;
if (entity != null)
{
entity.IsEnabled = isEnabled;
await _mcpUsersTableClient.UpdateEntityAsync(entity, ETag.All);
}
return true;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return false;
}
}
// --- Tenant Members ---
public async Task<List<TenantMember>> GetTenantMembersAsync(string tenantId)
@@ -0,0 +1,156 @@
using System.Net;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Infrastructure;
/// <summary>
/// CRUD endpoints for MCP user whitelist management + access check for the remote MCP server.
/// </summary>
public class McpUserFunction
{
private readonly ILogger<McpUserFunction> _logger;
private readonly McpUserService _mcpUserService;
public McpUserFunction(
ILogger<McpUserFunction> logger,
McpUserService mcpUserService)
{
_logger = logger;
_mcpUserService = mcpUserService;
}
/// <summary>
/// GET /api/admin/mcp-users
/// Lists all MCP users + current policy. GlobalAdminOnly.
/// </summary>
[Function("GetMcpUsers")]
[Authorize]
public async Task<HttpResponseData> GetMcpUsers(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "admin/mcp-users")] HttpRequestData req)
{
var users = await _mcpUserService.GetAllMcpUsersAsync();
var policy = await _mcpUserService.GetPolicyAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { policy = policy.ToString(), users });
return response;
}
/// <summary>
/// POST /api/admin/mcp-users
/// Adds a user to the MCP whitelist. GlobalAdminOnly.
/// Body: { "upn": "user@domain.com" }
/// </summary>
[Function("AddMcpUser")]
[Authorize]
public async Task<HttpResponseData> AddMcpUser(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "admin/mcp-users")] HttpRequestData req,
FunctionContext context)
{
var principal = context.GetUser();
var currentUpn = principal?.GetUserPrincipalName();
var body = await req.ReadFromJsonAsync<AddMcpUserRequest>();
if (body == null || string.IsNullOrWhiteSpace(body.Upn))
{
var badResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badResponse.WriteAsJsonAsync(new { error = "UPN is required" });
return badResponse;
}
var user = await _mcpUserService.AddMcpUserAsync(body.Upn, currentUpn!);
var response = req.CreateResponse(HttpStatusCode.Created);
await response.WriteAsJsonAsync(new { user });
return response;
}
/// <summary>
/// DELETE /api/admin/mcp-users/{upn}
/// Removes a user from the MCP whitelist. GlobalAdminOnly.
/// </summary>
[Function("RemoveMcpUser")]
[Authorize]
public async Task<HttpResponseData> RemoveMcpUser(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "admin/mcp-users/{upn}")] HttpRequestData req,
string upn)
{
await _mcpUserService.RemoveMcpUserAsync(upn);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { message = "MCP user removed" });
return response;
}
/// <summary>
/// PATCH /api/admin/mcp-users/{upn}/enable
/// Enables a previously disabled MCP user. GlobalAdminOnly.
/// </summary>
[Function("EnableMcpUser")]
[Authorize]
public async Task<HttpResponseData> EnableMcpUser(
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "admin/mcp-users/{upn}/enable")] HttpRequestData req,
string upn)
{
await _mcpUserService.SetMcpUserEnabledAsync(upn, true);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { message = "MCP user enabled" });
return response;
}
/// <summary>
/// PATCH /api/admin/mcp-users/{upn}/disable
/// Disables an MCP user without removing them. GlobalAdminOnly.
/// </summary>
[Function("DisableMcpUser")]
[Authorize]
public async Task<HttpResponseData> DisableMcpUser(
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "admin/mcp-users/{upn}/disable")] HttpRequestData req,
string upn)
{
await _mcpUserService.SetMcpUserEnabledAsync(upn, false);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { message = "MCP user disabled" });
return response;
}
/// <summary>
/// GET /api/admin/mcp-users/check
/// Lightweight access check for the remote MCP server.
/// Called by MCP server auth middleware to validate if a user can access MCP.
/// AuthenticatedUser policy — the endpoint itself checks MCP access via service.
/// </summary>
[Function("CheckMcpAccess")]
[Authorize]
public async Task<HttpResponseData> CheckMcpAccess(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "admin/mcp-users/check")] HttpRequestData req,
FunctionContext context)
{
var principal = context.GetUser();
var upn = principal?.GetUserPrincipalName();
var result = await _mcpUserService.IsAllowedAsync(upn);
var response = req.CreateResponse(result.IsAllowed ? HttpStatusCode.OK : HttpStatusCode.Forbidden);
await response.WriteAsJsonAsync(new
{
allowed = result.IsAllowed,
upn = result.Upn,
accessGrant = result.AccessGrant,
reason = result.Reason
});
return response;
}
}
public class AddMcpUserRequest
{
public string Upn { get; set; } = string.Empty;
}
@@ -114,6 +114,7 @@ builder.Services.AddSingleton<RateLimitService>();
builder.Services.AddSingleton<UsageMetricsService>();
builder.Services.AddSingleton<PlatformMetricsService>();
builder.Services.AddSingleton<GlobalAdminService>();
builder.Services.AddSingleton<McpUserService>();
builder.Services.AddSingleton<PreviewWhitelistService>();
builder.Services.AddSingleton<TenantAdminsService>();
builder.Services.AddSingleton<HealthCheckService>();
@@ -155,6 +155,9 @@ public static class EndpointAccessPolicyCatalog
new("POST", "bootstrap/sessions", EndpointPolicy.BootstrapManagerOrGA),
new("DELETE", "bootstrap/sessions/{code}", EndpointPolicy.BootstrapManagerOrGA),
// ── MCP User Access Check (any authenticated user can check their own access) ──
new("GET", "admin/mcp-users/check", EndpointPolicy.AuthenticatedUser),
// ── GlobalAdminOnly ────────────────────────────────────────────
new("GET", "global/raw/sessions", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/raw/events", EndpointPolicy.GlobalAdminOnly),
@@ -208,6 +211,11 @@ public static class EndpointAccessPolicyCatalog
new("DELETE", "vulnerability/cpe-mapping", EndpointPolicy.GlobalAdminOnly),
new("GET", "vulnerability/cpe-mappings", EndpointPolicy.GlobalAdminOnly),
new("POST", "rules/ime-log-patterns/reseed", EndpointPolicy.GlobalAdminOnly),
new("GET", "admin/mcp-users", EndpointPolicy.GlobalAdminOnly),
new("POST", "admin/mcp-users", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "admin/mcp-users/{upn}", EndpointPolicy.GlobalAdminOnly),
new("PATCH", "admin/mcp-users/{upn}/enable", EndpointPolicy.GlobalAdminOnly),
new("PATCH", "admin/mcp-users/{upn}/disable", EndpointPolicy.GlobalAdminOnly),
new("PATCH", "config/{tenantId}/plan", EndpointPolicy.GlobalAdminOnly),
new("GET", "feedback/all", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/notifications", EndpointPolicy.GlobalAdminOnly),
@@ -0,0 +1,170 @@
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure.Data.Tables;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Service for managing MCP (Model Context Protocol) user access.
/// Access is governed by AdminConfiguration.McpAccessPolicy:
/// - Disabled: no one can access MCP
/// - WhitelistOnly: Global Admins + explicit McpUsers table entries
/// - AllMembers: any authenticated user
/// </summary>
public class McpUserService
{
private readonly IAdminRepository _adminRepo;
private readonly IMemoryCache _cache;
private readonly ILogger<McpUserService> _logger;
private readonly GlobalAdminService _globalAdminService;
private readonly AdminConfigurationService _adminConfigService;
private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5);
public McpUserService(
IAdminRepository adminRepo,
IMemoryCache cache,
ILogger<McpUserService> logger,
GlobalAdminService globalAdminService,
AdminConfigurationService adminConfigService)
{
_adminRepo = adminRepo;
_cache = cache;
_logger = logger;
_globalAdminService = globalAdminService;
_adminConfigService = adminConfigService;
}
/// <summary>
/// Checks if a user is allowed to access the MCP server based on current policy.
/// </summary>
public async Task<McpAccessCheckResult> IsAllowedAsync(string? upn)
{
if (string.IsNullOrWhiteSpace(upn))
return McpAccessCheckResult.Denied("Missing UPN");
upn = upn.ToLowerInvariant();
var config = await _adminConfigService.GetConfigurationAsync();
if (!Enum.TryParse<McpAccessPolicy>(config.McpAccessPolicy, out var policy))
policy = McpAccessPolicy.WhitelistOnly;
switch (policy)
{
case McpAccessPolicy.Disabled:
return McpAccessCheckResult.Denied("MCP access is disabled");
case McpAccessPolicy.AllMembers:
return McpAccessCheckResult.Allowed(upn, "AllMembers");
case McpAccessPolicy.WhitelistOnly:
default:
// Global Admins always have access
if (await _globalAdminService.IsGlobalAdminAsync(upn))
return McpAccessCheckResult.Allowed(upn, "GlobalAdmin");
// Check McpUsers whitelist (cached)
var cacheKey = $"mcp-user:{upn}";
if (!_cache.TryGetValue<bool>(cacheKey, out var isMcpUser))
{
isMcpUser = await _adminRepo.IsMcpUserAsync(upn);
_cache.Set(cacheKey, isMcpUser, _cacheDuration);
}
return isMcpUser
? McpAccessCheckResult.Allowed(upn, "McpUser")
: McpAccessCheckResult.Denied("Not authorized for MCP access");
}
}
public async Task<McpUserEntry> AddMcpUserAsync(string upn, string addedBy)
{
upn = upn.ToLowerInvariant();
addedBy = addedBy.ToLowerInvariant();
await _adminRepo.AddMcpUserAsync(upn, addedBy);
_cache.Remove($"mcp-user:{upn}");
_logger.LogInformation("MCP user added: {Upn} by {AddedBy}", upn, addedBy);
return new McpUserEntry
{
Upn = upn,
IsEnabled = true,
AddedAt = DateTime.UtcNow,
AddedBy = addedBy
};
}
public async Task RemoveMcpUserAsync(string upn)
{
upn = upn.ToLowerInvariant();
await _adminRepo.RemoveMcpUserAsync(upn);
_cache.Remove($"mcp-user:{upn}");
_logger.LogInformation("MCP user removed: {Upn}", upn);
}
public async Task SetMcpUserEnabledAsync(string upn, bool isEnabled)
{
upn = upn.ToLowerInvariant();
await _adminRepo.SetMcpUserEnabledAsync(upn, isEnabled);
_cache.Remove($"mcp-user:{upn}");
_logger.LogInformation("MCP user {Action}: {Upn}", isEnabled ? "enabled" : "disabled", upn);
}
public async Task<List<McpUserEntry>> GetAllMcpUsersAsync()
{
return await _adminRepo.GetAllMcpUsersAsync();
}
/// <summary>
/// Returns the current MCP access policy.
/// </summary>
public async Task<McpAccessPolicy> GetPolicyAsync()
{
var config = await _adminConfigService.GetConfigurationAsync();
return Enum.TryParse<McpAccessPolicy>(config.McpAccessPolicy, out var policy)
? policy
: McpAccessPolicy.WhitelistOnly;
}
}
public class McpAccessCheckResult
{
public bool IsAllowed { get; init; }
public string Upn { get; init; } = string.Empty;
public string Reason { get; init; } = string.Empty;
public string AccessGrant { get; init; } = string.Empty;
public static McpAccessCheckResult Allowed(string upn, string accessGrant) => new()
{
IsAllowed = true,
Upn = upn,
AccessGrant = accessGrant
};
public static McpAccessCheckResult Denied(string reason) => new()
{
IsAllowed = false,
Reason = reason
};
}
/// <summary>
/// Entity representing an MCP user in Table Storage
/// </summary>
public class McpUserEntity : ITableEntity
{
public string PartitionKey { get; set; } = "McpUsers";
public string RowKey { get; set; } = string.Empty;
public DateTimeOffset? Timestamp { get; set; }
public Azure.ETag ETag { get; set; }
public string Upn { get; set; } = string.Empty;
public bool IsEnabled { get; set; } = true;
public DateTime AddedDate { get; set; }
public string AddedBy { get; set; } = string.Empty;
}
@@ -0,0 +1,10 @@
**/node_modules
**/dist
**/.git
**/*.md
**/.env
**/.env.*
src/Agent
src/Backend
src/Shared
src/Web
@@ -0,0 +1,37 @@
# Build context: repo root (run: docker build -f src/McpServer/autopilot-monitor-mcp/Dockerfile .)
# Stage 1: Build
FROM node:22-slim AS build
WORKDIR /app
COPY src/McpServer/autopilot-monitor-mcp/package.json src/McpServer/autopilot-monitor-mcp/package-lock.json ./
RUN npm ci --ignore-scripts
COPY src/McpServer/autopilot-monitor-mcp/tsconfig.json ./
COPY src/McpServer/autopilot-monitor-mcp/src/ ./src/
RUN npm run build
# Stage 2: Production
FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package.json /app/package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
COPY --from=build /app/dist/ ./dist/
# Knowledge base rules (analyze, gather, ime-log-patterns)
COPY rules/analyze/ ./rules/analyze/
COPY rules/gather/ ./rules/gather/
COPY rules/ime-log-patterns/ ./rules/ime-log-patterns/
ENV PORT=8080
ENV RULES_DIR=/app/rules
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://localhost:8080/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
CMD ["node", "dist/index.js"]
+94 -157
View File
@@ -9,9 +9,9 @@
"version": "1.2.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"@azure/msal-node": "^2.16.0",
"@huggingface/transformers": "^3.8.1",
"@modelcontextprotocol/sdk": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.28.0",
"express": "^5.2.1",
"fuse.js": "^7.1.0",
"zod": "^3.22.0"
},
@@ -19,6 +19,7 @@
"autopilot-monitor-mcp": "dist/index.js"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^22.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
@@ -44,29 +45,6 @@
}
}
},
"node_modules/@azure/msal-common": {
"version": "14.16.1",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz",
"integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-node": {
"version": "2.16.3",
"resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.3.tgz",
"integrity": "sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==",
"license": "MIT",
"dependencies": {
"@azure/msal-common": "14.16.1",
"jsonwebtoken": "^9.0.0",
"uuid": "^8.3.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
@@ -1039,9 +1017,9 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz",
"integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9",
@@ -1142,6 +1120,59 @@
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause"
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/express": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
"@types/serve-static": "^2"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
@@ -1151,6 +1182,41 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -1228,12 +1294,6 @@
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1441,15 +1501,6 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -2010,91 +2061,6 @@
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"license": "ISC"
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -2469,26 +2435,6 @@
"node": ">= 18"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -2844,15 +2790,6 @@
"node": ">= 0.8"
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -9,17 +9,20 @@
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts"
"dev": "tsx src/index.ts",
"docker:build": "docker build -f Dockerfile -t autopilotmonitor-mcp:local ../../../",
"docker:run": "docker run --rm -p 8080:8080 -e AUTOPILOT_API_URL=https://autopilotmonitor-api.azurewebsites.net autopilotmonitor-mcp:local"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"@azure/msal-node": "^2.16.0",
"@huggingface/transformers": "^3.8.1",
"@modelcontextprotocol/sdk": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.28.0",
"express": "^5.2.1",
"fuse.js": "^7.1.0",
"zod": "^3.22.0"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^22.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
+37 -134
View File
@@ -1,145 +1,48 @@
import { PublicClientApplication, LogLevel } from '@azure/msal-node';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { resolve } from 'node:path';
import { homedir } from 'node:os';
import { exec } from 'node:child_process';
/**
* Auth module for the remote MCP server.
*
* In remote mode, the Claude Code client handles OAuth and sends a Bearer token
* with each MCP request. This module provides helpers for token validation and
* extracting user info from the JWT claims.
*
* The user's token is passed through to the backend API (access_as_user scope),
* so no service principal or separate credentials are needed.
*/
const CLIENT_ID = process.env.AUTOPILOT_ENTRA_CLIENT_ID ?? '1a400946-62c1-4ab4-aa37-f730ac89704d';
const AUTHORITY = process.env.AUTOPILOT_ENTRA_AUTHORITY ?? 'https://login.microsoftonline.com/organizations';
const SCOPES = [`api://${CLIENT_ID}/access_as_user`];
const CACHE_DIR = resolve(homedir(), '.autopilot-monitor');
const CACHE_FILE = resolve(CACHE_DIR, 'auth-cache.json');
import { createDecoder } from './jwt-decode.js';
let pca: PublicClientApplication | null = null;
export interface TokenClaims {
/** User Principal Name (email) */
upn?: string;
/** Azure AD Object ID */
oid?: string;
/** Tenant ID */
tid?: string;
/** Token expiry (unix timestamp) */
exp?: number;
/** Audience */
aud?: string;
}
async function loadCache(): Promise<string | undefined> {
const decode = createDecoder();
/**
* Extracts claims from a JWT access token without cryptographic validation.
* Full validation (signature, issuer, audience) is deferred to the backend API
* which receives the same token. This avoids duplicating JWKS/OIDC config here.
*/
export function extractTokenClaims(token: string): TokenClaims | null {
try {
return await readFile(CACHE_FILE, 'utf-8');
return decode(token);
} catch {
return undefined;
return null;
}
}
async function saveCache(cache: string): Promise<void> {
try {
await mkdir(CACHE_DIR, { recursive: true });
await writeFile(CACHE_FILE, cache, { mode: 0o600 });
} catch (err) {
console.error('[auth] Failed to persist token cache:', err);
}
}
function getClient(): PublicClientApplication {
if (!pca) {
pca = new PublicClientApplication({
auth: {
clientId: CLIENT_ID,
authority: AUTHORITY,
},
system: {
loggerOptions: {
loggerCallback: (_level, message) => {
if (_level <= LogLevel.Warning) {
console.error(`[msal] ${message}`);
}
},
logLevel: LogLevel.Warning,
piiLoggingEnabled: false,
},
},
});
}
return pca;
}
/** Open a URL in the system default browser */
function openBrowser(url: string): Promise<void> {
return new Promise((resolve) => {
const cmd = process.platform === 'win32'
? `start "" "${url}"`
: process.platform === 'darwin'
? `open "${url}"`
: `xdg-open "${url}" 2>/dev/null`;
exec(cmd, () => resolve());
});
}
/**
* Acquires an access token — tries silent refresh first, falls back to interactive browser login.
* All log output goes to stderr (stdout is the MCP JSON-RPC transport).
* Checks if a token is expired (with 60s buffer).
*/
export async function getAccessToken(): Promise<string> {
const client = getClient();
// Load persisted cache
const cacheData = await loadCache();
if (cacheData) {
client.getTokenCache().deserialize(cacheData);
}
// Try silent acquisition first (cached token or refresh)
const accounts = await client.getTokenCache().getAllAccounts();
if (accounts.length > 0) {
try {
const result = await client.acquireTokenSilent({
account: accounts[0],
scopes: SCOPES,
});
if (result?.accessToken) {
await saveCache(client.getTokenCache().serialize());
return result.accessToken;
}
} catch {
// Silent failed — fall through to interactive login
}
}
// Interactive browser login — opens system browser, listens on localhost for redirect
console.error('');
console.error('=== Authentication Required ===');
console.error('Opening browser for sign-in…');
console.error('');
const result = await client.acquireTokenInteractive({
scopes: SCOPES,
openBrowser,
});
if (!result?.accessToken) {
throw new Error('Interactive authentication failed — no access token received');
}
// Persist cache after successful auth
await saveCache(client.getTokenCache().serialize());
console.error(`[auth] Authenticated as ${result.account?.username ?? 'unknown'}`);
return result.accessToken;
}
/**
* Returns a summary of the current auth state.
*/
export async function getAuthStatus(): Promise<{
mode: string;
hasCachedToken: boolean;
user?: string;
tenantId?: string;
}> {
const client = getClient();
const cacheData = await loadCache();
if (cacheData) {
client.getTokenCache().deserialize(cacheData);
}
const accounts = await client.getTokenCache().getAllAccounts();
if (accounts.length > 0) {
return {
mode: 'oauth',
hasCachedToken: true,
user: accounts[0].username,
tenantId: accounts[0].tenantId,
};
}
return { mode: 'oauth', hasCachedToken: false };
export function isTokenExpired(claims: TokenClaims): boolean {
if (!claims.exp) return true;
return Date.now() / 1000 > claims.exp - 60;
}
@@ -1,10 +1,27 @@
import { getAccessToken } from './auth.js';
const BASE_URL = process.env.AUTOPILOT_API_URL ?? 'https://autopilotmonitor-api.azurewebsites.net';
/**
* Per-request token store. The MCP request handler sets the current user's
* Bearer token before tool execution, and apiFetch reads it to pass through
* to the backend API.
*/
let _currentToken: string | undefined;
export function setCurrentToken(token: string | undefined): void {
_currentToken = token;
}
export function getCurrentToken(): string | undefined {
return _currentToken;
}
async function apiFetch(path: string, options: RequestInit = {}): Promise<unknown> {
const token = _currentToken;
if (!token) {
throw new Error('No authentication token available. Ensure the request includes a valid Bearer token.');
}
const url = `${BASE_URL}${path}`;
const token = await getAccessToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
@@ -1,38 +1,129 @@
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
import express from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { registerTools } from './tools.js';
import { registerResources } from './resources.js';
import { loadKnowledgeDocs } from './knowledge-base.js';
import { createSearchProvider } from './search-factory.js';
import { setCurrentToken } from './client.js';
import { extractTokenClaims, isTokenExpired } from './auth.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.PORT ?? '8080', 10);
const RULES_DIR = process.env.RULES_DIR ?? resolve(__dirname, '..', '..', '..', '..', 'rules');
// --- Initialize MCP Server ---
const server = new McpServer({
name: 'autopilot-monitor',
version: '1.2.0',
});
// Initialize the knowledge base search provider
console.error('Loading knowledge base documents…');
const docs = await loadKnowledgeDocs(RULES_DIR);
console.error(`Initializing search provider (${docs.length} documents)…`);
const knowledgeBase = await createSearchProvider();
await knowledgeBase.index(docs);
console.error(`Search provider ready: ${knowledgeBase.name} — ${knowledgeBase.size} documents indexed.`);
registerTools(server, knowledgeBase);
registerResources(server);
const transport = new StdioServerTransport();
await server.connect(transport);
// --- HTTP Server with Streamable HTTP Transport ---
console.error('Autopilot-Monitor MCP Server running (stdio transport)');
console.error(`API URL: ${process.env.AUTOPILOT_API_URL ?? 'https://autopilotmonitor-api.azurewebsites.net'}`);
console.error(`Auth mode: OAuth (device code flow)`);
console.error(`Search backend: ${knowledgeBase.name} | Documents: ${knowledgeBase.size} | Rules dir: ${RULES_DIR}`);
const app = express();
app.use(express.json());
// Health check
app.get('/health', (_req, res) => {
res.json({
status: 'healthy',
server: 'autopilot-monitor-mcp',
version: '1.2.0',
search: { backend: knowledgeBase.name, documents: knowledgeBase.size },
});
});
// Track transports by session ID for reuse
const transports = new Map<string, StreamableHTTPServerTransport>();
// Auth middleware for /mcp — extract Bearer token, validate basic claims, set for pass-through
app.use('/mcp', (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid Authorization header' });
return;
}
const token = authHeader.slice(7);
const claims = extractTokenClaims(token);
if (!claims || !claims.upn) {
res.status(401).json({ error: 'Invalid token: missing required claims' });
return;
}
if (isTokenExpired(claims)) {
res.status(401).json({ error: 'Token expired' });
return;
}
// Set token for pass-through to backend API
setCurrentToken(token);
next();
});
// MCP Streamable HTTP endpoint
app.all('/mcp', async (req, res) => {
// Handle GET for SSE streams and DELETE for session termination
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (req.method === 'GET' || req.method === 'DELETE') {
const transport = sessionId ? transports.get(sessionId) : undefined;
if (!transport) {
res.status(400).json({ error: 'No valid session. Send an initialize request first.' });
return;
}
await transport.handleRequest(req, res);
return;
}
// POST — either new session (initialize) or existing session
if (sessionId && transports.has(sessionId)) {
const transport = transports.get(sessionId)!;
await transport.handleRequest(req, res, req.body);
return;
}
// New session — create transport and connect
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
transport.onclose = () => {
if (transport.sessionId) {
transports.delete(transport.sessionId);
}
};
await server.connect(transport);
// Store transport for session reuse
if (transport.sessionId) {
transports.set(transport.sessionId, transport);
}
await transport.handleRequest(req, res, req.body);
});
app.listen(PORT, '0.0.0.0', () => {
console.error(`Autopilot-Monitor MCP Server running (Streamable HTTP on port ${PORT})`);
console.error(`API URL: ${process.env.AUTOPILOT_API_URL ?? 'https://autopilotmonitor-api.azurewebsites.net'}`);
console.error(`Search backend: ${knowledgeBase.name} | Documents: ${knowledgeBase.size} | Rules dir: ${RULES_DIR}`);
console.error(`Health: http://localhost:${PORT}/health`);
console.error(`MCP endpoint: http://localhost:${PORT}/mcp`);
});
@@ -0,0 +1,16 @@
/**
* Minimal JWT payload decoder — no signature verification.
* Used to extract claims (upn, tid, exp) from bearer tokens.
* Full cryptographic validation is handled by the backend API.
*/
export function createDecoder() {
return function decode<T = Record<string, unknown>>(token: string): T {
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid JWT: expected 3 parts');
}
const payload = parts[1];
const json = Buffer.from(payload, 'base64url').toString('utf-8');
return JSON.parse(json) as T;
};
}
@@ -238,6 +238,7 @@ namespace AutopilotMonitor.Shared
// Admin tables
public const string GlobalAdmins = "GlobalAdmins";
public const string TenantAdmins = "TenantAdmins";
public const string McpUsers = "McpUsers";
// Preview gating (temporary — remove after GA)
public const string PreviewWhitelist = "PreviewWhitelist";
@@ -297,6 +298,7 @@ namespace AutopilotMonitor.Shared
AdminConfiguration,
GlobalAdmins,
TenantAdmins,
McpUsers,
PreviewWhitelist,
PreviewConfig,
BlockedDevices,
@@ -17,6 +17,13 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<bool> RemoveGlobalAdminAsync(string upn);
Task<bool> DisableGlobalAdminAsync(string upn);
// --- MCP Users ---
Task<bool> IsMcpUserAsync(string upn);
Task<List<McpUserEntry>> GetAllMcpUsersAsync();
Task<bool> AddMcpUserAsync(string upn, string addedBy);
Task<bool> RemoveMcpUserAsync(string upn);
Task<bool> SetMcpUserEnabledAsync(string upn, bool isEnabled);
// --- Tenant Members ---
Task<List<TenantMember>> GetTenantMembersAsync(string tenantId);
Task<bool> AddTenantMemberAsync(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens = false);
@@ -36,6 +43,14 @@ namespace AutopilotMonitor.Shared.DataAccess
public string AddedBy { get; set; } = string.Empty;
}
public class McpUserEntry
{
public string Upn { get; set; } = string.Empty;
public bool IsEnabled { get; set; } = true;
public DateTime AddedAt { get; set; }
public string AddedBy { get; set; } = string.Empty;
}
public class TenantMember
{
public string Upn { get; set; } = string.Empty;
@@ -129,6 +129,15 @@ namespace AutopilotMonitor.Shared.Models
}
}
// ===== MCP ACCESS CONTROL =====
/// <summary>
/// Controls who can access the remote MCP server.
/// "Disabled" = MCP off, "WhitelistOnly" = GlobalAdmins + McpUsers table (default),
/// "AllMembers" = any authenticated user.
/// </summary>
public string McpAccessPolicy { get; set; } = nameof(Models.McpAccessPolicy.WhitelistOnly);
// ===== VULNERABILITY CORRELATION SETTINGS =====
/// <summary>
@@ -0,0 +1,18 @@
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Controls who can access the remote MCP server.
/// Stored as string in AdminConfiguration.McpAccessPolicy.
/// </summary>
public enum McpAccessPolicy
{
/// <summary>MCP access completely disabled.</summary>
Disabled = 0,
/// <summary>Only Global Admins and explicitly whitelisted MCP users.</summary>
WhitelistOnly = 1,
/// <summary>Any authenticated user with a valid tenant membership.</summary>
AllMembers = 2
}
}
@@ -5,10 +5,12 @@ import { notFound } from "next/navigation";
import { type AccessSectionId } from "../accessNavSections";
import { SectionAdminManagement } from "../sections/SectionAdminManagement";
import { SectionBootstrapSessions } from "../sections/SectionBootstrapSessions";
import { SectionMcpUsers } from "../sections/SectionMcpUsers";
const SECTION_COMPONENTS: Record<AccessSectionId, React.ComponentType> = {
"admin-management": SectionAdminManagement,
"bootstrap-sessions": SectionBootstrapSessions,
"mcp-users": SectionMcpUsers,
};
export default function AccessSectionPage() {
@@ -1,6 +1,7 @@
export const ACCESS_NAV_SECTIONS = [
{ id: "admin-management", label: "Admin Management", description: "Manage tenant admins and operators" },
{ id: "bootstrap-sessions", label: "Bootstrap Sessions", description: "Create and manage bootstrap tokens" },
{ id: "mcp-users", label: "MCP Users", description: "Manage AI agent access via MCP", globalAdminOnly: true },
] as const;
export type AccessSectionId = (typeof ACCESS_NAV_SECTIONS)[number]["id"];
@@ -0,0 +1,13 @@
"use client";
import { TenantNotifications } from "../../TenantNotifications";
import McpUsersSection from "../../components/McpUsersSection";
export function SectionMcpUsers() {
return (
<>
<TenantNotifications />
<McpUsersSection />
</>
);
}
@@ -0,0 +1,387 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useAuth } from "../../../contexts/AuthContext";
import { useNotifications } from "../../../contexts/NotificationContext";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { api } from "@/lib/api";
interface McpUser {
upn: string;
isEnabled: boolean;
addedAt: string;
addedBy: string;
}
type McpPolicy = "Disabled" | "WhitelistOnly" | "AllMembers";
const POLICY_LABELS: Record<McpPolicy, string> = {
Disabled: "Disabled",
WhitelistOnly: "Whitelist Only",
AllMembers: "All Members",
};
const POLICY_DESCRIPTIONS: Record<McpPolicy, string> = {
Disabled: "MCP access is completely disabled. No one can connect.",
WhitelistOnly: "Only Global Admins and explicitly added MCP users can connect.",
AllMembers: "Any authenticated user can connect to the MCP server.",
};
export default function McpUsersSection() {
const { getAccessToken, user } = useAuth();
const { addNotification } = useNotifications();
const [users, setUsers] = useState<McpUser[]>([]);
const [policy, setPolicy] = useState<McpPolicy>("WhitelistOnly");
const [loading, setLoading] = useState(true);
const [newEmail, setNewEmail] = useState("");
const [adding, setAdding] = useState(false);
const [removingUpn, setRemovingUpn] = useState<string | null>(null);
const [togglingUpn, setTogglingUpn] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(0);
const ITEMS_PER_PAGE = 5;
const fetchMcpUsers = useCallback(async () => {
try {
setLoading(true);
const response = await authenticatedFetch(api.mcpUsers.list(), getAccessToken);
if (!response.ok) throw new Error(`Failed to load MCP users: ${response.statusText}`);
const data = await response.json();
setUsers(data.users ?? []);
setPolicy(data.policy ?? "WhitelistOnly");
} catch (err) {
if (err instanceof TokenExpiredError) {
addNotification("error", "Session Expired", err.message, "session-expired-error");
} else {
setError(err instanceof Error ? err.message : "Failed to load MCP users");
}
} finally {
setLoading(false);
}
}, [getAccessToken, addNotification]);
useEffect(() => {
fetchMcpUsers();
}, [fetchMcpUsers]);
const handleAddUser = useCallback(async () => {
if (!newEmail.trim()) return;
try {
setAdding(true);
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(api.mcpUsers.add(), getAccessToken, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ upn: newEmail.trim() }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || `Failed to add user: ${response.statusText}`);
}
setSuccessMessage(`MCP user ${newEmail} added successfully!`);
setNewEmail("");
await fetchMcpUsers();
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err) {
if (err instanceof TokenExpiredError) {
addNotification("error", "Session Expired", err.message, "session-expired-error");
} else {
setError(err instanceof Error ? err.message : "Failed to add user");
}
} finally {
setAdding(false);
}
}, [newEmail, getAccessToken, addNotification, fetchMcpUsers]);
const handleRemoveUser = useCallback(async (upn: string) => {
if (!confirm(`Remove ${upn} from MCP users?`)) return;
try {
setRemovingUpn(upn);
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(api.mcpUsers.remove(upn), getAccessToken, {
method: "DELETE",
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || `Failed to remove user: ${response.statusText}`);
}
setSuccessMessage(`MCP user ${upn} removed.`);
await fetchMcpUsers();
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err) {
if (err instanceof TokenExpiredError) {
addNotification("error", "Session Expired", err.message, "session-expired-error");
} else {
setError(err instanceof Error ? err.message : "Failed to remove user");
}
} finally {
setRemovingUpn(null);
}
}, [getAccessToken, addNotification, fetchMcpUsers]);
const handleToggleUser = useCallback(async (upn: string, currentlyEnabled: boolean) => {
try {
setTogglingUpn(upn);
setError(null);
const url = currentlyEnabled ? api.mcpUsers.disable(upn) : api.mcpUsers.enable(upn);
const response = await authenticatedFetch(url, getAccessToken, { method: "PATCH" });
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || `Failed to update user: ${response.statusText}`);
}
setSuccessMessage(`MCP user ${upn} ${currentlyEnabled ? "disabled" : "enabled"}.`);
await fetchMcpUsers();
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err) {
if (err instanceof TokenExpiredError) {
addNotification("error", "Session Expired", err.message, "session-expired-error");
} else {
setError(err instanceof Error ? err.message : "Failed to update user");
}
} finally {
setTogglingUpn(null);
}
}, [getAccessToken, addNotification, fetchMcpUsers]);
// Filter & paginate
const filteredUsers = users.filter((u) =>
u.upn.toLowerCase().includes(searchQuery.toLowerCase()),
);
const totalPages = Math.ceil(filteredUsers.length / ITEMS_PER_PAGE);
const paginatedUsers = filteredUsers.slice(
currentPage * ITEMS_PER_PAGE,
(currentPage + 1) * ITEMS_PER_PAGE,
);
return (
<div className="bg-white rounded-lg shadow">
{/* Header */}
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-purple-50 to-indigo-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<h2 className="text-lg font-semibold text-gray-900">MCP User Management</h2>
</div>
<p className="mt-1 text-sm text-gray-600">
Control who can access the AI agent interface (Model Context Protocol).
</p>
</div>
<div className="p-6 space-y-4">
{/* Notifications */}
{successMessage && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-center space-x-3">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-green-800 font-medium">{successMessage}</span>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center space-x-3">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="text-red-800">{error}</span>
</div>
)}
{/* Policy Info */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-blue-800">
<p className="font-medium">Access Policy: {POLICY_LABELS[policy]}</p>
<p className="mt-1">{POLICY_DESCRIPTIONS[policy]}</p>
{policy === "WhitelistOnly" && (
<p className="mt-1">Global Admins always have access, no separate entry needed.</p>
)}
</div>
</div>
</div>
{/* User list only visible when WhitelistOnly */}
{policy === "WhitelistOnly" && (
<>
{/* Add user form */}
<div className="flex flex-wrap gap-2">
<input
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddUser();
}
}}
placeholder="user@domain.com"
autoComplete="off"
className="flex-1 min-w-0 px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors"
/>
<button
onClick={handleAddUser}
disabled={adding || !newEmail.trim()}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
>
{adding ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
<span>Adding...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span>Add</span>
</>
)}
</button>
</div>
{/* Search */}
{users.length > 3 && (
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setCurrentPage(0);
}}
placeholder="Search by email..."
autoComplete="off"
className="w-full px-4 py-2 pl-10 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors"
/>
<svg className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
)}
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
<span className="ml-3 text-sm text-gray-500">Loading MCP users...</span>
</div>
)}
{/* User list */}
{!loading && paginatedUsers.length === 0 && (
<p className="text-sm text-gray-500 py-4 text-center">
{searchQuery ? "No users match your search." : "No MCP users added yet. Add a user above to grant MCP access."}
</p>
)}
{!loading && (
<div className="space-y-2">
{paginatedUsers.map((mcpUser) => (
<div
key={mcpUser.upn}
className={`p-3 border rounded-lg ${
mcpUser.isEnabled ? "bg-gray-50 border-gray-200" : "bg-gray-100 border-gray-300"
}`}
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium text-gray-900 truncate">{mcpUser.upn}</span>
{mcpUser.isEnabled ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Active
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 text-gray-700">
Disabled
</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1">
Added {new Date(mcpUser.addedAt).toLocaleDateString()} by {mcpUser.addedBy}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => handleToggleUser(mcpUser.upn, mcpUser.isEnabled)}
disabled={togglingUpn === mcpUser.upn}
className="px-3 py-1 text-sm border border-gray-300 rounded-lg hover:bg-gray-100 disabled:opacity-50 transition-colors"
>
{togglingUpn === mcpUser.upn ? "..." : mcpUser.isEnabled ? "Disable" : "Enable"}
</button>
<button
onClick={() => handleRemoveUser(mcpUser.upn)}
disabled={removingUpn === mcpUser.upn}
className="px-3 py-1 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50 disabled:opacity-50 transition-colors"
>
{removingUpn === mcpUser.upn ? "..." : "Remove"}
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-200">
<button
onClick={() => setCurrentPage((prev) => Math.max(0, prev - 1))}
disabled={currentPage === 0}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {currentPage + 1} of {totalPages} ({filteredUsers.length} user{filteredUsers.length !== 1 ? "s" : ""})
</span>
<button
onClick={() => setCurrentPage((prev) => Math.min(totalPages - 1, prev + 1))}
disabled={currentPage >= totalPages - 1}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
)}
</>
)}
{/* Message for other policies */}
{policy === "Disabled" && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
MCP access is currently disabled. Change the policy in Global Config to enable it.
</div>
)}
{policy === "AllMembers" && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-sm text-green-800">
All authenticated users can access the MCP server. No whitelist management needed.
</div>
)}
</div>
</div>
);
}
+13
View File
@@ -213,6 +213,19 @@ export const api = {
unmatchedSoftware: () => `${API_BASE_URL}/api/vulnerability/unmatched-software`,
},
// ── MCP Users ─────────────────────────────────────────────────────────────
mcpUsers: {
list: () => `${API_BASE_URL}/api/admin/mcp-users`,
add: () => `${API_BASE_URL}/api/admin/mcp-users`,
remove: (upn: string) =>
`${API_BASE_URL}/api/admin/mcp-users/${encodeURIComponent(upn)}`,
enable: (upn: string) =>
`${API_BASE_URL}/api/admin/mcp-users/${encodeURIComponent(upn)}/enable`,
disable: (upn: string) =>
`${API_BASE_URL}/api/admin/mcp-users/${encodeURIComponent(upn)}/disable`,
check: () => `${API_BASE_URL}/api/admin/mcp-users/check`,
},
// ── Maintenance ───────────────────────────────────────────────────────────
maintenance: {
trigger: (date?: string) =>
File diff suppressed because one or more lines are too long
+78 -10
View File
@@ -1,4 +1,80 @@
# Data Access Layer — Migration Plan
# MCP Server → Azure Container App (Remote Deployment)
## Ziel
MCP Server als Remote-Service auf Azure Container Apps deployen (`autopilotmonitor-mcp`), damit OAuth mit richtiger Redirect URI funktioniert und kein lokaler Device Code Flow nötig ist.
## Plan
### Phase 1: Transport umstellen (stdio → Streamable HTTP)
- [ ] `index.ts` auf HTTP/SSE Transport umstellen (MCP SDK `StreamableHTTPServerTransport`)
- [ ] Health-Check Endpoint `/health` hinzufügen
- [ ] Port konfigurierbar via `PORT` env var (default 8080)
- [ ] Auth-Strategie anpassen: Remote Server braucht keinen Device Code Flow — der **Claude Code Client** übernimmt die OAuth-Authentifizierung und sendet das Token im MCP-Protokoll
### Phase 2: Zugriffskontrolle (MCP Access Policy + User Whitelist)
- [ ] Shared: `McpAccessPolicy` Enum — `Disabled`, `WhitelistOnly`, `AllMembers`
- [ ] Shared: `AdminConfiguration.McpAccessPolicy` Property (Default: `WhitelistOnly`)
- [ ] Backend: `McpUsers` Table Storage (Pattern wie `GlobalAdmins`) — PartitionKey="McpUsers", RowKey=UPN
- [ ] Backend: `McpUserService` mit Cache (5min TTL), `IsAllowedAsync(upn)` prüft Policy:
- `Disabled` → immer false
- `WhitelistOnly` → GlobalAdmin ODER McpUsers-Eintrag
- `AllMembers` → jeder authentifizierte User
- [ ] Backend: `McpUserFunction` — CRUD Endpoints (`GET/POST/DELETE /api/admin/mcp-users`), Policy: `GlobalAdminOnly`
- [ ] Backend: `GET /api/admin/mcp-users/check` — für MCP Server Auth-Check (lightweight, cached)
- [ ] Backend: Endpoints in `EndpointAccessPolicyCatalog` registrieren
- [ ] MCP Server: Token-Validierung Middleware — JWT prüfen, UPN extrahieren, gegen Backend `/api/admin/mcp-users/check` validieren
- [ ] MCP Server: Per-User Rate Limiting (in-memory, z.B. 60 req/min pro UPN, konfigurierbar via `MCP_RATE_LIMIT_PER_MINUTE`)
### Phase 3: Container-Setup
- [ ] `Dockerfile` erstellen (Node.js slim, multi-stage build, mit `@huggingface/transformers`)
- [ ] `.dockerignore` erstellen
- [ ] Lokal testen: `docker build` + `docker run`
### Phase 4: Azure Container App Deployment
- [ ] Container App Konfiguration (YAML oder Bicep) erstellen
- [ ] Environment Variables definieren (`AUTOPILOT_API_URL`, `PORT`, etc.)
- [ ] Container App erstellen: `autopilotmonitor-mcp`
- [ ] VS Code Azure Extension: Deploy to Container App testen
### Phase 5: OAuth für Remote MCP
- [ ] Entra App Registration: Redirect URI auf Container App URL setzen
- [ ] MCP Server OAuth Provider Metadata implementieren (`/.well-known/oauth-authorization-server`)
- [ ] Claude Code `.mcp.json` auf Remote URL umstellen
### Phase 6: Web UI — MCP Verwaltung
- [ ] Settings-Seite: MCP Access Policy Toggle (Disabled / WhitelistOnly / AllMembers)
- [ ] Settings-Seite: MCP Users Section (nur sichtbar wenn Policy = WhitelistOnly)
- [ ] User-Liste mit UPN, AddedBy, AddedDate, IsEnabled
- [ ] Add User (UPN Eingabe + Add Button)
- [ ] Remove User (Delete Button mit Confirmation)
- [ ] Enable/Disable Toggle
- [ ] Success/Error Notifications (bestehendes Pattern mit `error`/`successMessage` State)
### Phase 7: Aufräumen & Test
- [ ] Device Code Flow Code entfernen (nicht mehr nötig bei Remote)
- [ ] Lokale stdio-Variante als Fallback behalten? (Entscheidung)
- [ ] End-to-End Test: MCP Tools aus VS Code Claude Extension aufrufen
## Architektur-Entscheidungen
- **Container Apps Consumption Plan** — Scale to Zero, praktisch kostenlos bei sporadischer Nutzung
- **Name**: `autopilotmonitor-mcp` (konsistent mit `autopilotmonitor-api`)
- **Search Backend**: Vector Search (`@huggingface/transformers`, Xenova/all-MiniLM-L6-v2) — semantische Suche über Rules/Knowledge Base
- **Auth**: OAuth wird vom Claude Code Client gehandhabt — der MCP Server validiert das Bearer Token
- **Zugriffskontrolle**: 3-stufige Policy via `AdminConfiguration.McpAccessPolicy`
- `Disabled` — MCP komplett aus
- `WhitelistOnly` (Default) — GlobalAdmins + explizit freigeschaltete McpUsers
- `AllMembers` — jeder authentifizierte User
- Pattern identisch zu `GlobalAdmins` Table (UPN-basiert, IsEnabled Flag, Cache)
- GlobalAdmins haben automatisch Zugriff (kein separater Eintrag nötig)
- Policy-Wechsel = Config-Flip im Settings UI, kein Deployment nötig
---
# Data Access Layer — Migration Plan (abgeschlossen)
<details>
<summary>Phase 1-4 ✅ abgeschlossen</summary>
## Phase 1: Foundation ✅
- [x] Define 10 Repository Interfaces in `Shared/DataAccess/`
@@ -28,7 +104,6 @@
- [x] `ApiKeyManagementFunction` + `ApiKeyMiddleware` → `IAdminRepository`
## Phase 4: Migrate Bypass Services ✅
All services that created their own `TableServiceClient` now use repository interfaces:
- [x] `TenantConfigurationService` → `IConfigRepository`
- [x] `AdminConfigurationService` → `IConfigRepository`
- [x] `PreviewWhitelistService` → `IConfigRepository`
@@ -42,14 +117,7 @@ All services that created their own `TableServiceClient` now use repository inte
- [x] `BlockedVersionService` → `IDeviceSecurityRepository`
- [x] `VulnerabilityCorrelationService` → `IVulnerabilityRepository`
## Current State ✅
`new TableServiceClient()` only exists in:
- 5 Repository implementations (DataAccess/TableStorage/) — correct, they ARE the storage layer
- `TableStorageService.cs` — the core implementation used by repos
- `FeedbackFunction.cs` — domain-specific feedback CRUD (PreviewConfig table)
- `TenantOffboardFunction.cs` — bulk tenant data deletion across all tables
All 10 repository interfaces have Table Storage implementations registered in DI.
</details>
## Phase 5: Event Streaming (When Ready)
- [ ] Implement `EventHubPublisher` (or `ServiceBusPublisher`)