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:
Oliver Kieselbach
2026-09-24 15:00:15 +02:00
parent 8e637cf86a
commit 1a96cd20da
22 changed files with 205 additions and 70 deletions
@@ -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()
{
@@ -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",
@@ -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(),
});
@@ -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&apos;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,
@@ -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_&lt;id&gt; 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_&lt;id&gt;, 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://&lt;your LibreChat host&gt;/api/mcp/&lt;server name&gt;/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&apos;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. */