mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
feat(web,backend): AI Integration settings page; one self-hosted client per tenant, raisable by a Global Admin
- Settings > Tenant > AI Integration (between SLA Targets and Bootstrap Sessions) explains the normal connection first - server URL for Claude, ChatGPT, VS Code and other hosted assistants, nothing to register - and shows the self-hosted client registration below it (Tenant Admin, platform switch on). The page is visible to admins, platform scopes and anyone with MCP access. The self-hosted example text is neutral. - A tenant may hold one self-hosted client registration. The GA-only TenantConfiguration.McpClientRegistrationLimit (1-10) raises it on request: reverted for non-GA callers on PUT, refused on PATCH, edited in the Global Admin tenant dialog. MCP_SERVER_URL joins the portal URL registry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
Submodule internal updated: d07d299f36...deb4fd0ab2
@@ -141,7 +141,7 @@ public class McpClientRegistrationTests
|
||||
public required Mock<IMaintenanceRepository> Audit { get; init; }
|
||||
}
|
||||
|
||||
private static Harness Build(bool enabled = true)
|
||||
private static Harness Build(bool enabled = true, int? tenantLimit = null)
|
||||
{
|
||||
var rows = new List<McpClientRegistration>();
|
||||
var repo = new Mock<IMcpClientRegistrationRepository>();
|
||||
@@ -156,7 +156,11 @@ public class McpClientRegistrationTests
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var adminConfig = new Mock<AdminConfigurationService>(Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { McpClientRegistrationEnabled = enabled });
|
||||
var sut = new McpClientRegistrationService(repo.Object, audit.Object, adminConfig.Object, NullLogger<McpClientRegistrationService>.Instance);
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((string t) => new TenantConfiguration { TenantId = t, McpClientRegistrationLimit = t == TenantA ? tenantLimit : null });
|
||||
var tenantConfig = new TenantConfigurationService(configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
var sut = new McpClientRegistrationService(repo.Object, audit.Object, adminConfig.Object, tenantConfig, NullLogger<McpClientRegistrationService>.Instance);
|
||||
return new Harness { Sut = sut, Repo = repo, Audit = audit };
|
||||
}
|
||||
|
||||
@@ -189,23 +193,40 @@ public class McpClientRegistrationTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_EnforcesTheCap_AndRefusesADuplicateCallback()
|
||||
public async Task Create_AllowsOneRegistrationByDefault_PerTenant()
|
||||
{
|
||||
var h = Build();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, (await h.Sut.CreateAsync(TenantA, "chat", Callback, "a")).Status);
|
||||
var second = await h.Sut.CreateAsync(TenantA, "chat 2", "https://chat2.contoso.example/cb", "a");
|
||||
var otherTenant = await h.Sut.CreateAsync(TenantB, "chat", Callback, "b");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, second.Status);
|
||||
Assert.Contains("raise the limit", second.Error);
|
||||
Assert.Equal(HttpStatusCode.Created, otherTenant.Status); // the limit and the duplicate check are per tenant
|
||||
Assert.Equal(1, await h.Sut.GetLimitAsync(TenantA));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AGlobalAdminOverride_RaisesTheLimit_AndDuplicatesStayRefused()
|
||||
{
|
||||
var h = Build(tenantLimit: 3);
|
||||
Assert.Equal(3, await h.Sut.GetLimitAsync(TenantA.ToUpperInvariant()));
|
||||
for (var i = 0; i < 3; i++)
|
||||
Assert.Equal(HttpStatusCode.Created, (await h.Sut.CreateAsync(TenantA, $"chat {i}", $"https://chat{i}.contoso.example/cb", "a")).Status);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await h.Sut.CreateAsync(TenantA, "chat 4", "https://chat4.contoso.example/cb", "a")).Status);
|
||||
|
||||
var fourth = await h.Sut.CreateAsync(TenantA, "chat 4", "https://chat4.contoso.example/cb", "a");
|
||||
var otherTenant = await h.Sut.CreateAsync(TenantB, "chat", "https://chat0.contoso.example/cb", "b");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, fourth.Status);
|
||||
Assert.Equal(HttpStatusCode.Created, otherTenant.Status); // the cap and the duplicate check are per tenant
|
||||
|
||||
var dup = Build();
|
||||
var dup = Build(tenantLimit: 3);
|
||||
await dup.Sut.CreateAsync(TenantA, "one", Callback, "a");
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await dup.Sut.CreateAsync(TenantA, "two", Callback.ToUpperInvariant(), "a")).Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(50, 10)]
|
||||
public async Task AnOutOfRangeOverride_IsClamped(int stored, int effective)
|
||||
=> Assert.Equal(effective, await Build(tenantLimit: stored).Sut.GetLimitAsync(TenantA));
|
||||
|
||||
[Fact]
|
||||
public async Task Create_ValidatesBeforeStoring()
|
||||
{
|
||||
|
||||
+2
@@ -670,6 +670,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "DisabledUntil", config.DisabledUntil },
|
||||
{ "McpDisabled", config.McpDisabled },
|
||||
{ "McpDisabledReason", config.McpDisabledReason },
|
||||
{ "McpClientRegistrationLimit", config.McpClientRegistrationLimit },
|
||||
{ "CustomRateLimitRequestsPerMinute", config.CustomRateLimitRequestsPerMinute },
|
||||
{ "CustomUserRateLimitRequestsPerMinute", config.CustomUserRateLimitRequestsPerMinute },
|
||||
{ "ManufacturerWhitelist", config.ManufacturerWhitelist },
|
||||
@@ -784,6 +785,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
DisabledUntil = entity.GetDateTime("DisabledUntil"),
|
||||
McpDisabled = entity.GetBoolean("McpDisabled") ?? false,
|
||||
McpDisabledReason = entity.GetString("McpDisabledReason"),
|
||||
McpClientRegistrationLimit = entity.GetInt32("McpClientRegistrationLimit"),
|
||||
CustomRateLimitRequestsPerMinute = entity.GetInt32("CustomRateLimitRequestsPerMinute"),
|
||||
CustomUserRateLimitRequestsPerMinute = entity.GetInt32("CustomUserRateLimitRequestsPerMinute"),
|
||||
ManufacturerWhitelist = entity.GetString("ManufacturerWhitelist") ?? "Dell*,HP*,Lenovo*,Microsoft Corporation",
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ public class McpClientRegistrationFunction
|
||||
return await req.OkAsync(new McpClientRegistrationListResponse
|
||||
{
|
||||
Enabled = await _service.IsEnabledAsync(),
|
||||
MaxRegistrations = McpClientRegistrationService.MaxRegistrationsPerTenant,
|
||||
MaxRegistrations = await _service.GetLimitAsync(requestCtx.TargetTenantId),
|
||||
ServerUrl = $"{Constants.McpServerBaseUrl}/mcp",
|
||||
Registrations = registrations.Select(ToItem).ToList(),
|
||||
});
|
||||
|
||||
+3
-1
@@ -128,7 +128,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
config.CustomRateLimitRequestsPerMinute != existingConfig.CustomRateLimitRequestsPerMinute ||
|
||||
config.CustomUserRateLimitRequestsPerMinute != existingConfig.CustomUserRateLimitRequestsPerMinute ||
|
||||
config.Disabled != existingConfig.Disabled ||
|
||||
config.McpDisabled != existingConfig.McpDisabled)
|
||||
config.McpDisabled != existingConfig.McpDisabled ||
|
||||
config.McpClientRegistrationLimit != existingConfig.McpClientRegistrationLimit)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tenant Admin {User} attempted to modify GA-only fields for tenant {TenantId}",
|
||||
@@ -148,6 +149,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
config.DisabledReason = existingConfig.DisabledReason;
|
||||
config.DisabledUntil = existingConfig.DisabledUntil;
|
||||
config.McpDisabled = existingConfig.McpDisabled;
|
||||
config.McpClientRegistrationLimit = existingConfig.McpClientRegistrationLimit;
|
||||
config.McpDisabledReason = existingConfig.McpDisabledReason;
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,10 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
if (customLimitError != null)
|
||||
return $"{customLimitError} override must be at least 1 request per minute (or left blank to inherit the global default).";
|
||||
|
||||
if (candidate.McpClientRegistrationLimit is int registrations
|
||||
&& (registrations < 1 || registrations > Services.McpClientRegistrationService.MaxRegistrationLimit))
|
||||
return $"Self-hosted AI client registrations must be between 1 and {Services.McpClientRegistrationService.MaxRegistrationLimit} (or left blank for the default of {Services.McpClientRegistrationService.DefaultRegistrationLimit}).";
|
||||
|
||||
var contactEmailError = ValidateContactEmail(candidate.ContactEmail);
|
||||
if (contactEmailError != null)
|
||||
return $"Invalid contact email: {contactEmailError}";
|
||||
|
||||
@@ -13,7 +13,10 @@ namespace AutopilotMonitor.Functions.Services;
|
||||
/// </summary>
|
||||
public class McpClientRegistrationService
|
||||
{
|
||||
public const int MaxRegistrationsPerTenant = 3;
|
||||
/// <summary>Registrations a tenant may hold unless a Global Admin raised its limit on request.</summary>
|
||||
public const int DefaultRegistrationLimit = 1;
|
||||
/// <summary>Upper bound of the per-tenant override (<c>TenantConfiguration.McpClientRegistrationLimit</c>).</summary>
|
||||
public const int MaxRegistrationLimit = 10;
|
||||
public const int MaxNameLength = 64;
|
||||
/// <summary>Mirrors the MCP proxy's redirect_uri length limit (oauth-limits.ts).</summary>
|
||||
public const int MaxRedirectUriLength = 1024;
|
||||
@@ -25,20 +28,30 @@ public class McpClientRegistrationService
|
||||
private readonly IMcpClientRegistrationRepository _repo;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
private readonly ILogger<McpClientRegistrationService> _logger;
|
||||
|
||||
public McpClientRegistrationService(
|
||||
IMcpClientRegistrationRepository repo,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
AdminConfigurationService adminConfigService,
|
||||
TenantConfigurationService tenantConfigService,
|
||||
ILogger<McpClientRegistrationService> logger)
|
||||
{
|
||||
_repo = repo;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_adminConfigService = adminConfigService;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>The tenant's registration limit: its Global-Admin override, else the default of one.</summary>
|
||||
public virtual async Task<int> GetLimitAsync(string tenantId)
|
||||
{
|
||||
var (config, _) = await _tenantConfigService.TryGetConfigurationAsync(tenantId.ToLowerInvariant());
|
||||
return config.McpClientRegistrationLimit is int limit && limit >= 1 ? Math.Min(limit, MaxRegistrationLimit) : DefaultRegistrationLimit;
|
||||
}
|
||||
|
||||
/// <summary>The operator switch (5-minute cached admin configuration).</summary>
|
||||
public virtual async Task<bool> IsEnabledAsync()
|
||||
=> (await _adminConfigService.GetConfigurationAsync()).McpClientRegistrationEnabled;
|
||||
@@ -95,8 +108,10 @@ public class McpClientRegistrationService
|
||||
var tenant = tenantId.ToLowerInvariant();
|
||||
var callback = redirectUri!.Trim();
|
||||
var existing = await _repo.GetForTenantAsync(tenant);
|
||||
if (existing.Count >= MaxRegistrationsPerTenant)
|
||||
return McpClientRegistrationResult.Fail(HttpStatusCode.Conflict, $"A tenant can register at most {MaxRegistrationsPerTenant} self-hosted clients. Delete one first.");
|
||||
var limit = await GetLimitAsync(tenant);
|
||||
if (existing.Count >= limit)
|
||||
return McpClientRegistrationResult.Fail(HttpStatusCode.Conflict,
|
||||
$"This tenant can register {limit} self-hosted AI client{(limit == 1 ? "" : "s")}. Delete one first, or ask us to raise the limit.");
|
||||
if (existing.Any(r => string.Equals(r.RedirectUri, callback, StringComparison.OrdinalIgnoreCase)))
|
||||
return McpClientRegistrationResult.Fail(HttpStatusCode.Conflict, "This callback URL is already registered.");
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
"EnableEspContinueAnywayObservation",
|
||||
"CustomRateLimitRequestsPerMinute", "CustomUserRateLimitRequestsPerMinute",
|
||||
"MaxNdjsonPayloadSizeMB",
|
||||
"McpClientRegistrationLimit",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -4743,6 +4743,8 @@ export interface TenantConfiguration {
|
||||
mcpDisabled: boolean;
|
||||
/** Optional reason why MCP access was disabled. Shown to the caller in the 403 response. */
|
||||
mcpDisabledReason?: string | null;
|
||||
/** How many self-hosted MCP client registrations this tenant may hold. Null = the platform default (one); set by a Global Admin on request, 1-10. Global-Admin-only (UpdateTenantConfigurationFunction GA gate, TenantConfigPatchService.GaOnlyFields). */
|
||||
mcpClientRegistrationLimit?: number | null;
|
||||
/** Optional per-tenant override for the device (agent/cert) API rate limit. If null, the effective limit is the global AdminConfiguration.GlobalRateLimitRequestsPerMinute. If set, this value takes precedence. Global-Admin-only (see UpdateTenantConfigurationFunction GA-gate). */
|
||||
customRateLimitRequestsPerMinute?: number | null;
|
||||
/** Optional per-tenant override for the user (portal/JWT) API rate limit applied to standard users (Tenant Admins, Operators, Viewers). If null, the effective limit is the global AdminConfiguration.UserRateLimitRequestsPerMinute. Global-Admin-only. Note: Global Admins are rate-limited by the global GlobalAdminRateLimitRequestsPerMinute (cross-tenant), so this override does not apply to them. */
|
||||
|
||||
@@ -155,6 +155,13 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public string? McpDisabledReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How many self-hosted MCP client registrations this tenant may hold. Null = the platform default
|
||||
/// (one); set by a Global Admin on request, 1-10. Global-Admin-only (UpdateTenantConfigurationFunction
|
||||
/// GA gate, TenantConfigPatchService.GaOnlyFields).
|
||||
/// </summary>
|
||||
public int? McpClientRegistrationLimit { get; set; } = null;
|
||||
|
||||
// ===== SECURITY SETTINGS =====
|
||||
|
||||
/// <summary>
|
||||
@@ -1084,6 +1091,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
DisabledUntil = null,
|
||||
McpDisabled = false,
|
||||
McpDisabledReason = null,
|
||||
McpClientRegistrationLimit = null,
|
||||
CustomRateLimitRequestsPerMinute = null,
|
||||
CustomUserRateLimitRequestsPerMinute = null,
|
||||
ManufacturerWhitelist = "Dell*,HP*,Lenovo*,Microsoft Corporation",
|
||||
|
||||
@@ -1185,6 +1185,24 @@ function TenantManagementSectionInner({
|
||||
<p className="mt-1 text-xs text-gray-500">Per-user limit for MCP and integration requests made on behalf of this tenant's standard users. Portal sessions use the global portal budget and are not affected. Leave blank to inherit the global default. Does not apply to Global Admins.</p>
|
||||
</div>
|
||||
|
||||
{/* Self-hosted AI client registrations (raised on request) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Self-hosted AI Client Registrations</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
placeholder="Blank = default (1)"
|
||||
value={editingTenant.mcpClientRegistrationLimit ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
setEditingTenant({ ...editingTenant, mcpClientRegistrationLimit: v === "" ? null : (parseInt(v) || null) });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">How many self-hosted AI clients this tenant may register under Settings → Tenant → AI Integration (1–10). Leave blank for the default of one; raise it on request.</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -98,6 +98,7 @@ export const TENANT_FIELDS: Record<keyof TenantConfiguration, TenantRow | Exclud
|
||||
disabledReason: { section: "Tenant Status", label: "Disabled Reason", informational: true },
|
||||
disabledUntil: { section: "Tenant Status", label: "Disabled Until", kind: "date", informational: true },
|
||||
mcpDisabled: { section: "Tenant Status", label: "MCP Disabled" },
|
||||
mcpClientRegistrationLimit: { section: "Tenant Status", label: "Self-hosted AI Client Registrations" },
|
||||
mcpDisabledReason: { section: "Tenant Status", label: "MCP Disabled Reason", informational: true },
|
||||
onboardedAt: { section: "Tenant Status", label: "Onboarded At", kind: "date", informational: true },
|
||||
onboardedBy: { section: "Tenant Status", label: "Onboarded By", informational: true },
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SectionSubmitLogs } from "../sections/SectionSubmitLogs";
|
||||
import { SectionContact } from "../sections/SectionContact";
|
||||
import { SectionOptionalGraphCapabilities } from "../sections/SectionOptionalGraphCapabilities";
|
||||
import { SectionDelegatedAccess } from "../sections/SectionDelegatedAccess";
|
||||
import { SectionSelfHostedClients } from "../sections/SectionSelfHostedClients";
|
||||
import { SectionAiIntegration } from "../sections/SectionAiIntegration";
|
||||
|
||||
const SECTION_COMPONENTS: Record<TenantSectionId, React.ComponentType> = {
|
||||
"plan": SectionPlan,
|
||||
@@ -23,7 +23,7 @@ const SECTION_COMPONENTS: Record<TenantSectionId, React.ComponentType> = {
|
||||
"sla-targets": SectionSlaTargets,
|
||||
"access-management": SectionAccessManagement,
|
||||
"delegations": SectionDelegatedAccess,
|
||||
"ai-clients": SectionSelfHostedClients,
|
||||
"ai-integration": SectionAiIntegration,
|
||||
"bootstrap-sessions": SectionBootstrapSessions,
|
||||
"graph-permissions": SectionOptionalGraphCapabilities,
|
||||
"contact": SectionContact,
|
||||
|
||||
+71
-40
@@ -8,6 +8,7 @@ import { apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
|
||||
import { api } from "@/lib/api";
|
||||
import { SectionCardHeader } from "@/components/SectionCardHeader";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
import { MCP_SERVER_URL } from "@/utils/config";
|
||||
import type {
|
||||
CreateMcpClientRegistrationRequest,
|
||||
CreateMcpClientRegistrationResponse,
|
||||
@@ -20,15 +21,69 @@ function formatDay(iso: string | undefined | null): string {
|
||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function useCopy() {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const copy = useCallback(async (value: string, key: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
} catch {
|
||||
setCopied(null);
|
||||
}
|
||||
}, []);
|
||||
return { copied, copy };
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → Tenant → Self-hosted AI clients. A Tenant Admin registers the exact OAuth callback of an AI
|
||||
* client the organization runs itself; the client then connects to the MCP server with client id
|
||||
* amc_<id> through the normal browser sign-in, bound to this tenant. Every change is audited.
|
||||
* Settings → Tenant → AI Integration. Part 1 explains the normal connection (Claude, ChatGPT, VS Code and
|
||||
* other supported AI clients: add the server URL, sign in — nothing to register here). Part 2, for Tenant
|
||||
* Admins while the platform switch is on, registers the exact OAuth callback of an AI client the
|
||||
* organization hosts itself; that client then connects with client id amc_<id>, bound to this tenant.
|
||||
*/
|
||||
export function SectionSelfHostedClients() {
|
||||
export function SectionAiIntegration() {
|
||||
const { user } = useAuth();
|
||||
const { canEditConfig } = useTenantConfig();
|
||||
const { copied, copy } = useCopy();
|
||||
const showSelfHosted = canEditConfig && (user?.mcpClientRegistrationEnabled ?? false);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<SectionCardHeader
|
||||
tone="skyIndigo"
|
||||
iconPath="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
||||
title="AI Integration"
|
||||
subtitle="Ask Claude, ChatGPT, VS Code or another AI assistant about your enrollments. The assistant reads your data through the Autopilot Monitor MCP server."
|
||||
docsPath={DOCS_PATHS.mcpUsers}
|
||||
/>
|
||||
<div className="p-6 space-y-3 text-sm text-gray-700">
|
||||
<p>
|
||||
<strong>Nothing to register here.</strong> Add this server URL as an MCP server (connector) in your AI client:
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="text-sm bg-gray-100 text-gray-800 rounded px-2 py-1">{MCP_SERVER_URL}</code>
|
||||
<button type="button" onClick={() => void copy(MCP_SERVER_URL, "server-main")} className="text-xs text-indigo-600 hover:text-indigo-800">
|
||||
{copied === "server-main" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
The client opens the Microsoft sign-in in your browser. Sign in with your Autopilot Monitor account; the assistant
|
||||
then sees exactly what your role allows in the portal. The setup guide covers each client step by step.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSelfHosted && <SelfHostedClients />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Part 2: registrations of AI clients the organization hosts itself (Tenant Admin, platform switch on). */
|
||||
function SelfHostedClients() {
|
||||
const { getAccessToken } = useAuth();
|
||||
const { tenantId } = useTenant();
|
||||
const { canEditConfig } = useTenantConfig();
|
||||
const { copied, copy } = useCopy();
|
||||
|
||||
const [data, setData] = useState<McpClientRegistrationListResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -38,7 +93,6 @@ export function SectionSelfHostedClients() {
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [redirectUri, setRedirectUri] = useState("");
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const notify = (msg: string) => {
|
||||
setFlash(msg);
|
||||
@@ -59,12 +113,11 @@ export function SectionSelfHostedClients() {
|
||||
}, [tenantId, getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canEditConfig) return;
|
||||
const run = async () => {
|
||||
await load();
|
||||
};
|
||||
void run();
|
||||
}, [load, canEditConfig]);
|
||||
}, [load]);
|
||||
|
||||
const create = useCallback(async () => {
|
||||
if (!tenantId) return;
|
||||
@@ -102,39 +155,21 @@ export function SectionSelfHostedClients() {
|
||||
}
|
||||
}, [tenantId, getAccessToken, load]);
|
||||
|
||||
const copy = useCallback(async (value: string, key: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
} catch {
|
||||
setCopied(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!canEditConfig) {
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
|
||||
This page is available to tenant administrators only.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const registrations = data?.registrations ?? [];
|
||||
const atCap = data ? registrations.length >= data.maxRegistrations : false;
|
||||
const atLimit = data ? registrations.length >= data.maxRegistrations : false;
|
||||
const inputClass = "px-3 py-1.5 border border-gray-300 rounded-lg text-sm bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
{flash && <div className="bg-green-50 border border-green-200 rounded-lg p-4 text-sm text-green-800">{flash}</div>}
|
||||
{error && <div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-800">{error}</div>}
|
||||
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<SectionCardHeader
|
||||
tone="skyIndigo"
|
||||
iconPath="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"
|
||||
iconPath="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
|
||||
title="Self-hosted AI clients"
|
||||
subtitle="Connect an AI client your organization runs itself through the normal sign-in. Only accounts of this tenant can sign in through a registered client."
|
||||
subtitle="Only for an AI client your organization runs on its own servers. Claude, ChatGPT, VS Code and other hosted assistants need nothing here."
|
||||
docsPath={DOCS_PATHS.selfHostedClients}
|
||||
/>
|
||||
<div className="p-6 space-y-4">
|
||||
@@ -149,23 +184,23 @@ export function SectionSelfHostedClients() {
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name, e.g. Team chat"
|
||||
placeholder="Name, e.g. Team AI"
|
||||
maxLength={64}
|
||||
className={`${inputClass} w-44`}
|
||||
className={`${inputClass} w-48`}
|
||||
aria-label="Client name"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
value={redirectUri}
|
||||
onChange={(e) => setRedirectUri(e.target.value)}
|
||||
placeholder="https://chat.example.com/api/mcp/autopilot-monitor/oauth/callback"
|
||||
placeholder="https://ai.example.com/oauth/callback"
|
||||
className={`${inputClass} flex-1 min-w-[18rem] font-mono`}
|
||||
aria-label="Callback URL"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void create()}
|
||||
disabled={busy !== null || atCap || !data?.enabled || !name.trim() || !redirectUri.trim()}
|
||||
disabled={busy !== null || atLimit || !data?.enabled || !name.trim() || !redirectUri.trim()}
|
||||
className="px-3 py-1.5 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{busy === "create" ? "Registering…" : "Register"}
|
||||
@@ -173,7 +208,7 @@ export function SectionSelfHostedClients() {
|
||||
</div>
|
||||
{data && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{registrations.length} of {data.maxRegistrations} registrations used. The callback URL must match exactly what the client sends: https, or http only for localhost.
|
||||
{registrations.length} of {data.maxRegistrations} registration{data.maxRegistrations === 1 ? "" : "s"} used; more are available on request. The callback URL must match exactly what the client sends: https, or http only for localhost.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -232,11 +267,7 @@ export function SectionSelfHostedClients() {
|
||||
</li>
|
||||
<li>Authentication: OAuth. Enter the client ID from the list above and leave the client secret empty.</li>
|
||||
<li>Leave the authorization and token URLs empty; the client discovers them from the server.</li>
|
||||
<li>
|
||||
Register the callback URL the client really uses. LibreChat, for example, uses{" "}
|
||||
<code className="text-xs bg-white border border-gray-200 rounded px-1.5 py-0.5">https://<your LibreChat host>/api/mcp/<server name>/oauth/callback</code>
|
||||
, where the server name is the name you give the server in LibreChat.
|
||||
</li>
|
||||
<li>Register the exact callback URL your client uses for its OAuth sign-in. The client's MCP server settings or its documentation show it.</li>
|
||||
<li>Connecting opens the Microsoft sign-in. Users sign in with their account of this tenant; their role applies as in the portal.</li>
|
||||
</ol>
|
||||
</details>
|
||||
@@ -1,11 +1,11 @@
|
||||
export const TENANT_NAV_SECTIONS = [
|
||||
{ id: "access-management", label: "Access Management", description: "Manage tenant admins and operators" },
|
||||
{ id: "delegations", label: "Delegated Access", description: "Who can read your tenant, and the tenants you manage (MSP)" },
|
||||
{ id: "ai-clients", label: "Self-hosted AI clients", description: "Connect an AI client your organization runs itself" },
|
||||
{ id: "autopilot", label: "Autopilot Validation", description: "Autopilot device and corporate identifier validation" },
|
||||
{ id: "hardware-whitelist", label: "Hardware Whitelist", description: "Manufacturer and model whitelist configuration" },
|
||||
{ id: "notifications", label: "Notifications", description: "Webhook notification configuration" },
|
||||
{ id: "sla-targets", label: "SLA Targets", description: "SLA targets and breach notification settings" },
|
||||
{ id: "ai-integration", label: "AI Integration", description: "Connect AI assistants and self-hosted AI clients through MCP" },
|
||||
{ id: "bootstrap-sessions", label: "Bootstrap Sessions", description: "Create and manage bootstrap tokens" },
|
||||
{ id: "graph-permissions", label: "Optional Graph capabilities", description: "Grant additional Microsoft Graph permissions for optional features" },
|
||||
{ id: "support", label: "Submit Logs", description: "Send diagnostic files to the Autopilot Monitor team" },
|
||||
|
||||
@@ -224,13 +224,23 @@ describe("item and sub-item filtering", () => {
|
||||
expect(subIds(bootstrapOperator)).not.toContain("cfg-agent-unrestricted");
|
||||
});
|
||||
|
||||
it("self-hosted AI clients follow the platform switch and are Tenant Admin only", () => {
|
||||
expect(subIds(flagsOf("TenantAdmin"))).not.toContain("cfg-ai-clients");
|
||||
const switchedOn = (u: Parameters<typeof user>[0]) =>
|
||||
deriveNavFlags({ user: user({ ...u, mcpClientRegistrationEnabled: true }), hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false });
|
||||
expect(subIds(switchedOn({ isTenantAdmin: true, role: "Admin" }))).toContain("cfg-ai-clients");
|
||||
expect(subIds(switchedOn({ role: "Operator" }))).not.toContain("cfg-ai-clients");
|
||||
expect(subIds(switchedOn({ role: "Viewer" }))).not.toContain("cfg-ai-clients");
|
||||
it("AI Integration shows for admins and for anyone with MCP access, independent of the self-hosted switch", () => {
|
||||
const flags = (u: Parameters<typeof user>[0]) =>
|
||||
deriveNavFlags({ user: user(u), hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false });
|
||||
expect(subIds(flags({ isTenantAdmin: true, role: "Admin" }))).toContain("cfg-ai-integration");
|
||||
expect(subIds(flags({ role: "Viewer", hasMcpAccess: true }))).toContain("cfg-ai-integration");
|
||||
expect(subIds(flags({ role: "Operator" }))).not.toContain("cfg-ai-integration");
|
||||
expect(subIds(flagsOf("GlobalReader+adminModeOn"))).toContain("cfg-ai-integration");
|
||||
});
|
||||
|
||||
it("AI Integration sits between SLA Targets and Bootstrap Sessions", () => {
|
||||
const ids = subIds(deriveNavFlags({
|
||||
user: user({ isTenantAdmin: true, role: "Admin", bootstrapTokenEnabled: true }),
|
||||
hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false,
|
||||
}));
|
||||
const at = ids.indexOf("cfg-ai-integration");
|
||||
expect(ids[at - 1]).toBe("cfg-sla-targets");
|
||||
expect(ids[at + 1]).toBe("cfg-bootstrap-sessions");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -210,11 +210,11 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [
|
||||
items: [
|
||||
{ id: "cfg-access-mgmt", label: "Access Management", href: route("/settings/tenant/access-management") },
|
||||
{ id: "cfg-delegations", label: "Delegated Access", href: route("/settings/tenant/delegations") },
|
||||
{ id: "cfg-ai-clients", label: "Self-hosted AI clients", href: route("/settings/tenant/ai-clients") },
|
||||
{ id: "cfg-autopilot", label: "Autopilot Validation", href: route("/settings/tenant/autopilot") },
|
||||
{ id: "cfg-hardware", label: "Hardware Whitelist", href: route("/settings/tenant/hardware-whitelist") },
|
||||
{ id: "cfg-notifications", label: "Notifications", href: route("/settings/tenant/notifications") },
|
||||
{ id: "cfg-sla-targets", label: "SLA Targets", href: route("/settings/tenant/sla-targets") },
|
||||
{ id: "cfg-ai-integration", label: "AI Integration", href: route("/settings/tenant/ai-integration") },
|
||||
{ id: "cfg-bootstrap-sessions", label: "Bootstrap Sessions", href: route("/settings/tenant/bootstrap-sessions") },
|
||||
{ id: "cfg-graph-permissions", label: "Optional Graph capabilities", href: route("/settings/tenant/graph-permissions") },
|
||||
{ id: "cfg-support", label: "Submit Logs", href: route("/settings/tenant/support") },
|
||||
|
||||
@@ -132,9 +132,10 @@ export function filterExpandableNavGroups(
|
||||
if (sub.id === "cfg-agent-unrestricted") {
|
||||
return flags.isAdminLike && flags.unrestrictedModeEnabled;
|
||||
}
|
||||
// Registrations are a Tenant Admin (or Global Admin) write surface behind the platform switch.
|
||||
if (sub.id === "cfg-ai-clients") {
|
||||
return flags.isAdminLike && flags.mcpClientRegistrationEnabled;
|
||||
// How to connect an AI assistant: for anyone who can use MCP, plus admins and platform scopes.
|
||||
// The self-hosted registration part inside is Tenant Admin only and behind the platform switch.
|
||||
if (sub.id === "cfg-ai-integration") {
|
||||
return flags.hasMcpAccess || flags.isAdminLike || flags.hasGlobalScope;
|
||||
}
|
||||
// Tenant-admin-only sub-sections: Operators and Viewers (read-only settings
|
||||
// viewers) don't see them — matches the in-page "tenant administrators only" gates. A platform
|
||||
|
||||
@@ -59,6 +59,9 @@ export const BLOB_URL_PROD = "https://autopilotmonitoreu.blob.core.windows.net";
|
||||
*/
|
||||
export const AGENT_DOWNLOAD_URL = "https://download.autopilotmonitor.com/agent";
|
||||
|
||||
/** The MCP endpoint every AI client connects to. Counterpart of Constants.McpServerBaseUrl + "/mcp". */
|
||||
export const MCP_SERVER_URL = "https://mcp.autopilotmonitor.com/mcp";
|
||||
|
||||
/**
|
||||
* Hostnames the portal accepts in a bootstrap response's agentDownloadUrl.
|
||||
* Keep in sync with ValidateBootstrapCodeFunction.cs, which builds that URL
|
||||
|
||||
@@ -140,6 +140,7 @@ export const SHARED_MANIFEST = {
|
||||
"disabledUntil",
|
||||
"mcpDisabled",
|
||||
"mcpDisabledReason",
|
||||
"mcpClientRegistrationLimit",
|
||||
"customRateLimitRequestsPerMinute",
|
||||
"customUserRateLimitRequestsPerMinute",
|
||||
"planTier",
|
||||
@@ -245,6 +246,7 @@ export const SHARED_MANIFEST = {
|
||||
"disabledUntil": null,
|
||||
"mcpDisabled": false,
|
||||
"mcpDisabledReason": null,
|
||||
"mcpClientRegistrationLimit": null,
|
||||
"customRateLimitRequestsPerMinute": null,
|
||||
"customUserRateLimitRequestsPerMinute": null,
|
||||
"planTier": "free",
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
"disabledUntil",
|
||||
"mcpDisabled",
|
||||
"mcpDisabledReason",
|
||||
"mcpClientRegistrationLimit",
|
||||
"customRateLimitRequestsPerMinute",
|
||||
"customUserRateLimitRequestsPerMinute",
|
||||
"planTier",
|
||||
@@ -241,6 +242,7 @@
|
||||
"disabledUntil": null,
|
||||
"mcpDisabled": false,
|
||||
"mcpDisabledReason": null,
|
||||
"mcpClientRegistrationLimit": null,
|
||||
"customRateLimitRequestsPerMinute": null,
|
||||
"customUserRateLimitRequestsPerMinute": null,
|
||||
"planTier": "free",
|
||||
@@ -21725,6 +21727,16 @@
|
||||
"optional": true,
|
||||
"doc": "Optional reason why MCP access was disabled. Shown to the caller in the 403 response."
|
||||
},
|
||||
{
|
||||
"name": "mcpClientRegistrationLimit",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number",
|
||||
"nullable": true
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "How many self-hosted MCP client registrations this tenant may hold. Null = the platform default (one); set by a Global Admin on request, 1-10. Global-Admin-only (UpdateTenantConfigurationFunction GA gate, TenantConfigPatchService.GaOnlyFields)."
|
||||
},
|
||||
{
|
||||
"name": "customRateLimitRequestsPerMinute",
|
||||
"type": {
|
||||
|
||||
@@ -4743,6 +4743,8 @@ export interface TenantConfiguration {
|
||||
mcpDisabled: boolean;
|
||||
/** Optional reason why MCP access was disabled. Shown to the caller in the 403 response. */
|
||||
mcpDisabledReason?: string | null;
|
||||
/** How many self-hosted MCP client registrations this tenant may hold. Null = the platform default (one); set by a Global Admin on request, 1-10. Global-Admin-only (UpdateTenantConfigurationFunction GA gate, TenantConfigPatchService.GaOnlyFields). */
|
||||
mcpClientRegistrationLimit?: number | null;
|
||||
/** Optional per-tenant override for the device (agent/cert) API rate limit. If null, the effective limit is the global AdminConfiguration.GlobalRateLimitRequestsPerMinute. If set, this value takes precedence. Global-Admin-only (see UpdateTenantConfigurationFunction GA-gate). */
|
||||
customRateLimitRequestsPerMinute?: number | null;
|
||||
/** Optional per-tenant override for the user (portal/JWT) API rate limit applied to standard users (Tenant Admins, Operators, Viewers). If null, the effective limit is the global AdminConfiguration.UserRateLimitRequestsPerMinute. Global-Admin-only. Note: Global Admins are rate-limited by the global GlobalAdminRateLimitRequestsPerMinute (cross-tenant), so this override does not apply to them. */
|
||||
|
||||
Reference in New Issue
Block a user