mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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: ""
|
||||
@@ -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 }}
|
||||
@@ -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: ""
|
||||
@@ -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 }}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+92
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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`)
|
||||
|
||||
Reference in New Issue
Block a user