In-process MCP server for .NET with permission-based schema shaping. Authorization is schema discrimination — different users get different JSON Schemas from the same tool definitions.
C# port of the mcp_authorization Ruby gem and TypeScript package.
dotnet add package McpAuthorizationTraditional MCP authorization shows every tool to every user and rejects unauthorized calls at runtime. McpAuthorization takes a different approach: the LLM never sees what the user can't do. Fields, tools, and even descriptions are pruned from the schema before the model receives it.
Three layers of authorization, each independently controlled:
| Layer | What it controls | How you declare it |
|---|---|---|
| Tool visibility | Entire tool hidden from tools/list |
Authorization = "permission" on ToolDefinition |
| Field visibility | Property pruned from input schema | [Requires("permission")] on record property |
| Dynamic defaults | Default value injected per-user | [DefaultFor("key")] on record property |
using McpAuthorization;
public record PatientQuery
{
[McpProperty("Search term")]
public required string Search { get; init; }
[McpProperty("Include SSN in results")]
[Requires("SecurityAdmin")] // only admins see this field
public bool IncludeSsn { get; init; }
}public class MyAuthContext(HashSet<string> permissions) : IAuthContext
{
public bool Can(string permission) => permissions.Contains(permission);
}var server = new McpAuthorizationServer("my-app", "1.0.0");
server.Register(new ToolDefinition<PatientQuery>
{
Name = "search_patients",
Description = "Search patient records",
Authorization = "ViewPatients", // tool-level gate
ReadOnly = true,
Handler = async (input, ctx, ct) =>
{
// input.IncludeSsn is only present if user has SecurityAdmin
return new { results = new[] { "..." } };
},
});
await server.RunAsync(new MyAuthContext(["ViewPatients"]));Claude Desktop config:
{
"mcpServers": {
"my-app": {
"command": "dotnet",
"args": ["run", "--project", "path/to/MyApp"]
}
}
}| Attribute | Target | Effect |
|---|---|---|
[Requires("perm")] |
Property | Field pruned from schema if user lacks permission |
[DependsOn("field")] |
Property | Emits dependentRequired in JSON Schema |
[DefaultFor("key")] |
Property | Populates default from IAuthContext.DefaultFor() |
[McpProperty("desc")] |
Property | Sets description, format, min/maxLength, pattern |
[McpDeprecated] |
Property | Marks field as deprecated in schema |
Tool descriptions can change based on user permissions, matching the Ruby gem's def description pattern:
server.Register(new ToolDefinition<AdvanceInput>
{
Name = "advance_step",
Description = "Advance to the next stage",
DynamicDescription = ctx => ctx.Can("BackwardRouting")
? "Advance an applicant to any stage, or reroute them backward."
: "Advance an applicant to the next stage.",
Authorization = "ManageWorkflows",
Handler = async (input, ctx, ct) => { ... },
});Reusable utility for validating raw SQL before execution. Uses a state-machine tokenizer (not regex) to handle comment-hiding attacks, string literal embedding, and MySQL-specific quoting.
var result = SqlSafetyGuard.Validate(userProvidedSql);
if (!result.IsValid)
{
// result.Violations contains specific reasons
return new { error = true, violations = result.Violations };
}
// result.Sql is cleaned (comments stripped)
var table = db.ExecuteQuery(result.Sql + " LIMIT 100");Blocks: INSERT, UPDATE, DELETE, DROP, ALTER, TRUNCATE, semicolons, INTO OUTFILE, SLEEP(), BENCHMARK(), @@ system variables, and more. See tests for the full list.
The SchemaCompiler reads record properties and attributes via reflection once at registration time, then caches the metadata. Per-request work is only permission checks (HashSet.Contains calls) and JsonObject construction.
Phase 1 (cached): Type → reflection → List<PropertyMeta>
Phase 2 (per-req): PropertyMeta + IAuthContext.Can() → pruned JsonObject
Supported type mappings:
| C# type | JSON Schema |
|---|---|
string |
{"type": "string"} |
int, long |
{"type": "integer"} |
decimal, double |
{"type": "number"} |
bool |
{"type": "boolean"} |
DateTime |
{"type": "string", "format": "date-time"} |
DateOnly |
{"type": "string", "format": "date"} |
enum |
{"type": "string", "enum": [...]} |
T[], List<T> |
{"type": "array", "items": {...}} |
| nested record | Recurses (with permission pruning) |
The examples/OpenDentalExample/ directory shows a complete integration:
Shapes.cs— Domain records with[Requires]attributesOpenDentalBridge.cs—IAuthContextquerying permission tables,IDataAccesswrappingDataCore.GetTable()Program.cs— Tool registration and StdIO server
Run the demo to see schema differences per role:
cd examples/OpenDentalExample
dotnet run -- --demoOutput shows Front Desk sees 2 tools, Office Manager sees 3 (with extra fields), Admin sees all 4 (including raw SQL and SSN access).
The same authorization-as-schema-shaping pattern, three implementations:
| Concept | Ruby gem | TypeScript package | C# (this package) |
|---|---|---|---|
| Auth contract | can?(:symbol) |
ctx.can('string') |
IAuthContext.Can(string) |
| Field gating | @requires(:flag) |
.requires('perm') |
[Requires("perm")] |
| Dependency | @depends_on(:field) |
.dependsOn('field') |
[DependsOn("field")] |
| Dynamic default | @default_for(:key) |
.defaultFor('key') |
[DefaultFor("key")] |
| Tool gate | authorization :flag |
authorization: 'perm' |
Authorization = "perm" |
| Materialization | ToolRegistry.tool_classes_for |
registry.materialize(ctx) |
registry.Materialize(ctx) |
MIT