diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs
index 1f51f804..9e4c04cb 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs
@@ -626,6 +626,37 @@ public class PolicyEnforcementMiddlewareTests
}
}
+ [Fact]
+ public void ConfigFieldsSchema_LiteralRoute_WinsOverTenantIdTemplate()
+ {
+ // "fields-schema" must never be swallowed as a {tenantId} of the MemberRead config GET —
+ // FindPolicy's literal-over-template precedence is what this feature's route relies on
+ // (same mechanism as config/all).
+ var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/config/fields-schema");
+ Assert.NotNull(entry);
+ Assert.Equal("config/fields-schema", entry!.RouteTemplate);
+ Assert.Equal(EndpointPolicy.GlobalAdminOnly, entry.Policy);
+ }
+
+ [Fact]
+ public async Task ConfigFieldsSchema_GlobalAdminAllowed_ReaderAndTenantAdminForbidden()
+ {
+ var ga = BuildHarness();
+ ga.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
+ Assert.True((await ga.Middleware.DecideAsync(
+ "GET", "/api/config/fields-schema", null, AuthedPrincipal(TenantA, "ga@vendor.example"))).Allowed);
+
+ var reader = BuildHarness();
+ reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
+ Assert.False((await reader.Middleware.DecideAsync(
+ "GET", "/api/config/fields-schema", null, AuthedPrincipal(TenantA, "reader@vendor.example"))).Allowed);
+
+ var tenantAdmin = BuildHarness();
+ tenantAdmin.AsTenantAdmin(TenantA, "admin@contoso.com");
+ Assert.False((await tenantAdmin.Middleware.DecideAsync(
+ "GET", "/api/config/fields-schema", null, AuthedPrincipal(TenantA, "admin@contoso.com"))).Allowed);
+ }
+
[Fact]
public async Task ConfigFieldPatchRoutes_TenantAdmin_GlobalReader_Delegated_AreForbidden()
{
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigPatchServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigPatchServiceTests.cs
index d83fdb01..c261e55d 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigPatchServiceTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigPatchServiceTests.cs
@@ -544,6 +544,68 @@ public class TenantConfigPatchServiceTests
Assert.Equal(95m, rehydrated.SlaTargetSuccessRate);
}
+ // ── Fields schema (MCP steering surface) ────────────────────────────────
+
+ [Fact]
+ public void FieldsSchema_CoversEveryModelProperty_ExactlyOnce()
+ {
+ // Drift canary: the schema is reflection-generated, so this pins that EVERY writable
+ // model property surfaces exactly once under its camelCase name — a new config field
+ // is automatically schema-visible, and a rename cannot leave a stale entry.
+ var schema = TenantConfigPatchService.BuildFieldsSchema();
+ var expected = typeof(TenantConfiguration).GetProperties()
+ .Where(p => p.CanWrite && p.GetIndexParameters().Length == 0)
+ .Select(p => char.ToLowerInvariant(p.Name[0]) + p.Name.Substring(1))
+ .OrderBy(n => n, StringComparer.Ordinal)
+ .ToList();
+
+ Assert.Equal(expected, schema.Select(f => f.Name).ToList());
+ Assert.Equal(schema.Count, schema.Select(f => f.Name).Distinct().Count());
+ }
+
+ [Fact]
+ public void FieldsSchema_DeniedFields_MatchThePatchDenyList()
+ {
+ var schema = TenantConfigPatchService.BuildFieldsSchema()
+ .ToDictionary(f => f.Name, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var denied in TenantConfigPatchService.BaseDeniedFields
+ .Where(n => n is not ("PartitionKey" or "RowKey" or "Timestamp" or "ETag")))
+ {
+ Assert.False(schema[denied].Writable, $"{denied} must be marked non-writable");
+ Assert.False(string.IsNullOrWhiteSpace(schema[denied].Reason), $"{denied} needs a deny reason");
+ }
+ foreach (var gaOnly in TenantConfigPatchService.GaOnlyFields)
+ {
+ Assert.True(schema[gaOnly].Writable, $"{gaOnly} is writable for GA");
+ Assert.True(schema[gaOnly].GaOnly, $"{gaOnly} must carry the GA-only marker");
+ }
+ foreach (var protectedField in TenantConfigPatchService.RevertProtectedFields)
+ {
+ Assert.True(schema[protectedField].RevertProtected, $"{protectedField} must carry the revert-protected marker");
+ }
+ }
+
+ [Fact]
+ public void FieldsSchema_TypeExemplars_AreCorrect()
+ {
+ var schema = TenantConfigPatchService.BuildFieldsSchema()
+ .ToDictionary(f => f.Name, StringComparer.Ordinal);
+
+ Assert.Equal(("integer", false, true), (schema["dataRetentionDays"].Type, schema["dataRetentionDays"].Nullable, schema["dataRetentionDays"].Writable));
+ // The prod-finding column class: decimal? → JSON "number", nullable.
+ Assert.Equal(("number", true), (schema["slaTargetSuccessRate"].Type, schema["slaTargetSuccessRate"].Nullable));
+ Assert.Equal(("boolean", true), (schema["disabled"].Type, schema["disabled"].GaOnly));
+ Assert.Equal(("string", true), (schema["contactEmail"].Type, schema["contactEmail"].Nullable));
+ Assert.Equal("json", schema["notificationChannelsJson"].Format);
+ Assert.Equal("date-time", schema["trialExpiresUtc"].Format);
+ Assert.False(schema["planTier"].Writable);
+ Assert.Contains("plan/trial", schema["planTier"].Reason);
+ Assert.False(schema["lastUpdated"].Writable);
+ Assert.False(schema["homedAppClientId"].Writable);
+ Assert.True(schema["homedAppClientId"].RevertProtected);
+ }
+
[Fact]
public void RehydrateEntity_DateLookingStringField_StaysString()
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantConfigFieldsSchemaFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantConfigFieldsSchemaFunction.cs
new file mode 100644
index 00000000..c941623e
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantConfigFieldsSchemaFunction.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Linq;
+using System.Net;
+using System.Threading.Tasks;
+using AutopilotMonitor.Functions.Services;
+using Microsoft.Azure.Functions.Worker;
+using Microsoft.Azure.Functions.Worker.Http;
+using Microsoft.Extensions.Logging;
+
+namespace AutopilotMonitor.Functions.Functions.Config
+{
+ ///
+ /// Machine-readable schema of the tenant-configuration fields, for the MCP write surface:
+ /// which fields exist, their JSON type/nullability, which are writable via
+ /// PATCH config/{tenantId}/fields (and why not, when denied), the phase-2 GA-only markers,
+ /// and which fields a revert preserves by default. Reflection-generated from the model and
+ /// the patch service's deny-lists — cannot drift from what the patch endpoint enforces.
+ /// Tenant-independent (no tenant data), but GlobalAdminOnly to match the write surface it
+ /// describes. Route is literal — the catalog and ASP.NET routing both prefer literal
+ /// segments over config/{tenantId} (same precedence config/all relies on).
+ ///
+ public class GetTenantConfigFieldsSchemaFunction
+ {
+ private readonly ILogger _logger;
+
+ public GetTenantConfigFieldsSchemaFunction(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ [Function("GetTenantConfigFieldsSchema")]
+ public async Task Run(
+ [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "config/fields-schema")] HttpRequestData req)
+ {
+ try
+ {
+ // Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
+ var schema = TenantConfigPatchService.BuildFieldsSchema();
+
+ var response = req.CreateResponse(HttpStatusCode.OK);
+ await response.WriteAsJsonAsync(new
+ {
+ count = schema.Count,
+ writableCount = schema.Count(f => f.Writable),
+ fields = schema,
+ });
+ return response;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error building tenant-config fields schema");
+ var response = req.CreateResponse(HttpStatusCode.InternalServerError);
+ await response.WriteAsJsonAsync(new { error = "Internal server error" });
+ return response;
+ }
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
index db1a0ccf..2a46fbd4 100644
--- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
@@ -398,6 +398,10 @@ public static class EndpointAccessPolicyCatalog
new("PATCH", "config/{tenantId}/fields", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("GET", "config/{tenantId}/backups", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("POST", "config/{tenantId}/revert", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
+ // Field schema for the write surface above: tenant-independent reflection over the model
+ // + deny-lists (no tenant data), but GA-only to match the surface it describes. Literal
+ // route — FindPolicy and ASP.NET routing both prefer it over config/{tenantId}.
+ new("GET", "config/fields-schema", EndpointPolicy.GlobalAdminOnly),
new("GET", "auth/global-admins", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "auth/global-admins", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "auth/global-admins/{upn}", EndpointPolicy.GlobalAdminOnly),
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs
index 3be48d54..76ae7abb 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs
@@ -56,6 +56,21 @@ namespace AutopilotMonitor.Functions.Services
=> new(false, failure, error, backupId, Array.Empty(), null, drift);
}
+ ///
+ /// One entry of the tenant-config field schema (see TenantConfigPatchService.BuildFieldsSchema).
+ /// Serialized camelCase to the API. Nulls/false flags are serialized as-is — the payload is
+ /// tiny and consumed by a model, which reads explicit values better than absent keys.
+ ///
+ public sealed record TenantConfigFieldSchema(
+ string Name,
+ string Type,
+ string? Format,
+ bool Nullable,
+ bool Writable,
+ string? Reason,
+ bool GaOnly,
+ bool RevertProtected);
+
///
/// Transactional field-level writes against a tenant's configuration row:
/// fresh ETag read → field gate → patch onto a clone → shared validation →
@@ -392,6 +407,63 @@ namespace AutopilotMonitor.Functions.Services
return new PatchOutcome(true, PatchFailure.None, null, backupId, applied, maskedDiff, null);
}
+ ///
+ /// Machine-readable schema of the tenant-config fields for the MCP write surface:
+ /// name (camelCase, as the patch endpoint accepts it), JSON type, nullability
+ /// (nullable ⇒ an explicit JSON null clears the field), writability incl. the deny
+ /// reason, the phase-2 GA-only marker, and the revert-protected marker. Generated by
+ /// REFLECTION over the model + the deny-lists above — the same sources the patch flow
+ /// enforces — so the schema can never drift from what the endpoint actually accepts.
+ /// Static per process (the model does not change at runtime).
+ ///
+ internal static IReadOnlyList BuildFieldsSchema() => FieldsSchema.Value;
+
+ private static readonly Lazy> FieldsSchema = new(() =>
+ {
+ var nullability = new NullabilityInfoContext();
+ var fields = new List();
+ foreach (var prop in ModelProperties.Values)
+ {
+ var underlying = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
+ var type =
+ underlying == typeof(bool) ? "boolean"
+ : underlying == typeof(int) || underlying == typeof(long) ? "integer"
+ : underlying == typeof(decimal) || underlying == typeof(double) || underlying == typeof(float) ? "number"
+ : "string";
+ var format =
+ underlying == typeof(DateTime) || underlying == typeof(DateTimeOffset) ? "date-time"
+ : prop.Name.EndsWith("Json", StringComparison.Ordinal) ? "json"
+ : null;
+ var nullable = Nullable.GetUnderlyingType(prop.PropertyType) != null
+ || (!prop.PropertyType.IsValueType
+ && nullability.Create(prop).WriteState == NullabilityState.Nullable);
+
+ var writable = !BaseDeniedFields.Contains(prop.Name);
+ fields.Add(new TenantConfigFieldSchema(
+ Name: char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1),
+ Type: type,
+ Format: format,
+ Nullable: nullable,
+ Writable: writable,
+ Reason: writable ? null : DeniedReason(prop.Name),
+ GaOnly: writable && GaOnlyFields.Contains(prop.Name),
+ RevertProtected: RevertProtectedFields.Contains(prop.Name)));
+ }
+ return fields.OrderBy(f => f.Name, StringComparer.Ordinal).ToList();
+ });
+
+ private static string DeniedReason(string name) => name switch
+ {
+ "TenantId" or "DomainName" => "identity — never writable",
+ "LastUpdated" or "UpdatedBy" => "stamped server-side on every write",
+ "OnboardedAt" or "OnboardedBy" => "system-owned onboarding provenance (set at first login)",
+ "HomedAppClientId" => "system-owned — written only by the app-homing flow",
+ "LastAuthClientId" or "LastAuthClientIdSince" => "system-owned auth provenance",
+ "PlanTier" or "TrialExpiresUtc" or "TrialStartedUtc" or "TrialConsumed" or "TrialGrantedBy"
+ => "mutable only via the dedicated plan/trial endpoints",
+ _ => "not writable via the field patch",
+ };
+
///
/// Schema-aware rehydration of a raw EntityJson snapshot back into a TableEntity.
/// Strings are converted to DateTime ONLY for columns the model declares as DateTime —
diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/config-write-tools.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/config-write-tools.test.ts
index 4d251f83..a83fad60 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/config-write-tools.test.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/config-write-tools.test.ts
@@ -38,7 +38,7 @@ describe('config-write tool registration', () => {
const readerHandlers = captureToolHandlers(true, false);
const tenantHandlers = captureToolHandlers(false, false);
for (const name of [
- 'get_tenant_config', 'update_tenant_config',
+ 'get_tenant_config', 'get_tenant_config_schema', 'update_tenant_config',
'list_tenant_config_backups', 'revert_tenant_config',
]) {
expect(gaHandlers, `${name} missing for GA`).toHaveProperty(name);
@@ -61,6 +61,18 @@ describe('get_tenant_config — forced redaction pin', () => {
});
});
+describe('get_tenant_config_schema — request shape', () => {
+ beforeEach(() => apiFetchMock.mockReset());
+
+ it('GETs the tenant-independent fields-schema endpoint', async () => {
+ apiFetchMock.mockResolvedValueOnce({ count: 90, fields: [] });
+ await captureToolHandlers(true, true).get_tenant_config_schema({});
+
+ expect(apiFetchMock).toHaveBeenCalledTimes(1);
+ expect(apiFetchMock.mock.calls[0][0]).toBe('/api/config/fields-schema');
+ });
+});
+
describe('update_tenant_config — request shape', () => {
beforeEach(() => apiFetchMock.mockReset());
diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts
index f6b955aa..0816664b 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts
@@ -129,6 +129,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
'get_session_summary',
'get_software_inventory',
'get_tenant_config',
+ 'get_tenant_config_schema',
// Available to every role: backed by MemberRead endpoints (tenant) with role-aware
// routing to the GlobalReadOrAdmin variant — same placement as get_app_install_metrics.
'get_time_attribution',
@@ -164,6 +165,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
// operational config is a GA surface; Readers use list_tenants' keep-list view).
const CONFIG_WRITE_GA_STRICT = [
'get_tenant_config',
+ 'get_tenant_config_schema',
'list_tenant_config_backups',
'revert_tenant_config',
'update_tenant_config',
@@ -178,6 +180,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
'get_ops_events',
'get_platform_metrics',
'get_tenant_config',
+ 'get_tenant_config_schema',
'list_blocked_devices',
'list_session_reports',
'list_tables',
diff --git a/src/McpServer/autopilot-monitor-mcp/src/index.ts b/src/McpServer/autopilot-monitor-mcp/src/index.ts
index f5b6e298..0fd96275 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/index.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/index.ts
@@ -238,8 +238,9 @@ function buildInstructions(ga: boolean, strictGa: boolean, delegated: boolean, m
const headline = strictGa
? 'Autopilot-Monitor is a telemetry server for Windows Autopilot enrollment sessions. All investigation ' +
'tools are read-only; as a Global Admin you additionally have tenant-configuration write tools ' +
- '(update_tenant_config, revert_tenant_config). Every config write is snapshotted first and verified ' +
- 'after — use list_tenant_config_backups + revert_tenant_config to roll back.'
+ '(update_tenant_config, revert_tenant_config). Call get_tenant_config_schema before composing a patch — ' +
+ 'it lists every field with its exact name and JSON type. Every config write is snapshotted first and ' +
+ 'verified after — use list_tenant_config_backups + revert_tenant_config to roll back.'
: 'Autopilot-Monitor is a READ-ONLY telemetry server for Windows Autopilot enrollment sessions.';
return [
headline,
diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools.ts b/src/McpServer/autopilot-monitor-mcp/src/tools.ts
index 0a447a76..b25f9db8 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/tools.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/tools.ts
@@ -13,8 +13,9 @@ import type { DocsSearchBundle } from './search-provider.js';
* backend endpoints stay GlobalAdminOnly: the raw tools (list_tables / query_table /
* query_backend_logs — they can dump secret-bearing tables that the GlobalReader config redaction
* would otherwise hide) and the tenant-config write surface (get_tenant_config /
- * update_tenant_config / list_tenant_config_backups / revert_tenant_config — the server's only
- * mutating tools; every write is snapshot-backed and verified server-side). When `ga` is false
+ * get_tenant_config_schema / update_tenant_config / list_tenant_config_backups /
+ * revert_tenant_config — the server's only mutating tools; every write is snapshot-backed
+ * and verified server-side). When `ga` is false
* (normal tenant user) no platform tool is registered at all — they never appear in tools/list —
* and the remaining tools' descriptions carry no cross-tenant wording.
*
diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts
index 082e50f0..7416d224 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts
@@ -1078,12 +1078,38 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
})
);
+ if (strictGa) server.registerTool(
+ 'get_tenant_config_schema',
+ {
+ title: 'Get Tenant Config Field Schema',
+ description:
+ 'Field schema of the tenant configuration for update_tenant_config: every field\'s exact name (camelCase), ' +
+ 'JSON type (string/boolean/integer/number), nullability (nullable ⇒ an explicit JSON null clears it), ' +
+ 'format hints ("json" = a JSON document passed AS A STRING, "date-time" = ISO 8601 string), whether it is ' +
+ 'writable (with the reason when not), and which fields a revert preserves by default. Tenant-independent — ' +
+ 'call it ONCE before composing an update_tenant_config patch so field names and value types are right on ' +
+ 'the first attempt. Generated server-side from the live model; never guess types instead of calling this.',
+ inputSchema: {},
+ annotations: READ_ONLY,
+ },
+ async (args) => withToolTelemetry('get_tenant_config_schema', async () => {
+ try {
+ const data = await apiFetch('/api/config/fields-schema');
+ return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
+ } catch (error: unknown) {
+ return toolError('get_tenant_config_schema', args, error);
+ }
+ })
+ );
+
if (strictGa) server.registerTool(
'update_tenant_config',
{
title: 'Update Tenant Configuration',
description:
'Change specific fields of a tenant\'s configuration — transactional and verified. Global Admin only. ' +
+ 'Unsure about a field\'s exact name, type, or writability? Call get_tenant_config_schema FIRST — it lists ' +
+ 'every field with its JSON type, so the patch is right on the first attempt. ' +
'Pass ONLY the fields to change (camelCase or PascalCase); omitted fields stay untouched; an explicit JSON ' +
'null clears a nullable field. The backend snapshots the row first (fail-closed), writes conditionally, ' +
're-reads, and verifies that exactly the intended fields changed — on any drift it rolls back automatically. ' +