# onemodelcontext docs, all pages (OneModelContext 1.0.1) Every page of https://recursiveai.org/onemodelcontext/docs/ as markdown, getting started first. Index: https://recursiveai.org/onemodelcontext/llms.txt # Getting started with OneModelContext Install OneModelContext 1.0.1 for .NET 10, write a server with two tools and a resource, connect a client to it over stdio and over Streamable HTTP, host it on ASP.NET Core, and drive it from the command line. Every sample on this page was run on the 1.0.1 packages from NuGet and shows its real output. ## Install The packages target `net10.0`, so you need the .NET 10 SDK. A server and a client are two console programs: ```shell dotnet new console -o GlossaryServer dotnet add GlossaryServer package RecursiveAI.OneModelContext --version 1.0.1 dotnet new console -o GlossaryClient dotnet add GlossaryClient package RecursiveAI.OneModelContext --version 1.0.1 ``` `RecursiveAI.OneModelContext` is the whole protocol: client, server, transports, the built-in HTTP listener, authorization and the `[Tool]` source generator. In the README’s words, it “depends on the .NET base class library only, and is trim- and NativeAOT-safe.” Seven more packages share its version; [the overview](https://recursiveai.org/onemodelcontext/docs/overview/) lists what each adds. ## Write a server A tool is a method on a `partial` class with `[Tool]` on it. The source generator reads the signature and the doc comments and writes the tool’s JSON Schema and an invoker, so nothing is found by reflection at run time. `[Resource]` with a URI template makes a resource. `GlossaryServer/Glossary.cs`: ```csharp using System.Collections.Concurrent; using RecursiveAI.OneModelContext; /// A glossary the model can read and add to. public partial class Glossary { private readonly ConcurrentDictionary _terms = new(StringComparer.OrdinalIgnoreCase); public Glossary() { _terms["idempotent"] = "Safe to repeat: doing it twice has the same effect as doing it once."; } /// Looks up a term. /// The word or phrase to define. [Tool(ReadOnly = true)] public string Define(string term) => _terms.TryGetValue(term, out var meaning) ? $"{term}: {meaning}" : $"No entry for '{term}'."; /// Adds a term, replacing an older definition. /// The word or phrase. /// What it means, in one sentence. [Tool(Idempotent = true)] public string AddTerm(string term, string meaning) { _terms[term] = meaning; return $"Added '{term}'. The glossary has {_terms.Count} terms."; } /// A term's definition, as plain text. /// The term. [Resource("glossary://{term}", MimeType = "text/plain")] public string Entry(string term) => _terms.TryGetValue(term, out var meaning) ? meaning : throw new ProtocolException(-32602, "No such term."); } ``` `GlossaryServer/Program.cs` serves it over standard input and output, or over Streamable HTTP when asked: ```csharp using RecursiveAI.OneModelContext; // stdio by default; "--http " serves Streamable HTTP on the built-in // listener instead. if (args is ["--http", var port]) { await ModelContext.ServeHttpAsync(new Glossary(), port: int.Parse(port), onListening: url => Console.WriteLine($"Serving {url}")); } else { await ModelContext.ServeAsync(new Glossary()); } ``` Over stdio, standard output carries the protocol, so a server writes its own diagnostics to standard error. ## Connect a client over stdio `ModelContext.ConnectAsync` takes a command line, starts it as a child process (no shell runs) and negotiates the protocol era before it returns. Tool arguments can be any object; its properties become the tool’s arguments. `GlossaryClient/Program.cs`: ```csharp using RecursiveAI.OneModelContext; using RecursiveAI.OneModelContext.Protocol; // Starts the server as a child process and talks to it over its standard input // and output. await using var client = await ModelContext.ConnectAsync( "dotnet run --project ../GlossaryServer"); var server = client.ServerInfo.Name; Console.WriteLine($"{server}, protocol {client.ProtocolVersion} ({client.Era} era)"); foreach (var tool in await client.ListToolsAsync()) { Console.WriteLine($"{tool.Name}: {tool.Description}"); } var defined = await client.CallToolAsync("define", new { term = "idempotent" }); Console.WriteLine(defined.Text); ``` ```text GlossaryServer, protocol 2026-07-28 (Modern era) add_term: Adds a term, replacing an older definition. define: Looks up a term. idempotent: Safe to repeat: doing it twice has the same effect as doing it once. ``` The server answered the 2026-07-28 revision. The next three sections continue the same `Program.cs`. Tool names are snake case by default (`AddTerm` became `add_term`), and the descriptions are the `` comments. Passing an anonymous object serializes it by reflection. In the README’s words, “The object-argument call uses reflection and is not trim- or NativeAOT-safe. For AOT, pass source-generated serializer metadata instead”: `CallToolAsync(name, arguments, typeInfo)` takes a `JsonTypeInfo` from your own `JsonSerializerContext`. ## The generated schema Each `` comment becomes a property description, parameters without a default are required, and the annotation properties of `[Tool]` become hints: ```csharp var tools = await client.ListToolsAsync(); var addTerm = tools.First(t => t.Name == "add_term"); Console.WriteLine(addTerm.InputSchema); Console.WriteLine($"idempotent hint: {addTerm.Annotations?.IdempotentHint}"); ``` ```text {"type":"object","properties":{"term":{"type":"string","description":"The word or phrase."},"meaning":{"type":"string","description":"What it means, in one sentence."}},"required":["term","meaning"],"additionalProperties":false} idempotent hint: True ``` The dialect is JSON Schema 2020-12. [Tool signatures and JSON Schema](https://recursiveai.org/onemodelcontext/docs/tool-schemas/) maps every .NET type the generator accepts. ## Call a tool, read a resource ```csharp var added = await client.CallToolAsync("add_term", new { term = "stdio", meaning = "A process's standard input and output streams.", }); Console.WriteLine(added.Text); var entry = await client.ReadResourceAsync("glossary://stdio"); Console.WriteLine(entry.Contents[0] is TextResourceContents text ? text.Text : "(binary)"); var missing = await client.CallToolAsync("define", new { trm = "typo" }); Console.WriteLine($"isError={missing.IsError}: {missing.Text}"); ``` ```text Added 'stdio'. The glossary has 2 terms. A process's standard input and output streams. isError=True: Unknown argument 'trm'. ``` A bad argument is a tool result with `IsError` set, not an exception, so a model can read it and try again. ## Older servers and clients The same code talks to servers that only know the older revisions. In the README’s words: “A client negotiates the era per server; a server answers both on one stdio stream or endpoint.” Pinning the legacy era shows the other side of the same server: ```csharp await using var legacy = await ModelContext.ConnectAsync( "dotnet run --project ../GlossaryServer", era: EraPreference.Legacy); Console.WriteLine($"protocol {legacy.ProtocolVersion} ({legacy.Era} era)"); var again = await legacy.CallToolAsync("define", new { term = "idempotent" }); Console.WriteLine(again.Text); ``` ```text protocol 2025-11-25 (Legacy era) idempotent: Safe to repeat: doing it twice has the same effect as doing it once. ``` One legacy codec covers “2024-11-05 through 2025-11-25.” A handler that asks the client for input behaves the same on both eras; on 2026-07-28 it’s run again from the start, so see [the replay rule](https://recursiveai.org/onemodelcontext/docs/multi-round-trip/) before a tool asks for confirmation. ## Streamable HTTP on the built-in listener Start the server with `--http 3001`. `ServeHttpAsync` runs the library’s small HTTP/1.1 listener on the loopback address: ```shell dotnet run --project GlossaryServer -- --http 3001 ``` ```text Serving http://127.0.0.1:3001/mcp ``` A URL instead of a command line connects over Streamable HTTP. `GlossaryHttp/Program.cs`: ```csharp using RecursiveAI.OneModelContext; // The same client, by URL: Streamable HTTP instead of a child process. await using var client = await ModelContext.ConnectAsync( "http://127.0.0.1:3001/mcp"); var server = client.ServerInfo.Name; Console.WriteLine($"{server}, protocol {client.ProtocolVersion} ({client.Era} era)"); var result = await client.CallToolAsync("define", new { term = "idempotent" }); Console.WriteLine(result.Text); ``` ```text GlossaryServer, protocol 2026-07-28 (Modern era) idempotent: Safe to repeat: doing it twice has the same effect as doing it once. ``` The endpoint checks requests before any handler runs. A POST without `Content-Type: application/json` got `415 Unsupported Media Type`, and one with a foreign `Origin` header got `403 Forbidden` (DNS-rebinding protection). The docs say the listener is “for local tools and command-line programs” and “For a server on the internet, host the endpoint in a web framework.” Every limit is in [the server and listener limits](https://recursiveai.org/onemodelcontext/docs/http-limits/). ## Host it on ASP.NET Core `RecursiveAI.OneModelContext.AspNetCore` adds `MapModelContext`. Make a web project and reuse `Glossary.cs`: ```shell dotnet new web -o WebGlossary dotnet add WebGlossary package RecursiveAI.OneModelContext.AspNetCore --version 1.0.1 ``` ```csharp using RecursiveAI.OneModelContext; using RecursiveAI.OneModelContext.AspNetCore; var builder = WebApplication.CreateSlimBuilder(args); var app = builder.Build(); var server = new ContextServer("web-glossary", "1.0.0"); new Glossary().AddTo(server); // One endpoint answers the 2026-07-28 revision and the older ones. app.MapModelContext("/mcp", server); app.Run(); ``` The generator wrote `Glossary.AddTo`. Middleware added before `MapModelContext` (authentication, rate limiting) runs before the protocol handler, and `.RequireAuthorization()` on the endpoint works with [the resource server](https://recursiveai.org/onemodelcontext/docs/resource-server/). Started with `--urls http://127.0.0.1:5080`, it answered `onemodelcontext inspect http://127.0.0.1:5080/mcp` (below) with `server: web-glossary 1.0.0` and `protocol: 2026-07-28 (modern era)`, and with `protocol: 2025-11-25 (legacy era)` under `--era legacy`. ## From the command line `RecursiveAI.OneModelContext.Cli` is a .NET tool named `onemodelcontext`. It inspects and calls any server, by command line or URL: ```shell dotnet tool install --global RecursiveAI.OneModelContext.Cli --version 1.0.1 onemodelcontext inspect "dotnet run --project GlossaryServer" ``` ```text server: GlossaryServer 1.0.0 protocol: 2026-07-28 (modern era) era chosen: the server answered server/discover capabilities: tools (listChanged), resources (subscribe, listChanged), logging tools (2): add_term - Adds a term, replacing an older definition. define - Looks up a term. resources (0): resource templates (1): glossary://{term} - A term's definition, as plain text. (template) ``` ```shell onemodelcontext call "dotnet run --project GlossaryServer" define --arg term=idempotent ``` ```text idempotent: Safe to repeat: doing it twice has the same effect as doing it once. ``` `--arg` values are typed from the tool’s input schema, and `--era legacy` pins the older revisions. The tool also has `read`, `prompt`, `login`, `logout` and `proxy`; [the OAuth client](https://recursiveai.org/onemodelcontext/docs/oauth-client/) covers `login`. ## Start from a template `RecursiveAI.OneModelContext.Templates` adds `dotnet new onemodelcontext-server` and `onemodelcontext-client`: ```shell dotnet new install RecursiveAI.OneModelContext.Templates::1.0.1 dotnet new onemodelcontext-server -o MyServer onemodelcontext call "dotnet run --project MyServer" add --arg a=2 --arg b=40 ``` ```text 42 ``` The server template has two tools (`add` and `greet`) and sets `PublishAot`, so `dotnet publish` makes a single native executable. ## Next - [Overview](https://recursiveai.org/onemodelcontext/docs/overview/): the eight packages, what each adds and what it depends on. - [Samples](https://recursiveai.org/onemodelcontext/docs/samples/): a program for each way of using the library, each with tests. - [Resource server](https://recursiveai.org/onemodelcontext/docs/resource-server/), [OAuth client](https://recursiveai.org/onemodelcontext/docs/oauth-client/) and [authorization server](https://recursiveai.org/onemodelcontext/docs/authorization-server/): sign-in on both sides. - [Coming from the official C# SDK](https://recursiveai.org/onemodelcontext/docs/coming-from-the-official-sdk/): where each task lives here. - [Conformance](https://recursiveai.org/onemodelcontext/docs/conformance/): what the protocol’s conformance suite checked, revision by revision. # OneModelContext: a .NET SDK for the Model Context Protocol® **Status: 1.0.** The public API is stable and checked by package validation. Tasks, Skills and Server Cards follow experimental MCP extensions and are marked `[Experimental]` until those extensions are stable. OneModelContext is a clean-room .NET 10 library for MCP, the Model Context Protocol, on both sides: client and server. The core package (`RecursiveAI.OneModelContext`) depends on the .NET base class library only, and is trim- and NativeAOT-safe. ## A server in ten lines ```csharp using RecursiveAI.OneModelContext; await ModelContext.ServeAsync(new IndexTools(index)); // stdio // await ModelContext.ServeHttpAsync(new IndexTools(index)); // Streamable HTTP on 127.0.0.1:3001 public partial class IndexTools(SearchIndex index) { /// Searches the index. /// What to look for. /// Most results to return. [Tool(ReadOnly = true)] public Task Search(string query, int limit = 10) => index.SearchAsync(query, limit); } ``` ```csharp await using var client = await ModelContext.ConnectAsync("npx -y @modelcontextprotocol/server-filesystem ."); var tools = await client.ListToolsAsync(); var result = await client.CallToolAsync("read_file", new { path = "README.md" }); Console.WriteLine(result.Text); ``` The object-argument call uses reflection and is not trim- or NativeAOT-safe. For AOT, pass source-generated serializer metadata instead: ```csharp await using var client = await ModelContext.ConnectAsync("my-server"); var result = await client.CallToolAsync("read_file", new ReadFileArguments("README.md"), argumentsJsonTypeInfo); Console.WriteLine(result.Text); ``` Here `ReadFileArguments` is your arguments record and `argumentsJsonTypeInfo` is its `JsonTypeInfo` from a source-generated `JsonSerializerContext` (with JSON property names matching the tool schema). The server example assumes your application supplies `SearchIndex` and `index`; see the complete [indexed search sample](https://github.com/recursive-ai-oss/onemodelcontext-csharp/tree/d72d78c39c934697857be92dd8514efd4c415a88/samples/IndexedSearch). The generator writes each tool's JSON Schema 2020-12 from the signature and its doc comments, and an AOT-safe invoker. More in [`samples/`](https://recursiveai.org/onemodelcontext/docs/samples/). ## What it does - **Both protocol eras, one API.** The stateless 2026-07-28 revision first, and one legacy codec for 2024-11-05 through 2025-11-25. A client negotiates the era per server; a server answers both on one stdio stream or endpoint. Your code is written against the modern semantics and the era is a codec detail. - **Transports.** [stdio and any duplex `Stream`](https://recursiveai.org/onemodelcontext/docs/stdio/); Streamable HTTP as a client, as a host-agnostic server core, on a small built-in HTTP/1.1 listener (loopback by default) and on ASP.NET Core with `MapModelContext`; the deprecated HTTP+SSE transport as a client fallback. - **Tools, resources and prompts**, declared with attributes or registered while the server runs, with `RequestContext` for cancellation, progress, user confirmation and elicitation. A handler that asks for input behaves the same on both eras ([multi-round-trip](https://recursiveai.org/onemodelcontext/docs/multi-round-trip/)). - **Authorization.** A resource server (protected resource metadata, challenges, step-up, audience checks; JWT/JWKS, introspection, static-token and delegate validators), a full OAuth 2.1 client, Client Credentials and Enterprise-Managed Authorization, and an embedded `AuthorizationServer` whose login and consent stay with the host. See [resource-server](https://recursiveai.org/onemodelcontext/docs/resource-server/), [oauth-client](https://recursiveai.org/onemodelcontext/docs/oauth-client/) and [authorization-server](https://recursiveai.org/onemodelcontext/docs/authorization-server/). - **Extensions.** A public extension mechanism, MCP Apps, and Tasks, Skills and Server Cards, which are `[Experimental]` until their specifications are stable. See [extensions](https://recursiveai.org/onemodelcontext/docs/extensions/). - **Sampling, Roots and Logging** are implemented and marked `[Obsolete]` (diagnostic IDs `OMC0001`-`OMC0003`), following the revision that deprecates them. - **Observability.** An `ActivitySource` and `Meter` named `RecursiveAI.OneModelContext`, a diagnostics event stream and a per-call audit hook. - **Safe by default.** Every size, depth and time limit is a documented default on a public option ([limits](https://recursiveai.org/onemodelcontext/docs/http-limits/)). - **A command line tool** (`onemodelcontext inspect | call | read | prompt | login | logout | proxy`) and `dotnet new` templates. Coming from the official C# SDK? See [the task map](https://recursiveai.org/onemodelcontext/docs/coming-from-the-official-sdk/). ## Packages All packages share one version. | Package | Adds | Depends on | |---|---|---| | `RecursiveAI.OneModelContext` | Protocol (both eras), client, server, transports, built-in listener, auth, extensions, stores, the `[Tool]` generator | the .NET base class library | | `RecursiveAI.OneModelContext.AspNetCore` | `MapModelContext`, the authentication handler, authorization server endpoints | ASP.NET Core (framework reference) | | `RecursiveAI.OneModelContext.Hosting` | `AddContextServer()`, hosted stdio and listener lifetimes, `ILogger` bridge | `Microsoft.Extensions.Hosting.Abstractions` | | `RecursiveAI.OneModelContext.Extensions.AI` | Tools as `AIFunction`s both ways; Sampling answered by an `IChatClient` | `Microsoft.Extensions.AI.Abstractions` | | `RecursiveAI.OneModelContext.EntityFrameworkCore` | Authorization, token and task stores on your `DbContext`, for several instances | EF Core and EF Core Relational | | `RecursiveAI.OneModelContext.OneLite` | The same stores in one embedded database file, for a single host | `RecursiveAI.OneLite` | | `RecursiveAI.OneModelContext.Cli` | The `onemodelcontext` tool | the core package | | `RecursiveAI.OneModelContext.Templates` | `dotnet new onemodelcontext-server` and `onemodelcontext-client` | none | Third-party licenses: [THIRD-PARTY-NOTICES.md](https://github.com/recursive-ai-oss/onemodelcontext-csharp/blob/d72d78c39c934697857be92dd8514efd4c415a88/THIRD-PARTY-NOTICES.md). ## Repository layout | Path | What it holds | |---|---| | `src/` | The packages above. In the core, `JsonRpc`, `Sse`, `Http`, `OAuth` and `Jose` are the base layer and hold no protocol types. | | `docs/` | Notes on [HTTP limits](https://recursiveai.org/onemodelcontext/docs/http-limits/), the [HTTP client](https://recursiveai.org/onemodelcontext/docs/http-client/), [tool schemas](https://recursiveai.org/onemodelcontext/docs/tool-schemas/), [stores](https://recursiveai.org/onemodelcontext/docs/stores/), [adapters](https://recursiveai.org/onemodelcontext/docs/adapters/) and the pages linked above | | `samples/` | Small programs for each way of using the library, with tests ([samples/README.md](https://recursiveai.org/onemodelcontext/docs/samples/)) | | `tests/` | Unit tests, the sample tests and the NativeAOT smoke programs | | `templates/` | The `dotnet new` template pack | | `conformance/`, `interop/`, `bench/`, `fuzz/` | Runners for the protocol's conformance suite, an interop matrix against other SDKs and servers, benchmarks and fuzz targets | | `tools/` | `aot-check.sh`: publishes core, a stdio server, an HTTP server and the CLI with NativeAOT and runs them | ## Build ```sh dotnet build -c Release dotnet test -c Release tools/aot-check.sh # NativeAOT publishes; needs a native toolchain (clang or gcc) ``` Build output goes to `artifacts/` (ignored). Set `ArtifactsPath` to put it elsewhere. See [CHANGELOG.md](https://recursiveai.org/onemodelcontext/docs/changelog/). ## License `MIT OR Apache-2.0`, Copyright (c) 2026 Recursive AI, LLC. See [LICENSE-MIT](https://github.com/recursive-ai-oss/onemodelcontext-csharp/tree/d72d78c39c934697857be92dd8514efd4c415a88/LICENSE-MIT) and [LICENSE-APACHE](https://github.com/recursive-ai-oss/onemodelcontext-csharp/tree/d72d78c39c934697857be92dd8514efd4c415a88/LICENSE-APACHE). Model Context Protocol® is a registered trademark of LF Projects, LLC. OneModelContext is an independent project and is not affiliated with or endorsed by LF Projects, LLC or the Model Context Protocol project. # Samples Small programs that show how OneModelContext is used. Each one runs on both protocol eras (2026-07-28 and the older revisions) without changes to its code, and each has tests in `tests/RecursiveAI.OneModelContext.SamplesTests`. | Sample | Shows | AOT | |---|---|---| | [`IndexedSearch`](https://github.com/recursive-ai-oss/onemodelcontext-csharp/tree/d72d78c39c934697857be92dd8514efd4c415a88/samples/IndexedSearch) | A search tool over an in-memory index, served over stdio in one line. `[Tool]` on a partial class; the generator writes the schema and the invoker. | yes | | [`AspNetCoreServer`](https://github.com/recursive-ai-oss/onemodelcontext-csharp/tree/d72d78c39c934697857be92dd8514efd4c415a88/samples/AspNetCoreServer) | Generated tools served over Streamable HTTP on ASP.NET Core with `app.MapModelContext("/mcp", server)`. Host middleware (authentication, rate limiting) runs before the protocol handler. | yes | | [`LiveControl`](https://github.com/recursive-ai-oss/onemodelcontext-csharp/tree/d72d78c39c934697857be92dd8514efd4c415a88/samples/LiveControl) | A running application exposing operations that are only known at runtime: dynamic tools on a `ContextServer`, served over a Unix socket (`ContextTransport.Stream`) and through `ContextServer.OpenSession`, the message-level entry an in-page channel uses. Operations come and go while agents are connected. | yes | | [`ToolBridge`](https://github.com/recursive-ai-oss/onemodelcontext-csharp/tree/d72d78c39c934697857be92dd8514efd4c415a88/samples/ToolBridge) | Tools defined by another framework (name, description, JSON schema, async delegate) served as tools, including one that asks the user first with `RequestContext.ConfirmAsync`, answered on the client by one `OnConfirm` callback. | yes | | [`Explorer`](https://github.com/recursive-ai-oss/onemodelcontext-csharp/tree/d72d78c39c934697857be92dd8514efd4c415a88/samples/Explorer) | A client for any stdio server: connect (the era is negotiated), list tools, call one, read a resource, get a prompt, answer the server's questions from typed host callbacks (`OnConfirm`, `OnElicit`). Connects with `ModelContext.ConnectAsync("", onConfirm: ...)`. | yes | | [`ProtectedServer`](https://github.com/recursive-ai-oss/onemodelcontext-csharp/tree/d72d78c39c934697857be92dd8514efd4c415a88/samples/ProtectedServer) | `MapModelContext(...).RequireAuthorization()` with the resource server: a static bearer token, a 401 challenge that points at the protected resource metadata, and a client that connects with the token. A server on the internet swaps the validator for JWT or introspection. | yes | | [`QuickPath`](https://github.com/recursive-ai-oss/onemodelcontext-csharp/tree/d72d78c39c934697857be92dd8514efd4c415a88/samples/QuickPath) | The reflection quick path: tools, a resource and a prompt from lambdas, for trying things out. Not trimmable and not NativeAOT. | no | | [`HttpServer`](https://github.com/recursive-ai-oss/onemodelcontext-csharp/tree/d72d78c39c934697857be92dd8514efd4c415a88/samples/HttpServer) | Two tools served on the built-in HTTP listener with `ModelContext.ServeHttpAsync` (loopback only). | yes | | [`AppsServer`](https://github.com/recursive-ai-oss/onemodelcontext-csharp/tree/d72d78c39c934697857be92dd8514efd4c415a88/samples/AppsServer) | A tool with a `ui://` view (MCP Apps) and a text fallback for clients that do not render it, served over stdio, `--http`, or a self-demo on each era. | yes | | [`HostedServer`](https://github.com/recursive-ai-oss/onemodelcontext-csharp/tree/d72d78c39c934697857be92dd8514efd4c415a88/samples/HostedServer) | A generic-host app serving a tool class whose constructor takes a service from the container, over stdio, with `services.AddContextServer().UseStdio()`. The host stops when the client hangs up. | yes | | [`ChatTools`](https://github.com/recursive-ai-oss/onemodelcontext-csharp/tree/d72d78c39c934697857be92dd8514efd4c415a88/samples/ChatTools) | A server's tools handed to a function-calling `IChatClient` with `client.ToAIFunctionsAsync()`, on each protocol era. Uses a scripted chat client; swap in a real one. | yes | | [`StdioServer`](https://github.com/recursive-ai-oss/onemodelcontext-csharp/tree/d72d78c39c934697857be92dd8514efd4c415a88/samples/StdioServer) | A notebook server with tools, a resource template and a prompt. | yes | ## Running them ```sh dotnet run --project samples/IndexedSearch # waits for a client on stdin dotnet run --project samples/LiveControl # connects to itself and prints what happens dotnet run --project samples/AspNetCoreServer # Streamable HTTP on http://localhost:5000/mcp dotnet run --project samples/ToolBridge # the confirmation flow on each era dotnet run --project samples/Explorer -- --call search --args '{"query":"coffee"}' -- dotnet run --project samples/IndexedSearch ``` `Explorer` takes `--era auto|modern|legacy`, `--call TOOL [--args JSON]`, `--read URI`, `--prompt NAME [--arg k=v]` and then `--` and the server's command line. ## NativeAOT ```sh dotnet publish samples/IndexedSearch -c Release -p:SmokeMode=aot -warnaserror -o out # Explorer publishes the same way samples/IndexedSearch/check-native.sh out/indexed-search ``` The script drives the native binary over stdin and stdout on both eras and checks `tools/list` and `tools/call`. Run `tools/aot-check.sh` to publish core, `IndexedSearch`, `HttpServer` and the CLI with NativeAOT (warnings fail it) and run each. ## The CLI against a legacy server `onemodelcontext inspect "dotnet run --project samples/StdioServer" --era legacy` shows the older handshake against any sample; `tests/RecursiveAI.OneModelContext.CliTests` also runs `inspect` and `call` against a server that only speaks 2025-03-26, 2025-06-18 and the full older set. `ProtectedServer` requires `PROTECTED_SAMPLE_TOKEN` in its environment and exits without serving if it is absent. The sample never prints the token. ## Tests ```sh dotnet run --project tests/RecursiveAI.OneModelContext.SamplesTests ``` © Recursive AI, LLC. Licensed under MIT OR Apache-2.0. Model Context Protocol® is a registered trademark of LF Projects, LLC. OneModelContext is an independent project and is not affiliated with or endorsed by LF Projects, LLC or the Model Context Protocol project. # Coming from the official C# SDK A task map for developers who know the official C# SDK (`ModelContextProtocol`, `ModelContextProtocol.AspNetCore`) and want the same thing in OneModelContext. It lists where to look, not which is better: the two libraries make different choices (this one has a BCL-only core, source-generated tools and a protocol-era layer under a single API), and either may suit a project. The left column follows the official SDK's published documentation ([csharp.sdk.modelcontextprotocol.io](https://csharp.sdk.modelcontextprotocol.io), [Microsoft Learn](https://learn.microsoft.com/dotnet/ai/quickstarts/build-mcp-server)). Nothing is mapped one-to-one by shape; each row says what to write here. Samples with the same names live in [`samples/`](https://recursiveai.org/onemodelcontext/docs/samples/). ## Packages | Task | There | Here | |---|---|---| | Core client and low-level server | `ModelContextProtocol.Core` | `RecursiveAI.OneModelContext` (base class library only) | | Hosting, DI, attribute discovery | `ModelContextProtocol` | `RecursiveAI.OneModelContext` for discovery (the generator ships inside it); `RecursiveAI.OneModelContext.Hosting` for DI | | HTTP server on ASP.NET Core | `ModelContextProtocol.AspNetCore` | `RecursiveAI.OneModelContext.AspNetCore`; the core also has a small built-in HTTP listener | | Tools as `AIFunction`s | `McpClientTool` is an `AIFunction` | `RecursiveAI.OneModelContext.Extensions.AI` (`ToAIFunctionsAsync()`) | ## Tasks | Task | There | Here | |---|---|---| | Declare a tool | `[McpServerToolType]` class, `[McpServerTool, Description("...")]` method | `partial` class, `[Tool]` on the method. The description and parameter descriptions come from `/// ` and `/// ` comments; the generator writes the schema and an AOT-safe invoker. Annotations are properties: `[Tool(ReadOnly = true)]`. | | Declare a resource | `[McpServerResourceType]` / `[McpServerResource]` | `[Resource("notes://{title}")]` on a method of the same partial class | | Declare a prompt | `[McpServerPromptType]` / `[McpServerPrompt]` | `[Prompt]` on a method | | Register what the generator found | `.WithToolsFromAssembly()` | `new Notebook().AddTo(server)`; `Notebook` implements `IContextRegistrar`, so it can be passed straight to `ModelContext.ServeAsync` | | Register at runtime | `McpServerTool.Create(...)` and the server's collections | `server.Tools.Add(new ToolDefinition(name, description, schema), handler)`, and `server.Resources`, `server.Prompts` | | Quick lambda | (use a delegate with the tool factory) | `server.Tool("name", lambda)`; reflection based, so it is marked not trimmable and not NativeAOT-safe | | Run a server over stdio | `AddMcpServer().WithStdioServerTransport()` on a host | `await ModelContext.ServeAsync(new Notebook());` | | Run over HTTP, small | ASP.NET Core `MapMcp()` | `await ModelContext.ServeHttpAsync(new Notebook(), port: 3001);` (built-in listener, loopback only) | | Host over HTTP on ASP.NET Core | `app.MapMcp()` | `app.MapModelContext("/mcp", server)`, then `.RequireAuthorization()` once authentication is configured | | DI hosting | `services.AddMcpServer()...` | `services.AddContextServer().UseStdio()` (or `.UseListener(port: 3001)`) from `.Hosting`; scoped services through `context.GetRequiredService()` | | Reach the server object in a tool | a parameter of type `McpServer` | a parameter of type `RequestContext` (also carries the cancellation token, progress, the signed-in principal and `ConfirmAsync`/`ElicitAsync`) | | Connect a client to a stdio server | `StdioClientTransport` + `McpClient.CreateAsync` | `await ModelContext.ConnectAsync("npx -y some-server")` (a command line), or `ContextClient.ConnectAsync(ContextTransport.Process(...))` | | Connect over HTTP | `HttpClientTransport` | `await ModelContext.ConnectAsync(new Uri("https://host/mcp"))`; pass your own `HttpClient` through `ContextTransport.Http(uri, httpClient)` | | List and call tools | `ListToolsAsync`, `CallToolAsync(name, dictionary)` | `client.ListToolsAsync()`, `client.CallToolAsync(name, arguments)`; the result has `.Text`, `.Content`, `.Structured` and `.IsError` | | Resources and prompts (client) | `ListResourcesAsync`, `ReadResourceAsync`, `GetPromptAsync` | the same three names on `ContextClient`, plus `ListResourceTemplatesAsync`, `ListPromptsAsync`, `CompleteAsync` | | Progress | `IProgress` | `await context.ReportProgressAsync(done, total, message)` | | Ask the user (elicitation) | `server.ElicitAsync(new ElicitRequestParams { ... })` | `await context.ConfirmAsync("Delete 3 files?")` for yes/no, or `context.ElicitAsync(message, schema, typeInfo)`; see the replay rule in [multi-round-trip.md](https://recursiveai.org/onemodelcontext/docs/multi-round-trip/) | | Answer elicitation on the client | `ElicitationHandler` in `McpClientOptions.Handlers` | `onConfirm:` / `onElicit:` arguments of `ModelContext.ConnectAsync`, or the same on `ContextClientOptions` | | Require sign-in on the server | ASP.NET Core authentication, then `[Authorize]` | `ResourceServer` with a validator (JWT, introspection, static token or delegate), `AddResourceServer`, `RequireAuthorization()`; see [resource-server.md](https://recursiveai.org/onemodelcontext/docs/resource-server/) | | Sign in on the client | `ClientOAuthOptions` on the HTTP transport | `OAuthClient` and its `CreateHttpClient()`; see [oauth-client.md](https://recursiveai.org/onemodelcontext/docs/oauth-client/) | | Inspect a server from a shell | the Inspector | the Inspector, or `onemodelcontext inspect ` / `call` / `read` / `prompt` | ## Behavior that differs - **The protocol era is negotiated for you.** The client probes the modern revision (2026-07-28) and falls back to `initialize` for older servers; a server answers both on one endpoint. Write handlers against the modern semantics. `--era` / `ContextClientOptions.Era` pins one. - **A handler that asks for input is re-run on the modern era.** See [multi-round-trip.md](https://recursiveai.org/onemodelcontext/docs/multi-round-trip/). - **Tool names are snake case by default** (`SaveNote` becomes `save_note`). Set `Name` on the attribute to choose another. - **Unsolicited sampling, roots and logging** exist but are `[Obsolete]` with `OMC` diagnostic IDs, following the revision that deprecates them. ## Type names and namespaces The official SDK uses an `Mcp` prefix (`McpClient`, `McpServer`, `McpException`, `McpServerTool`). OneModelContext avoids the abbreviation in public identifiers, so names differ: | There | Here | |---|---| | `McpClient` | `ContextClient` | | `McpServer` | `ContextServer` (the object you register on); `RequestContext` (what a handler receives) | | `McpServerTool` / `McpClientTool` | `ToolDefinition` / `Tool` | | `McpException`, `McpProtocolException` | `ContextException`, `ProtocolException` (JSON-RPC error with code), `AuthorizationException` | | `ModelContextProtocol.Protocol` | `RecursiveAI.OneModelContext.Protocol` | | `ModelContextProtocol.Client` / `.Server` | `RecursiveAI.OneModelContext` (client and server both live in the root namespace) | A project that references both libraries can keep them apart with namespace aliases: ```csharp using Official = ModelContextProtocol.Client; using OneModelContext = RecursiveAI.OneModelContext; OneModelContext.ContextClient one = await OneModelContext.ModelContext.ConnectAsync("my-server"); ``` Both libraries can run in one process. They share nothing but the wire protocol. Model Context Protocol® is a registered trademark of LF Projects, LLC. OneModelContext is an independent project and is not affiliated with or endorsed by LF Projects, LLC or the Model Context Protocol project. # Stdio and duplex streams Messages are UTF-8 JSON-RPC, one per line. Standard output carries only protocol messages; diagnostics belong on standard error. Both protocol eras share the stream. A legacy `initialize` is an admission barrier: its outcome is visible before later requests or notifications on the connection are dispatched. Ordinary requests still run concurrently and replies may arrive out of order. ## Era negotiation Auto mode probes modern first. `ContextClientOptions.ProbeTimeout` defaults to 5 seconds; newly spawned processes get an additional `ProcessStartupAllowance` of 10 seconds on their first probe (15 seconds total), still within the 30-second `ConnectTimeout`. Silence or a recoverable transport failure can trigger legacy fallback for that connection, but cannot teach the cache that the server is legacy. Only a definitive rejection or an explicit legacy-only version list can do that. Other protocol errors and malformed probe responses also remain uncached. The default in-memory `EraCache` expires a legacy choice five minutes after a definitive probe, even if cached legacy connections keep succeeding. This lets a dual-era or upgraded server be probed again. Modern choices remain until forgotten or negotiation fails. Construct `EraCache` with a clock and legacy lifetime to change expiration; custom `IEraCache` implementations own their expiration policy. ## Backpressure `NewlineChannel` queues at most 1 MiB of output, except that a single larger message is accepted into an empty queue. One additional block may be in flight to the stream. Writers wait **before copying** when the queue has no room. The connection also stops reading input while that queue is full, and awaits rejection writes, so a peer that never reads cannot create an unbounded backlog of replies. Handler concurrency remains subject to `ContextServerOptions.MaxConcurrentRequests` (256 by default). The cost of that bound: a single-threaded peer that writes more than about 1 MiB of requests before reading any response can block against the paused input, because the server stops reading until its replies are read. Clients should read and write concurrently, as the protocol already expects. ## EOF and shutdown Input EOF is a half-close: admitted handlers may finish and their queued replies are drained before output closes. `ContextServerOptions.EndOfStreamTimeout` gives handlers a 5-second grace period, followed by cancellation and a bounded 5-second shutdown wait. Total EOF linger, including output draining, is at most grace plus shutdown: **10 seconds by default**. The JSON-RPC layer exposes both bounds as `JsonRpcConnectionOptions.EndOfStreamTimeout` and `ShutdownTimeout`. Output progress is checked after each 16 KiB write. A successful write resets the no-progress deadline (the shutdown timeout, 5 seconds by default); a reader that starts late or reads slowly is not cut off by a fixed one-second drain deadline. The total shutdown bound still applies. Transport failure or a reader that remains stalled past either deadline closes the output; a failed write is never followed by another message on that stream. Like any bounded transport shutdown, this cannot guarantee delivery to a peer that has stopped reading or disconnected halfway through a message. Explicit cancellation and transport faults skip the handler grace period. Standalone `NewlineChannel` disposal uses the same 5-second idle and 10-second total output bounds with `TimeProvider.System`; connections inject their clock and configured bounds. # Streamable HTTP client `ModelContext.ConnectAsync("https://host/mcp")` (or `ConnectAsync(new Uri(...))`, or `ContextTransport.Http(uri, client, options)`) connects over Streamable HTTP. Every message is a POST to the endpoint; the answer is a JSON body or an event stream. The client speaks both eras and negotiates like it does over stdio: a `server/discover` probe, then an optimistic modern request with a `-32022` retry, then `initialize` for servers that only speak a legacy revision. The era is remembered per origin. A timeout or transport failure never records a legacy choice; definitive rejections (including HTTP 400/404/405) can. The default `EraCache` expires legacy choices after five minutes without renewing them on cache hits, so modern support is probed again. ## What each era does on the wire - **2026-07-28:** no session. Each request carries `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name` headers that match the body, and the version, capabilities and client info in `_meta`. A broken response stream means the request is issued again with a new id (`MaxReissues`), except for `subscriptions/listen`, which the caller reopens. - **2024-11-05 to 2025-11-25:** `initialize`, then `Mcp-Session-Id` on every request; a GET stream (`OpenListeningStream`) carries server-initiated requests and notifications; a dropped event stream resumes with `Last-Event-ID` and the server's `retry` value (`MaxResumeAttempts`, `DefaultRetryDelay`, `MaxRetryDelay`); closing the client sends DELETE. A server that refuses the GET stream with 405 is tolerated. - **Deprecated HTTP+SSE (2024-11-05):** if a POST to the URL is refused the way an HTTP+SSE-only server would (400, 404 or 405 without a JSON-RPC error) and `AllowSseFallback` is on, the client opens the URL with GET, waits for the `endpoint` event (`SseFallbackTimeout`) and posts to that endpoint. The endpoint must be on the same origin. This transport is client-only; the library never serves it. ## Subscriptions `client.ListenAsync(filter)` opens `subscriptions/listen` on 2026-07-28 and reports list changes and resource updates. On a legacy server it uses the GET stream and `resources/subscribe`. See `ContextSubscription`. ## Credentials and redirects - Credentials are never put in the URL: `ContextTransport.Http` refuses a URL with a user name or password. - `HttpTransportOptions.AuthorizeRequest` runs before every request the transport sends, including the GET stream, resumption and DELETE, so a token provider can add `Authorization` (and refresh it) in one place. Tokens are never logged or put in exception text. - Redirects are not followed: the client the transport creates has `AllowAutoRedirect = false`, and a 3xx answer is a `TransportException` that names the status. If you pass your own `HttpClient`, give it a handler that does not follow redirects, so a credential cannot be forwarded to another place. A 401 or 403 is surfaced as an authorization error with the server's challenge for the caller (or the OAuth client) to act on. ## Limits and defaults | Option (`HttpTransportOptions`) | Default | Effect | |---|---|---| | `AllowSseFallback` | on | Try the HTTP+SSE transport when the URL only speaks that. | | `OpenListeningStream` | on | Open the legacy GET stream after `initialize`. | | `MaxEventBytes` | 8 MiB | A larger event ends that stream. | | `MaxResponseBodyBytes` | 16 MiB | A larger JSON body fails that request. | | `StreamIdleTimeout` | 5 min | A stream with no event or keep-alive comment for this long is treated as broken. | | `MaxResumeAttempts` | 5 | Resumptions of one stream before the request fails. | | `DefaultRetryDelay`, `MaxRetryDelay` | 1 s, 30 s | Wait before resuming; the server's `retry` is capped by the maximum. | | `MaxReissues` | 2 | Modern re-issues of a request whose stream broke. | | `SseFallbackTimeout` | 10 s | Wait for the `endpoint` event. | | `CloseTimeout` | 5 s | The DELETE on close. | | `MaxQueuedMessages` | 256 | Messages waiting to be read by the client. | | `TimeProvider` | system | Every timeout and delay above. | Failures before the first successful exchange end the connection with a `TransportException`; afterwards a failure fails the request it belongs to and leaves the client usable. # Streamable HTTP server: limits and defaults `StreamableHttpEndpoint` serves both protocol eras on one path over the small `IHttpExchange` abstraction. Every limit below is a property of `StreamableHttpOptions`. | Option | Default | Effect | |---|---|---| | `ValidateHost` | on | The `Host` header must be a loopback name or listed in `AllowedHosts`. | | `AllowedOrigins`, `AllowLoopbackOrigins` | none, on | A request with an `Origin` header outside the list is refused with 403 (DNS-rebinding protection). A request without `Origin` is accepted. `*` disables the check and is unsafe. | | `MaxRequestBodyBytes` | 8 MiB (`ModelContext.ServeHttpAsync`: 1 MiB) | A larger declared body gets 413 without being read; a chunked body is cut off at the limit. A body with a declared length is held in a buffer of exactly that size. | | `MaxInFlightBodyBytes` | 64 MiB | Request bodies held in memory at once, over all requests of the endpoint. A body is charged its declared length (chunked: its size so far) until its request ends; a request that would exceed the budget gets 503 with `Retry-After: 1`. | | `MaxStreamLifetime` | 1 hour | A legacy GET event stream is ended with a `retry` field after about this long (checked every `KeepAliveInterval`); a live client resumes with `Last-Event-ID`, a reader that never reads is dropped. Zero keeps streams until the client leaves. | | `BodyReadTimeout` | 30 s | A body that trickles in more slowly gets 408. | | `SseWriteTimeout` | 30 s | A client that stops reading an event stream is dropped. | | `KeepAliveInterval` | 15 s | Comment lines keep idle streams open. | | `MaxSessions`, `SessionIdleTimeout` | 1000, 30 min | Legacy sessions only; more sessions get 503, idle ones are removed. | | `MaxReplayEvents`, `MaxReplayBytes` | 256, 4 MiB | Events kept per session for `Last-Event-ID` resumption. | | `MaxStreamsPerSession` | 16 | Concurrent GET streams of one legacy session. | ## Status codes - Envelope errors are 400: parse errors, invalid requests, a missing or mismatched `MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name` or `Mcp-Param-*` header (`-32020`), an unsupported version (`-32022`), a missing capability (`-32021`), and a modern request whose `_meta` lacks the protocol version or the client capabilities (2026-07-28 Streamable HTTP, "Protocol Version Header"; SEP-2575). - A POST without `Content-Type: application/json` (parameters such as `charset` are fine) is 415. Browsers can then only send it after a CORS preflight, which the `Origin` check answers. - A POST that finds the in-flight body budget used up is 503. - A method the modern endpoint does not implement is 404 with `-32601`, including `initialize` sent with the 2026-07-28 version header. - Everything a handler says (unknown tool, bad arguments, resource not found) is a 200 with the error in the body. - A legacy request other than `initialize` without `Mcp-Session-Id` is 400; with an unknown or ended session it is 404. ## Era handling - Modern requests carry no session; a stray `Mcp-Session-Id` is ignored and never echoed. Closing the response stream cancels the request. - Legacy requests are pinned to the session. A dropped connection does not cancel a running handler, so the client can resume the stream with `Last-Event-ID`; an unknown id is 400. - `server/discover` without `_meta` is answered `-32602` with status 400: the discovery page defines its request as the standard `_meta` and nothing else, and every 2026-07-28 request must carry the version and client capabilities there. # The built-in HTTP listener: limits and defaults `HttpServer` is a small HTTP/1.1 server on `Socket` (with `SslStream` when a certificate is set) for local tools and command-line programs. `ModelContext.ServeHttpAsync` starts one on the loopback address. For a server on the internet, host the endpoint in a web framework. The listener checks nothing about `Origin` or `Host`; `StreamableHttpEndpoint` does, as above. Every limit below is a property of `HttpServerOptions`. | Option | Default | Effect | |---|---|---| | `Address`, `Port` | loopback, 0 | Any other address is an explicit choice and warns unless `Certificate` or `TrustedProxies` is set; `TrustedProxies` on an any-address bind warns too, because direct clients reach the port as well. The warning is a `DiagnosticKind.Warning` event when a diagnostics subscriber exists, otherwise one line on standard error. Port 0 picks a free port (`LocalEndPoint`). | | `Certificate`, `SslProtocols` | none, OS default | With a certificate every connection is TLS and only `http/1.1` is offered through ALPN. | | `TrustedProxies` | none | Only these peers' `X-Forwarded-For`, `-Proto` and `-Host` are believed (the client address is the first address from the right that is not itself a trusted proxy; the last proto and host value). For every other peer all `X-Forwarded-*` and `Forwarded` headers are removed before the handler sees the request. | | `MaxConnections` | 256 | Further connections queue in the operating system's accept backlog. | | `MaxConnectionsPerAddress` | 64 | Connections one client IP address may hold at once (IPv4-mapped IPv6 folded in). A surplus connection is closed at once. Behind a reverse proxy all clients share the proxy's address: raise it there. | | `MaxRequestLineBytes` | 8 KiB | 414. | | `MaxHeaderBytes`, `MaxHeaderCount` | 32 KiB, 100 | 431. The head is read into one pooled buffer of this size. | | `MaxRequestBodyBytes` | 8 MiB | A larger `Content-Length` gets 413 before anything is read; a chunked body is cut off at the limit with 413. | | `FirstByteTimeout` | 10 s | A new connection that sends no byte is closed silently (the shorter of this and `IdleTimeout`). | | `IdleTimeout` | 60 s | A keep-alive connection that sends no byte between requests is closed silently. | | `HeaderReadTimeout` | 10 s | From the first byte of a request to the end of its head (and for the TLS handshake). Slower gets 408. A sender that trickles bytes still hits it. | | `RequestTimeout` | 30 s | From the end of the head to the end of the body. Slower gets 408. | | `WriteTimeout` | 30 s | One write to the peer. A client that stops reading is dropped. | | `ShutdownGracePeriod` | 5 s | How long `DisposeAsync` waits for handlers after canceling them. | ## What is refused The parser is strict (RFC 9112), because a disagreement with a proxy about where a request ends is what request smuggling uses. Each of these gets 400 (unless stated), is answered with `Connection: close`, and nothing after it on the connection is parsed: - a bare CR or LF anywhere in the head; obsolete line folding; whitespace before a header colon; control characters in values; - `Content-Length` together with `Transfer-Encoding`; a repeated, signed, spaced or list-valued `Content-Length`; - a transfer coding other than a lone `chunked` (501); `Transfer-Encoding` on HTTP/1.0; - a missing or repeated `Host` on HTTP/1.1; request targets other than origin-form; lower-case method names; versions other than 1.0 and 1.1 (505); bad percent escapes and encoded NULs in the path; - malformed chunk sizes (non-hex, signed, spaced, empty, more than 15 digits), a chunk not followed by CRLF, bare LF in chunk framing, obsolete folding in a trailer; `Expect` other than `100-continue` (417). Chunk extensions are read and ignored (RFC 9112 section 7.1.1) and trailers are read and dropped, both bounded. ## Connection behavior - Keep-alive on HTTP/1.1; HTTP/1.0 connections close after the response. Pipelined requests are answered in order, one at a time. - A handler that does not read the whole request body leaves the connection open only if the rest is at most 64 KiB; otherwise it closes. - `IHttpExchange.Aborted` is canceled when the peer closes the connection, including closing only its sending side, or the server stops. While a handler runs with a pipelined request already waiting, a hang-up is not watched for. - Every accepted socket has TCP keep-alive probes on (first after 60 s, then every 15 s, 4 misses), so a peer that vanished is evicted; the TLS close notification is bounded to 2 s. - Error responses the listener writes itself carry `X-Content-Type-Options: nosniff`. - After an error response the server finishes its side, then reads and discards up to 64 KiB for up to 2 s so the peer sees the response instead of a connection reset. - A response that is never flushed is sent with `Content-Length`; a flushed one (every event stream) is chunked. A handler may set `Content-Length` itself, and then writing more or fewer bytes closes the connection. `Transfer-Encoding` cannot be set, and header names and values with control characters are refused when set. # Tool signatures and JSON Schema The `[Tool]` source generator and the reflection quick path (`server.Tool("name", delegate)`) write the same schema for the same signature. This note is that mapping. It follows the table that OneLLM publishes for its tools (`docs/tool-schemas.md` in OneLLM), so a method declared for both libraries lists the same schema in each; the differences are listed at the end. Dialect: JSON Schema 2020-12. Only the keywords below are written, always in this order: `type`, `format`, `description`, `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `pattern`, `contentEncoding`, `items`, `minItems`, `maxItems`, `uniqueItems`, `properties`, `required`, `additionalProperties`, `default`. ## A tool - **Name:** `[Tool(Name = ...)]` (or `[Tool("name")]`), else the method name in snake_case without a trailing `Async` (`GetDocketAsync` → `get_docket`, `HTTPRequest` → `http_request`). 1 to 128 ASCII letters, digits, `_`, `-` or `.` (the tools specification's rule). - **Description:** `[Tool(Description = ...)]`, else `[Description]` on the method, else the `` doc comment (generator only), else none. - **Parameters:** `RequestContext` and `CancellationToken` are filled in and never listed (at most one of each). Every other parameter is a property named exactly as the C# parameter. It is required unless it has a default value; a nullable type without a default is still required and accepts `null`. A string, number, boolean, char or enum default is written as `default`; a `null` default writes nothing. The object has `"additionalProperties": false`, and `required` is left out when empty. Parameter description: `[Description]`, else the method's ``. - **Annotations:** `ReadOnly`, `Destructive`, `Idempotent`, `OpenWorld` and `Title` on the attribute. A hint is listed only when the attribute assigns it. ## Types | C# type | Fragment | |---|---| | `bool` | `{"type":"boolean"}` | | `sbyte` `byte` `short` `ushort` `int` `uint` `long` `ulong` | `{"type":"integer","minimum":MIN,"maximum":MAX}` for the type's range | | `float` `double` `decimal` | `{"type":"number"}` | | `string` | `{"type":"string"}` | | `char` | `{"type":"string","minLength":1,"maxLength":1}` | | `Guid` | `{"type":"string","format":"uuid"}` | | `DateTime`, `DateTimeOffset` | `{"type":"string","format":"date-time"}` | | `DateOnly` / `TimeOnly` | `format: date` / `format: time` | | `TimeSpan` | a string with the pattern of the invariant `c` form | | `Uri` | `{"type":"string","format":"uri"}` | | `byte[]` | `{"type":"string","contentEncoding":"base64"}` | | enum | `{"type":"string","enum":[member names in declaration order]}`, matched exactly | | `T?` | the fragment of `T` with `null` added to `type` (and to an enum's `enum` list) | | arrays, `List`, `IList`, `ICollection`, `IEnumerable`, `IReadOnlyList`, `IReadOnlyCollection` | `{"type":"array","items":...}` | | `HashSet`, `ISet`, `IReadOnlySet` | the same plus `"uniqueItems":true` | | `Dictionary`, `IDictionary`, `IReadOnlyDictionary` | `{"type":"object","additionalProperties":...}` | | `JsonElement`, `JsonNode` / `JsonObject` / `JsonArray` | `{}` / `{"type":"object"}` / `{"type":"array"}` | | record, class, struct | a closed object (below) | Not supported: `object`, other interfaces, delegates, multi-dimensional arrays, dictionaries keyed by anything but `string`, `ref`/`out`/`in` parameters, open generics, other collection types. **Records and classes:** public instance properties with a public getter, base class first, then declaration order; `[JsonIgnore]` removes one and `[JsonPropertyName]` renames it. Names are camelCase when the type's `JsonSerializerContext` says `CamelCase` (or uses `JsonSerializerDefaults.Web`) and as written when it names no policy; any other policy is refused. A property is required when it is `required`, `[JsonRequired]`, or a parameter without a default of the type's only public constructor. Property descriptions come from `[Description]`, then the property's ``, then (for a positional record) the record's ``. **Validation attributes:** `[Range]`, `[StringLength]`, `[MinLength]`, `[MaxLength]`, `[Length]`, `[RegularExpression]`, `[EmailAddress]` and `[Url]` map to `minimum`/`maximum`, `minLength`/`maxLength` (`minItems`/`maxItems` on arrays), `pattern`, `format: email` and `format: uri`. **Doc comments** are read from the source tree, so they work without a documentation file: whitespace collapses, `` becomes `X`, `` becomes `p`, `` and `` keep their text. `[Description]` always wins. ## Reading arguments and writing results The generated invoker reads arguments with System.Text.Json source-generated metadata: the built-in set for the scalar types above, nullable scalars, `string[]`, `List` and a few other common collections, and the `JsonSerializerContext` that lists the type for everything else (records, other collections, dictionaries). Enums are matched by name without a context. A type no context lists is a build error (`OMCG0005`), so nothing falls back to reflection. | Return type | Result | |---|---| | `void`, `Task`, `ValueTask` | no content | | `string` | one text block (`null` is the text `null`) | | `ContentBlock` and subclasses, sequences of them | those blocks | | `CallToolResult` | passed through | | `JsonNode`, `JsonElement` | one text block of the JSON | | numbers, `bool`, `Guid`, dates, `Uri`, collections | one text block of the JSON value | | enum | its member name | | record, class, dictionary | structured content, the same JSON as a text block, and an `outputSchema` | `Task` and `ValueTask` are unwrapped. A nullable object return (`Note?`) keeps `"type":"object"` in its `outputSchema` (every revision before 2026-07-28 requires an object); a null result is sent as the text `null` with no `structuredContent`. **Errors.** A missing, unknown, null or wrongly typed argument is a tool execution error (an `isError` result whose text names the argument, for example `Argument 'items[2].qty' is not valid.`), because the tools specification asks for input validation errors to be fixable by the model. A `ToolException` becomes an error result with its message. Any other exception becomes the result `The tool failed.`: its text can hold internals and is never sent; it is published to the diagnostics stream instead. ## Where this differs from the OneLLM table - Tool names may use `.` and be up to 128 characters, as the tools specification allows (the table says 1 to 64 without `.`). - `outputSchema` and structured content are written only for object-shaped results (records, classes, dictionaries). The table writes one for any non-text result, but four of the five protocol revisions require `outputSchema.type` to be `"object"`, and clients validate listings against that. - An exception from the tool is reported as `The tool failed.`, not with its type and message. - `HashSet` is listed with `uniqueItems`, but duplicates are not refused when reading (the serializer drops them). - Generator diagnostics are `OMCG0001` to `OMCG0012` (the table's `OLT001` to `OLT011` in order, with the same meanings, plus the resource and prompt rules). - `RequestContext` plays the part of the table's `ToolContext`; `EndsLoop`, `RequiresApproval`, `ParallelSafe` and `TimeoutSeconds` do not exist here (a request timeout is a server option). ## MCP Apps properties `[Tool(UiResource = "ui://weather/view", UiVisibility = new[] { "model", "app" })]` names the `ui://` resource that renders the tool's result and who may call it (default both). The generated registration opts the server in to the Apps extension and sets the tool's `_meta.ui`; callers that did not negotiate Apps see an ordinary tool. Register the resource itself with `AddUiResource`. See `extensions.md`. ## Resources and prompts `[Resource("scheme://{template}")]` methods take template variables as parameters of the same name; `[Prompt]` methods take the prompt arguments. Both read text: parameters are `string` or a type that parses from a string (numbers, `bool`, `Guid`, dates and times), required unless they have a default value or are nullable. A bad value is an invalid-params error (-32602). Resources return `string` (text, `text/plain` unless `MimeType` says otherwise), `byte[]` (blob), `ReadResourceResult` or resource contents; prompts return `string` (one user message), `PromptMessage`s or `GetPromptResult`. ## Generator diagnostics | Id | Meaning | |---|---| | OMCG0001 | The containing type, and every type around it, must be a non-static `partial` class | | OMCG0002 | A parameter type has no schema (or a resource/prompt parameter is not text-like) | | OMCG0003 | The return type is not supported, or the method is `async void` | | OMCG0004 | The method is generic | | OMCG0005 | A record, collection or dictionary needs a `JsonSerializerContext` that lists it | | OMCG0006 | A type refers to itself | | OMCG0007 | Two members of one class share a name | | OMCG0008 | The name is not valid | | OMCG0009 | More than one `RequestContext` or `CancellationToken` parameter | | OMCG0010 | The containing class is generic | | OMCG0011 | The serializer context uses a property naming policy other than `CamelCase` or none | | OMCG0012 | `UiResource` is not a `ui://` URI, or `UiVisibility` holds something other than `model` or `app` | # Asking the client for input: the replay rule A tool, prompt or resource handler can ask the client for input in the middle of a request: `ctx.ConfirmAsync`, `ctx.ElicitAsync`, `ctx.SampleAsync`, `ctx.ListRootsAsync`, `ctx.ElicitUrlAsync`. The handler code is the same on every protocol revision. What happens underneath is not. ## 2026-07-28: the handler runs again The revision has no server-to-client requests. When a handler asks for input and the answer is not in the request: 1. The ask ends the current attempt. The server answers `resultType: "input_required"` with the `inputRequests` and a `requestState`. 2. The client answers each input request and retries the same request with `inputResponses` and the `requestState` it was given. 3. The server runs the handler **again from the start**. This time the same `ctx.*` call returns the answer. So **code before an ask runs once per attempt and must be safe to repeat**. Read-only work is fine; do not charge a card, send a message or append to a file before the last ask. Anything that must not be recomputed goes in `ctx.State` (string values), which travels in `requestState` and is restored on the retry. Asks made in parallel (`Task.WhenAll`) share one round. Answers from earlier rounds ride inside `requestState`, so a flow of several rounds sees every answer it was given. The client gives up after `ContextClientOptions.MaxInputRounds` rounds (default 8). ## `requestState` is not trusted input The client holds it and sends it back, so the server protects it: - **HMAC-SHA256** by default; with `ContextServerOptions.EncryptRequestState` it is sealed with **AES-GCM**, so the client cannot read it either. Keys are derived with HKDF from `ContextServerOptions.RequestStateKey`; without one the server uses an ephemeral per-process key, so state from another process or an earlier run is refused. Set a key when several instances serve one client. - It expires after `RequestStateLifetime` and is bound to the method, the target name, the caller's principal and a digest of the request's parameters (leaving out `_meta`, `inputResponses` and `requestState`). State replayed on another request, by another user, or edited, is refused. - Comparison is constant time. A refused state is `-32602` ("the requestState is not valid"); it never reaches the handler. - `MaxRequestStateBytes` bounds its size. A capability the client did not declare (for example elicitation) is `-32021` with the missing capability, not an `input_required` the client cannot answer. ## 2025-11-25 and earlier: a real await Legacy revisions have server-initiated requests, so the same `ctx.*` call sends `elicitation/create`, `sampling/createMessage` or `roots/list` on the held connection and awaits the reply. The handler runs once; nothing is replayed. Code written to the rule above is correct on both. Sampling and roots are deprecated by 2026-07-28 but still answered on every revision that has them. # Resource server An MCP server that listens on HTTP can require OAuth 2.1 bearer tokens. OneModelContext plays the resource server role: it serves protected resource metadata, challenges requests without a valid token, and checks that every token was issued for this server. Authentication is optional; stdio servers take their credentials from the environment and do not use any of this. ```csharp var validator = new JwtTokenValidator("https://auth.example.com", new HttpJwksProvider(new Uri("https://auth.example.com/jwks"))); var resourceServer = new ResourceServer(new ResourceServerOptions(new Uri("https://mcp.example.com/mcp"), validator) { AuthorizationServers = ["https://auth.example.com"], ScopesSupported = ["files:read", "files:write"], }); // Built-in endpoint (any host that passes requests to StreamableHttpEndpoint): var endpoint = new StreamableHttpEndpoint(server, new StreamableHttpOptions { ResourceServer = resourceServer }); // ASP.NET Core: builder.Services.AddAuthentication(ResourceServerDefaults.AuthenticationScheme).AddResourceServer(resourceServer); builder.Services.AddAuthorization(); app.UseAuthentication(); app.UseAuthorization(); app.MapModelContext("/mcp", server, new StreamableHttpOptions { ResourceServer = resourceServer }).RequireAuthorization(); ``` ## What the endpoint does | Request | Answer | |---|---| | `GET` of `/.well-known/oauth-protected-resource` or the same path with the resource's own path appended | 200, the metadata document (RFC 9728); public | | any other request, no `Authorization` header or a scheme other than `Bearer` | 401, `WWW-Authenticate: Bearer resource_metadata="…"` (plus `scope="…"` when the server has required scopes) | | two `Authorization` headers | 400 `invalid_request` | | a token that is not valid for this server | 401, `error="invalid_token"` | | a valid token without the server-wide scopes, or without the scopes of the tool, resource or prompt being used | 403, `error="insufficient_scope"`, `scope="…"` listing every scope the operation needs, `resource_metadata` | | the issuer or introspection endpoint cannot be reached | 503 | Per-operation scopes are the `RequiredScopes` property of `ToolDefinition`, `ResourceDefinition`, `ResourceTemplateDefinition` and `PromptDefinition`. A `ScopeHierarchy` lets a broad scope (`admin`) satisfy narrower ones. Batches (2025-03-26 only) are refused as a whole if any request in them lacks its scopes. Both protocol eras get the same checks; legacy sessions belong to the subject that created them, and another caller presenting the session id gets 404. The resource is configured, never derived from the `Host` header. Tokens must name it in `aud` (RFC 8707); the comparison lower-cases scheme and host and drops a default port, and is otherwise exact. A token is never passed on to anything: handlers get a `ClaimsPrincipal` (`sub`, `iss`, `client_id`, one `scope` claim per scope), available as `RequestContext.Principal` and in the audit record. ## Validators | Validator | Notes | |---|---| | `JwtTokenValidator` | RS/PS/ES 256, 384 and 512 on BCL crypto. `iss` exact, `aud` = the resource, `exp` required, `nbf`/`iat` with at most 5 minutes of skew (default 60 s). Everything else, `none` and HMAC included, is refused. For EdDSA issuers, supply a custom `ITokenValidator` or `DelegateTokenValidator`; OneCrypto-based support is planned. | | `HttpJwksProvider` | HTTPS only (HTTP on loopback), no redirects, 256 KiB and 10 s limits, cached 10 minutes. An unknown `kid` triggers one refetch, never more than one fetch per 30 s. A failed refresh keeps serving the cached keys. | | `IntrospectionTokenValidator` | RFC 7662, HTTP Basic or bearer client authentication. Needs `active: true` and an `aud` naming the resource. Active answers are cached for at most 60 s and never past `exp`; inactive ones are not cached. | | `StaticTokenValidator` | Per-user opaque tokens kept as SHA-256 hashes; every entry is compared in constant time. Tokens must be at least 16 characters. | | `DelegateTokenValidator` | Your own check. Implement `ITokenValidator` for anything richer. | Validator failures carry a short `Description` that is safe to send to the caller. Tokens are never logged, never part of an exception message or a diagnostic event, and a description that contains the presented token is scrubbed before it is used. ## Legacy clients The 2025-06-18 and 2025-11-25 revisions use the same metadata document and challenge. The 2025-03-26 revision predates protected resource metadata: its clients look for authorization server metadata on the MCP server's own origin, which a resource server that delegates to a separate authorization server does not serve. # OAuth client `OAuthClient` signs a program in to an OAuth-protected server and keeps its tokens fresh. It lives in the `RecursiveAI.OneModelContext.OAuth` namespace, which holds no protocol types, and follows the 2026-07-28 authorization pages (with the 2025-03-26, 2025-06-18 and 2025-11-25 differences), OAuth 2.1, RFC 9728, RFC 8414, OpenID Connect Discovery, RFC 7636, RFC 8707 and RFC 9207. ```csharp using var receiver = new LoopbackRedirectReceiver((url, ct) => OpenBrowser(url)); // host supplies OpenBrowser using var oauth = new OAuthClient(new OAuthClientOptions(new Uri("https://example.com/mcp")) { UserAgent = receiver, ClientIdMetadataUrl = "https://app.example/client.json", // or PreRegistered = new("id", "secret") Store = new FileTokenStore(path), }); await using var client = await ModelContext.ConnectAsync(ContextTransport.Http(new Uri("https://example.com/mcp"), oauth.CreateHttpClient())); ``` `CreateHttpClient()` is the integration: its handler adds the token, and answers a `401` or `403` by running the flow and replaying the request. `HttpTransportOptions.AuthorizeRequest = oauth.AuthorizeRequestAsync` only decorates requests and cannot answer a challenge. A host that wants to handle `AuthorizationException` itself calls `oauth.AuthorizeAsync(ex.Challenge, null, ex.InsufficientScope)` and retries. ## The flow 1. **Discovery.** The `resource_metadata` URL of the `WWW-Authenticate` challenge, else the well-known protected resource document for the endpoint path, then the origin. The document's `resource` must be the endpoint or a parent of it, and is the `resource` parameter from then on. Authorization server metadata comes from RFC 8414, then OpenID Connect Discovery; its `issuer` must equal the URL it was fetched for exactly. A server with no protected resource document is treated as 2025-03-26 does: metadata on the MCP origin, else default endpoints. 2. **Registration**, in this order: `PreRegistered` credentials, the Client ID Metadata Document URL (when the server advertises support), credentials stored for this issuer, then Dynamic Client Registration (`AllowDynamicRegistration`, default on, `[Obsolete]` with `OMC0004`; `application_type` is set). Credentials are keyed by issuer and never reused for another one. A public pre-registered client without an `Issuer` binds to the first issuer it is used with (set `Issuer` anyway when you know it). A rejected stored registration (`invalid_client` on refresh) is dropped, so the next sign-in registers again. 3. **Authorization code with PKCE S256.** A server whose metadata does not list `S256` is refused. `resource` is sent on the authorization and token requests; `state` is checked first, then `iss` by the RFC 9207 table (compared as a plain string, no normalization: present and different is an error; absent is an error when the server advertises `authorization_response_iss_parameter_supported`), then the `error` parameter, and only then is the code redeemed. 4. **Scopes** come from the challenge, else `scopes_supported`, else are omitted. `offline_access` is added when the server lists it (`RequestOfflineAccess`). A `403 insufficient_scope` asks again for the union of the granted and challenged scopes; the attempt stops when the challenge adds nothing, and after `MaxAuthorizationAttempts` (3). A challenge with no `scope` names nothing to ask for, so it never repeats within one request and starts a new authorization at most once per `StepUpCooldown` (5 minutes) per handler. 5. **Refresh** uses the stored refresh token, replacing it when the server rotates; a refresh failure falls back to a new sign-in. Before spending a refresh token the client looks at the store again, and when the server refuses the token as spent it adopts a different one that another process has stored in the meantime instead of erasing it. **Anything that carries a secret needs a pinned issuer.** A resource's metadata may list any authorization server (RFC 9728 section 7.6 leaves the choice to the client), so a hostile resource could name its own and receive the credential. `ClientCredentials` and `Enterprise` therefore require `ExpectedIssuer`, and a `PreRegisteredClient` with a `ClientSecret` requires `Issuer`; without it `AuthorizeAsync` throws `OAuthException` with the error `issuer_required` before any request is made. Flows without a secret (Client ID Metadata Document, public pre-registered client, dynamic registration) keep choosing the first listed server unless a pinned issuer is set; pinning is still the recommendation when the application knows its server. Client Credentials (`ClientCredentials`: a secret sent by HTTP Basic, or by form post when the server lists only that, or a signed `private_key_jwt` assertion with RS256, PS256 or ES256/384/512) and Enterprise-Managed Authorization (`Enterprise`: token exchange at the identity provider for an ID-JAG, then the JWT-bearer grant at the server) need no user agent. ## Safety rules - The token goes only to the origin of the resource it was issued for. A token for resource A is never sent to B. - No request that carries a credential follows a redirect (`AllowAutoRedirect = false`); a 3xx from any OAuth endpoint is a failure. - Metadata is fetched over HTTPS. Plain HTTP is accepted only for a loopback endpoint and only when the protected resource itself is on loopback, so a remote resource cannot point the client at a service on the user's machine. `RequestTimeout` (30 s) and `MaxResponseBytes` (256 KiB) bound each request. - A caller who supplies `HttpHandler` must hand over a `SocketsHttpHandler` or `HttpClientHandler` (possibly under `DelegatingHandler`s) with `AllowAutoRedirect = false`; anything that follows redirects, or cannot be checked, is refused with `ArgumentException`. (A followed 307 would replay the token request, secret included, at a host the server chose.) - Tokens, codes and secrets are never logged, never in a URL and are scrubbed from exception text and `ToString()`. - Only `Bearer` tokens are accepted; DPoP and MAC tokens are refused. ## Token stores `ITokenStore` is a small key/value contract. `MemoryTokenStore` is the default. `FileTokenStore` writes one file with mode 0600 in a 0700 directory (the user profile's ACL on Windows), replaces it atomically, and optionally passes the bytes through an `ITokenProtector` the host supplies; there is no keychain package. An unreadable file reads as empty, so the user signs in again, but it is never overwritten: a file the protector cannot decrypt, one over 4 MiB, or (on Unix) one writable by its group or by others makes `SetAsync` and `RemoveAsync` throw `IOException`, because the key may be back tomorrow or someone else may have written the file. Delete the file to start over. File ownership is not checked (it needs native calls); temporary files left by a crash are swept on the next write. Keys are `token:`, `client:` and `bound:`. ## Loopback receiver `LoopbackRedirectReceiver` runs the library's HTTP/1.1 server on `127.0.0.1` (a port the system picks unless given), accepts only `GET` on its callback path with a loopback `Host` and the `state` of the authorization request in progress (compared in constant time; any other request, such as one a web page fires at the port, gets a 404 and the wait goes on), and returns the redirect URL to `OAuthClient`. Register `RedirectUri` with the server, or list it in the metadata document. Waiting is bounded by `Timeout` (5 minutes). ## Errors `AuthorizationException` (root namespace) is what `ContextClient` throws for a `401` or `403`, at connect or mid-session, with the status and the parsed `Challenges`. `OAuthException` reports a failed flow (state, `iss`, metadata, registration, token endpoint, timeout) with the server's error code; its text never holds a secret. ## From the command line `onemodelcontext login ` signs in to an http(s) server and `onemodelcontext logout ` forgets the tokens. - **Registration:** `--client-id https://…` names a Client ID Metadata Document; any other `--client-id` is a pre-registered client. A client with a secret needs `--issuer URL`, the authorization server that issued it, and the secret is given as `--client-secret env:NAME`, `-` (first line of standard input) or `@file`, or in the `ONEMODELCONTEXT_CLIENT_SECRET` variable; a literal on the command line shows in the process list. Without `--client-id` the client tries the server's dynamic registration. - **Browser:** the authorization address is printed to standard error and opened with the system opener (`xdg-open`, `open`, `explorer.exe`); `--no-browser` only prints it. The redirect returns to a loopback receiver, on a free port unless `--redirect-port` fixes it (a pre-registered client needs a registered redirect URI, so fix the port for it). - **Tokens:** kept in the per-user `onemodelcontext/tokens.json` (`--token-store FILE` to choose), a `FileTokenStore`: mode 0600 on Unix, in a 0700 directory. Running `login` again replaces them once the new sign-in has succeeded (until then the old tokens stay), which is how a session gains more scope. - **Other commands** (`inspect`, `call`, `read`, `prompt`, `proxy` with an http(s) upstream) send the stored access token and refresh it when it expires. They never open a browser. When the server still refuses (HTTP 401, or a 403 that asks for more scope), the command exits with code 4 and prints the status, the challenge's error and scope, and a hint to run `login`. `--bearer` overrides stored tokens. Tokens are never printed. # Authorization server A .NET application can be the OAuth 2.1 authorization server for its own MCP endpoint, so it can act as a connector for hosted clients without a third-party identity provider. `AuthorizationServer` implements the protocol; **you** keep the users. Signing a user in and asking for consent is a callback into your application, and the server never renders a login page itself. ```csharp var server = new AuthorizationServer(new AuthorizationServerOptions { Issuer = new Uri("https://example.com"), Resources = ["https://example.com/mcp"], // what tokens may be issued for (RFC 8707) SigningKeys = [JsonWebSigningKey.FromECDsa(ecdsa, "2026-11")], ScopesSupported = ["files:read", "files:write"], Store = new FileAuthorizationStore("/var/lib/app/oauth.json"), AuthorizeUser = (request, ct) => ValueTask.FromResult( request.Exchange.User is { } user // the host's own sign-in ? AuthorizationDecision.Approve(user.Identity!.Name!, request.RequestedScopes) // after your consent screen : AuthorizationDecision.Redirect(new Uri("https://example.com/login?return=" + Uri.EscapeDataString(request.ResumeUrl.AbsoluteUri)))), }); // ASP.NET Core: app.MapAuthorizationServer(server); // Any other host: hand it the requests whose path it owns. if (server.IsServerPath(exchange.Request.Path)) await server.HandleAsync(exchange); // The MCP endpoint in the same process, refusing revoked tokens too: var resourceServer = new ResourceServer(new ResourceServerOptions(new Uri("https://example.com/mcp"), server.CreateTokenValidator()) { AuthorizationServers = [server.Issuer] }); ``` The access tokens are JWTs in the RFC 9068 shape, so a resource server elsewhere validates them with the unchanged `JwtTokenValidator` over `server.JwksUri`. ## Endpoints | Path (below the issuer's path) | What | |---|---| | `/.well-known/oauth-authorization-server`, `/.well-known/openid-configuration` (and the issuer-path forms) | RFC 8414 metadata: `code_challenge_methods_supported` `["S256"]`, `authorization_response_iss_parameter_supported` `true`, `client_id_metadata_document_supported`, the grant types and client authentication methods, `registration_endpoint` when enabled, `authorization_grant_profiles_supported` when Enterprise-Managed Authorization is on | | `/authorize` | GET (and POST). PKCE, exact redirect URI, `resource`, then your callback | | `/token` | `authorization_code`, `refresh_token`, `client_credentials`, and `urn:ietf:params:oauth:grant-type:jwt-bearer` when enabled | | `/revoke` | RFC 7009 for refresh tokens (revokes the family) and access tokens | | `/jwks` | The public keys, including retired keys until the last token they signed has expired | | `/register` | RFC 7591, off by default (`DynamicClientRegistration`, `[Obsolete]` `OMC0004`) | Paths are options. `server.Paths` lists everything served; `IsServerPath` tests one request path. With an issuer without a path the metadata is at the origin, which is where clients of the 2025-03-26 revision look; with an issuer that has a path, set `ServeMetadataAtOrigin` to serve it there too. Those clients send no `resource`: `AllowMissingResource` binds their tokens to the one configured resource. ## What is enforced - **PKCE** with S256 is required on every authorization request; `plain` is not offered. The verifier is checked in constant time. - **Redirect URIs** are compared character for character against the registration. The one exception is an `http` loopback redirect (`127.0.0.1`, `[::1]`, `localhost`), whose port may differ (RFC 8252 section 7.3). A request with an unknown client or an unregistered redirect URI is answered with a plain 400 and never redirected. A redirect URI must be printable ASCII with no whitespace, control character or backslash and no userinfo (`user@`), at registration and in each request; one that cannot be put safely into a `Location` header is an error page, never a bare 302. For a loopback redirect the host and path must match the registration and only the port may differ. - **Scopes** are checked as RFC 6749 section 3.3 scope tokens (no quote, backslash, control or non-ASCII character) at every endpoint. Which scopes may be granted is limited by `ScopesSupported` and the client's own `Scopes`. **Set one of them.** With neither, an interactive (authorization code) request is granted any well-formed scope string, which is unsafe, and the server refuses to guess for the grants where a client acts for itself: Client Credentials is refused with `invalid_scope` unless an allow-list applies to the client, and a client registered through the registration endpoint gets no scopes at all unless `ScopesSupported` is set. - **`iss`** (RFC 9207) is on every redirect, success and error, next to `state`. - **`resource`** is required (one value, one of `Resources`) and becomes the token's `aud`. A different resource at the token endpoint is `invalid_target`. - **Codes** live 60 seconds, work once, and a second use revokes the tokens the first produced. A spent code is remembered until its refresh family expires (`MaxRefreshFamilyLifetime`), so a replay long after the 60 seconds still revokes the family. - **Refresh tokens** rotate on every use. Showing a spent one again revokes its whole family: the refresh tokens and the access tokens (they carry the family in `sid`; `CreateTokenValidator` checks it). A family also has an absolute lifetime (90 days). - **Client secrets** are stored as salted HMAC-SHA-256 hashes (`ClientSecret.Hash`) and compared in constant time. They are meant to be the 256 random bits `ClientSecret.Generate` makes, so a fast hash is enough, and an unauthenticated caller cannot make the server burn CPU on a slow one. Hashes written by earlier versions (PBKDF2) still verify. Codes and refresh tokens are stored as SHA-256 hashes. Nothing secret appears in a log, a diagnostic event, an exception or an error response. - **Request limits:** bodies are read to `MaxRequestBodyBytes` (32 KiB) at most, repeated parameters are refused, and a handler fault becomes a bare 500. - **Cache headers:** token, registration and error responses are `no-store`. ## Login and consent `AuthorizeUser` receives who is asking (`ClientId`, `ClientName`, whether the client is a metadata-document client, `RedirectUri`), what for (`RequestedScopes`, `Resource`) and the browser's request (`Exchange`, so you can read your session cookie). It returns: - `AuthorizationDecision.Approve(subject, scopes, claims)` — the user is signed in and consents. `scopes` may only narrow the request. `claims` become string claims of the access token (not the registered names). - `Deny(description)` — the client is sent back with `access_denied`. - `Interact(respond)` or `Redirect(url)` — the user must sign in first. Your response is sent untouched. When the user has signed in, send the browser to `request.ResumeUrl`: it carries the pending request in an HMAC-signed, expiring token (`ResumeLifetime`, 15 minutes), so it cannot be edited or reused for another client, redirect URI or scope, and the server keeps no state while the user is away. Your callback is called again for it and sees the same context. Set `ResumeKey` (32 bytes or more) when several instances serve the same users; otherwise the key is random per process. The `ClientName` is whatever the client wrote. Show the `ClientId` and the host of the `RedirectUri` beside it. The callback may redirect to an upstream OpenID Connect provider; the result is still your decision to approve. ## Clients - **Pre-registered:** `store.SaveClientAsync(new RegisteredClient { ... })`. Public clients use `none`; confidential ones `client_secret_basic` or `client_secret_post` (a secret hash from `ClientSecret.Hash`; `ClientSecret.Generate` makes a secret) or `private_key_jwt` with `JwksJson` or `JwksUri`. `GrantTypes` lists what the client may use (`client_credentials` and the JWT-bearer grant are never implied) and `Scopes` limits what it may be granted (Client Credentials needs `Scopes` or the server's `ScopesSupported`). - **Client ID Metadata Documents:** a `client_id` that is an HTTPS URL is fetched (draft-ietf-oauth-client-id-metadata-document). The document must name itself in `client_id`, list valid redirect URIs, use `none` or `private_key_jwt`, and may not carry a secret. A pre-registered client of the same name wins. Fetching is guarded (below). Documents are cached per `Cache-Control` (default 5 minutes, at most an hour; `no-store` is honored), failures for 30 seconds, at most 1000 entries (at the limit, expired entries go first, then the one that expires soonest, so junk `client_id`s cannot switch caching off; a URL that can never be fetched takes no entry), at most 8 fetches in flight and 2 per host (`MaxFetchesPerHost`; more are refused at once rather than queued). After a fetch that timed out or could not connect, the host is left alone for 10 seconds (`HostFailureBackoff`); an error status, a wrong content type or an oversized body only fails that URL (for 30 seconds), because a caller can choose such URLs on any host. **Rate-limit `/authorize` and `/token` in the host:** every request with a new metadata-document `client_id` can cost a fetch, and the limits above bound the damage, not the volume. - **Dynamic registration** is `[Obsolete]` since the 2026-07-28 revision and off by default. Turn it on only when clients you must serve still need it, and rate-limit the endpoint in the host: it accepts anonymous callers and stores what they send. ### The fetch guard Every outbound fetch for a remote party (metadata documents, `jwks_uri`) goes through `GuardedHttpFetcher`, which you can use for your own such fetches. It requires HTTPS; resolves the name **once**, in the connect step, checks every returned address against `AddressPolicy` and connects to one of those addresses, so a name that answers differently a second time cannot redirect the connection; refuses the whole host when any address is not public; never follows redirects, uses no proxy, cookies or credentials; reads at most `MaxBytes` (16 KiB) within `Timeout` (5 seconds); and requires status 200 and a JSON content type for documents. `AddressPolicy.IsPublic` refuses the RFC 6890 special-use ranges (loopback, private, link-local, carrier-grade NAT, documentation, benchmarking, multicast, reserved), IPv4-mapped IPv6 forms judged as the IPv4 address, 6to4 judged by the embedded address, NAT64, Teredo, unique-local, link-local and site-local IPv6, and everything outside `2000::/3`. Odd spellings (`2130706433`, `0x7f.1`, `[::ffff:7f00:1]`) are parsed by the platform before the policy sees them. To fetch a document from a host you run (a test fixture), add it explicitly: `new AddressPolicy().Allow("127.0.0.1")`, and `AllowHttpToAllowListedAddresses` if it has no certificate. ## Tokens and keys Access tokens live 10 minutes by default (`AccessTokenLifetime`) and carry `iss`, `sub`, `aud` (the resource), `client_id`, `scope`, `iat`, `nbf`, `exp`, `jti`, and `sid` (the refresh family) next to the host's extra claims. `typ` is `at+jwt`; `CreateTokenValidator()` requires it (RFC 9068 section 4), and `JwtTokenValidator` does when `RequireAccessTokenType` is set. **Revocation is honored only by `CreateTokenValidator()`**, which asks the store. A plain `JwtTokenValidator` over the key set (the usual setup for a resource server in another process) accepts a revoked access token until its `exp`, which is why access tokens are short. A resource server that validates tokens from an external issuer should set `RequireAccessTokenType` on its `JwtTokenValidator` when that issuer emits `typ: at+jwt`, so that another JWT of the same issuer (an ID token) is not accepted as an access token. Keep `AccessTokenLifetime` short, or run the resource server in the same process and use `CreateTokenValidator()`. Signing keys come from you: `JsonWebSigningKey.FromRsa` (RS or PS algorithms, at least 2048 bits) or `FromECDsa` (P-256, P-384, P-521). The first key that is not retired signs. **Rotating:** put the new key first in `SigningKeys` and set `RetiredAt` on the old one (restart or rebuild the server with the new list). The old key stays in `/jwks` for `AccessTokenLifetime` plus a minute after `RetiredAt`, so every token it signed stays valid until it expires. Resource servers that cache the key set refetch on an unknown `kid`. ## Enterprise-Managed Authorization and Client Credentials `EnterpriseAuthorization` accepts an Identity Assertion JWT Authorization Grant (draft-ietf-oauth-identity-assertion-authz-grant-04, the Enterprise-Managed Authorization extension) through the JWT-bearer grant. The server checks, in this order: the `typ` is `oauth-id-jag+jwt`; the algorithm is RS, PS or ES; `iss` is one of the trusted identity providers; the signature verifies against that provider's keys; `aud` is exactly this server's issuer; `client_id` is the authenticated client; `exp`, `iat` and `nbf` are in range and the lifetime is at most 10 minutes; `sub` and `jti` are present; then `resource` and `scope` are matched against the request and local policy; and last the `jti` is recorded, so an assertion is accepted once, and a request refused for its scope does not spend it. The token is audience-restricted to the resolved resource. No refresh token is issued. The client must authenticate (`RequireConfidentialClient` defaults to true). The token's subject is the provider's `sub` prefixed with its issuer and `#` (`https://idp.example#alice`), because a subject is only unique within its issuer; `ResolveSubject` maps the provider's user to your own subject instead (or refuses). EdDSA-signed assertions are currently refused; OneCrypto-based support is planned. For resource-server tokens from EdDSA issuers, use a custom `ITokenValidator` or `DelegateTokenValidator`. Client Credentials needs a pre-registered, authenticated client that lists `client_credentials` in its grant types and a scope allow-list (`Scopes` on the client or `ScopesSupported`). `client_secret_basic`, `client_secret_post` and `private_key_jwt` work; an assertion is single-use (`jti`), must name this issuer or the token endpoint as `aud`, and must expire within ten minutes. ## Stores `IAuthorizationStore` holds clients, codes, refresh tokens, revocations and used assertion ids. Each method is one atomic step with no shared object identity, so a database implementation needs a row or a unique key per record. `MemoryAuthorizationStore` (one process, lost on restart) and `FileAuthorizationStore` (one JSON file, written atomically, owner-only on Unix, hashes only; one process) ship in the core. Multi-instance hosts need a database store. `EfAuthorizationStore` compares `client_id` exactly after the lookup; pass a binary collation for the key column to `ApplyAuthorizationStore(clientIdCollation: ...)` (for example `Latin1_General_100_BIN2` on SQL Server, `BINARY` on SQLite, `C` on PostgreSQL) so that ids differing only in case cannot collide as keys either. ## Not provided Login pages, consent screens, CORS headers for browser clients, DPoP, mTLS, token introspection, rate limiting (put it in front of the endpoints), and `id_token`s (this is not an OpenID Connect provider, though its metadata is also served at the OpenID discovery path). # Extensions An extension adds methods, notifications, `_meta` keys or capability settings to the Model Context Protocol (MCP) without a new core revision. The mechanism is public and the official extensions use it. This page covers writing one and what MCP Apps adds. Source: the MCP extensions overview and the Apps specification (revision 2026-01-26). ## Writing and registering an extension An extension is off until the host opts in. ```csharp var server = new ContextServer("demo", "1.0.0"); server.Extensions.Add( new ExtensionRegistration("com.example/echo", settings: default, revisions: null) .Request("com.example/echo/say", (parameters, context) => ValueTask.FromResult(parameters)) .Notification("com.example/echo/ping", (parameters, peer, ct) => ValueTask.CompletedTask)); ``` - **Identifier:** `{vendor-prefix}/{name}`, the prefix a reversed domain name you own (`ExtensionId.IsValid`). Prefixes the protocol reserves (`io.modelcontextprotocol`, `*.mcp`, `*.modelcontextprotocol`) are refused for third parties. - **Settings:** a JSON object advertised to the peer. `ExtensionRegistration.Create(id, settings, typeInfo)` serializes a record through source-generated metadata (AOT-safe). - **Revisions:** the core revisions the extension works on; `null` means all. On other revisions it is not advertised and its methods answer method-not-found. - **`MetaKeys`:** the `_meta` keys the extension owns, documented for peers; unknown `_meta` always survives decode and encode. - **Registration is frozen** when added to the registry; change the registration before `Extensions.Add`. ### Negotiation - **2026-07-28:** the client puts `extensions` in `_meta` `clientCapabilities` on every request; the server lists its extensions in `server/discover` capabilities. - **2025-11-25 and earlier:** `capabilities.extensions` in `initialize`, both ways. - A handler asks the request: `context.SupportsExtension(id)`, `context.TryGetExtensionSettings(id, out settings)` or `context.GetExtensionSettings(id, typeInfo)`. `ContextClient.ServerExtensions` and `ServerSupportsExtension(id)` show what the server offers after connect, on either era. - Extensions a peer declares that we do not know are kept and ignored. ### Methods and notifications `Request(method, handler)` and `Notification(method, handler)` dispatch on both eras. Extension methods answer **only peers that declared the extension**; others get method-not-found (-32601). The client calls them with `CallExtensionAsync(method, params)` and `SendExtensionNotificationAsync`; a server notifies with `RequestContext.NotifyExtensionAsync`. A client registers notification handlers on `ClientExtension.Notification`. Server-to-client extension *requests* and MRTR input requests inside extension calls are not supported. ### Degradation and required mode By default a peer without the extension just gets the plain behavior. `Required = true` on the registration rejects requests from peers that lack it: **-32021** `MissingRequiredClientCapability` on 2026-07-28; on the legacy revisions, which define no such code, the existing invalid-params mapping (**-32602**) with a message naming the extension. A `ClientExtension` with `Required = true` makes `ConnectAsync` throw `ExtensionRequiredException` when the server lacks it. ### Client side `ContextClientOptions.Extensions` takes `ClientExtension` entries (id, settings); only those are declared. ## MCP Apps (`io.modelcontextprotocol/ui`, stable) The server declares `ui://` HTML resources and tools that point at them; a host that supports Apps renders the view. OneModelContext renders nothing: it carries the resource, its metadata and a typed model of the view-to-host messages (`UiMessages`, `UiMethods`) so a host can bridge them. ```csharp server.UseApps(); // opt in server.AddUiResource("ui://weather/forecast", "forecast_view", html, new UiResourceMeta { PrefersBorder = true }); server.AddAppTool(definition, "ui://weather/forecast", handler, visibility: ["model", "app"]); ``` From the generator: `[Tool(UiResource = "ui://weather/forecast", UiVisibility = new[] { "model", "app" })]` (a bad URI or visibility is compile error OMCG0012). Register the resource with `AddUiResource`. Behavior: - A peer supports Apps only if its extension settings list `text/html;profile=mcp-app` in `mimeTypes`. For any other peer `_meta.ui` is removed from tools, **app-only tools are hidden and cannot be called** (unknown tool, -32602), and the handler can return a text fallback (`context.SupportsApps()`). - Because the tool list now depends on the caller, `tools/list` results are `cacheScope: private` whenever a tool adapter is registered. - The flat `ui/resourceUri` key is read from other servers but never written. - A `resourceUri` that is not `ui://` means no UI. - `UiContentSecurityPolicy` builds the CSP and permission metadata and refuses values that could break out of the header. The secure default allows no external origins. - UI resources appear in `resources/list`; read them with `client.ReadUiResourceAsync(uri)`, and `tool.GetUi()` gives a tool's UI metadata. - `ui/initialize` is modeled with `clientInfo` and `appCapabilities`; fields the types do not know are preserved. See `samples/AppsServer`. The suite version pinned for this repository has no Apps or extension-negotiation scenarios; the behavior is covered by unit tests on both eras. ## Experimental extensions Tasks, Skills and Server Cards are Beta extensions. Their types carry `[Experimental]`, so using one is a compile error until the project opts in with `$(NoWarn);OMCX0001` (Tasks), `OMCX0002` (Skills) or `OMCX0003` (Server Cards). They are off until the host calls `UseTasks`, `UseSkills` or `UseServerCard`. A Beta extension leaves `[Experimental]` when it reaches Stable or Core; a change of its `revision` ships in a minor release. All three live in core. ### Tasks (`io.modelcontextprotocol/tasks`) A tool can answer `tools/call` with a task handle instead of its result; the client polls `tasks/get`, answers questions with `tasks/update` and stops it with `tasks/cancel`. There is no `tasks/list`. The extension is specified against 2026-07-28, so earlier revisions do not see it and the tool runs to completion inside the call. ```csharp server.UseTasks(new TaskServerOptions { Store = new FileTaskStore("tasks"), Ttl = TimeSpan.FromHours(1) }); server.AddTaskTool(definition, async (arguments, task) => { await task.SetStatusMessageAsync("indexing"); // shown to pollers var answer = await task.ElicitAsync("replace", "Replace the old index?", schema); // input_required until the client answers return CallToolResult.FromText(answer.Action == "accept" ? "done" : "kept"); }, new TaskToolOptions { RequireTasks = false }); ``` ```csharp // Client var outcome = await client.CallToolAsTaskAsync("reindex"); // needs TasksExtension.Client() in ContextClientOptions.Extensions var final = outcome.Task is { } task ? await task.WaitAsync() : null; // answers input requests with the client's handlers ``` - A caller that did not declare the extension gets the result inside the call, or `-32021` for a tool added with `RequireTasks`. A plain `CallToolAsync` on a client that declared the extension follows a task to its end. - Input needed before the work starts goes in `TaskToolOptions.Prepare` (it can elicit, as any tool can); the task exists only after it returns. Questions during the work use the `TaskContext`. A task asks only for capabilities the creating request declared. - Tasks belong to the principal that created them (issuer and subject; anonymous callers share one owner). Another principal's task is `-32602` not found, the same as an expired or unknown one. - **Stores.** `ITaskStore` is a small compare-and-swap contract (`CreateAsync`, `GetAsync`, `UpdateAsync` on `Version`, `DeleteAsync`, `DeleteExpiredAsync`) that a relational store can implement. `MemoryTaskStore` (the default) holds tasks in one process: use it for a single instance only. `FileTaskStore` keeps them in a directory with a lock file and atomic renames: one machine, any number of processes there, and tasks survive a restart. - **A task whose worker is gone stays `working` until its TTL.** The store has no worker heartbeat: if the process that ran the work dies, pollers keep seeing `working` until the task expires (default one hour, `TaskServerOptions.Ttl`). Clients should put a limit on how long they wait. - **Limits and defaults** (all on `TaskServerOptions`): TTL one hour, poll interval one second, 256 running tasks per instance, 16 outstanding input requests per task, 15 minutes for the client to answer. - Task ids are bearer-like handles: they are never put in audit records, diagnostics or telemetry (the `Mcp-Name` header carries them, so keep access logs of the HTTP endpoint private). - `notifications/tasks` over `subscriptions/listen` is not implemented (the suite skips that scenario as well); clients poll. - The 2025-11-25 experimental core `tasks/*` methods are not implemented. ### Skills over MCP (`io.modelcontextprotocol/skills`) A server publishes [Agent Skills](https://agentskills.io/specification) as resources under `skill://`, and describes them with `skills/list` and `skills/get`; `resources/directory/read` lists folders. The extension is specified against 2026-07-28; earlier revisions see the skill files as ordinary resources. ```csharp server.UseSkills(); // directoryRead on, 100 per page server.AddSkill(SkillDefinition.FromDirectory("skills/pdf-processing")); server.AddSkill(SkillDefinition.FromDirectory("skills/refunds", pathPrefix: "acme/billing")); // skill://acme/billing/refunds/... ``` - `SkillDefinition` reads the files once, so the digests published are those of the bytes served. It refuses a name that breaks the Agent Skills rules (1 to 64 of `a-z 0-9 -`, no leading, trailing or doubled hyphen), a skill path whose last segment is not the frontmatter name, a path segment outside `A-Z a-z 0-9 . _ ~ - + @ = ,`, and more than 512 files or 16 MiB. Symbolic links are skipped. - `SKILL.md` is served as `text/markdown` with the skill's name and description; text files go as text and others as base64, so the digest (SHA-256 over the raw bytes, `sha256:` and lowercase hex) is the same either way. Nested skills share files with the enclosing skill. - The frontmatter is read by a small YAML reader that supports mappings, sequences, quoted and plain scalars, block scalars, short flow collections and comments. Anchors, tags, complex keys and merge keys are refused with an error, not guessed. The reader is bounded in size (64 KiB) and depth, and runs under a fuzz target. - The client finds the extension in the server's declaration and sends none of its own, so the server answers every caller on 2026-07-28 (`SkillsExtension.Client()` is optional). - **Client.** `ListSkillsAsync` and `GetSkillAsync` return `SkillEntry` values (frontmatter, files with digest and size, or dynamic). `LoadSkillAsync` reads `SKILL.md` and checks size, digest and that its frontmatter equals the entry's; `ReadSkillFileAsync` reads a file the entry lists and checks it. A mismatch, a file the entry does not list, an entry without `resources`, a skill over the limits throw `SkillVerificationException` (`Failure` says which) and the content is not returned. Recover by fetching the entry again with `GetSkillAsync`; a changed file set voids any approval your host stored. Nothing is fetched ahead of need. - A digest match shows that content and entry agree, not that either is trustworthy: both come from the same server. Treat skill content as untrusted model input, tag it with the server it came from, and do not honor `allowed-tools` from it without the user's approval. - Not built: skills with generated ("dynamic") content on the server side; clients read them but get no integrity check. ### Server Cards A Server Card is a small public JSON document that describes one remote server before anyone connects (`application/mcp-server-card+json`, schema `v1`). It does not list tools, resources or prompts. ```csharp server.UseServerCard(server.CreateServerCard("com.example/weather", "Forecasts for any city.", "https://example.com/mcp")); ``` - **Publishing.** The HTTP core, the built-in listener and `MapModelContext` answer `GET /server-card` (and the CORS preflight) with the document, `Access-Control-Allow-Origin: *`, `Cache-Control: public, max-age=3600` and an `ETag`; a matching `If-None-Match` gets 304 and an `Accept` that excludes the type gets 406. The card is answered before the `Origin` check and before authentication because browsers must read it; the `Host` check still applies. A server without a card answers 404. A host that routes by path (the listener) must pass `/server-card` to the endpoint; `ModelContext.ServeHttpAsync` and `MapModelContext` do. Put nothing private in a card. Serve it over HTTPS in production. - `CreateServerCard` fills `version` from `serverInfo` and the remote's `supportedProtocolVersions` from the server. `UseServerCard` refuses a card that breaks the schema (name shape, lengths, version ranges, remote type or URL). - **Fetching.** `ServerCardClient.FetchAsync(url, previousETag)` and `DiscoverAsync(origin)` (the AI Catalog at `/.well-known/ai-catalog.json`, entries of the card type with `url` or inline `data`, at most 100) go through `GuardedHttpFetcher`: public addresses only unless the caller's fetcher allow-lists more, no redirects, no cookies or credentials, a 256 KiB body cap and a timeout. Failures are `ServerCardException`. - A card is advisory. Never use it for security or access decisions, and prefer what the live connection reports when the two differ. The suite pinned for this repository has Tasks and Skills scenarios (run with the scenario list's `extension` revision) and none for Server Cards, which unit tests cover over the listener and ASP.NET Core. # Adapters: `.Hosting` and `.Extensions.AI` Two small packages, each referencing the core package and the one Microsoft package it adapts. ## `RecursiveAI.OneModelContext.Hosting` References `Microsoft.Extensions.Hosting.Abstractions` only. The abstractions are enough: the hosted services derive from `BackgroundService`, tool classes are created with `ActivatorUtilities`, and the logging bridge uses `LoggerMessage`. The generic host itself (`Microsoft.Extensions.Hosting`) is the app's choice. ```csharp builder.Services.AddSingleton(); builder.Services.AddContextServer().UseStdio(); // or .UseListener(port: 3001), or .UseTransport(sp => ...) ``` - **Tool classes** (`AddTools()`) are generated partial classes. The container supplies the constructor arguments, or the class's own registration is used when it has one. There is one instance for the life of the server, so a constructor takes singletons. `AddTools(factory)` creates the class without reflection. `Configure((server, services) => ...)` is for dynamic registration. - **Scoped services** are not constructor parameters. Every tool, resource and prompt call runs in its own DI scope, and the handler reaches it with `context.GetRequiredService()` (or `GetRequestServices()`); the scope is disposed when the call ends. The scope travels as an ambient value through the call's async flow, because generated invokers hold their class instance. On the modern era a handler that asks for input is re-run from the start on retry (see `multi-round-trip.md`), so it gets a new scope each attempt. - **Lifetimes.** `UseStdio` and `UseTransport` serve one connection and ask the host to stop when it closes (stdin closed). `UseListener` runs the built-in HTTP listener on loopback with its default limits; `ContextListener.Started` completes with the bound address. Both stop with the host. Standard output carries the protocol in a stdio host, so send the console logger to standard error. - **Logging.** Diagnostics events become `ILogger` entries in the category `RecursiveAI.OneModelContext`. Only the event text and an exception's type and message are logged (never the exception object or a stack), after bearer tokens, JWTs and token-like parameters (`access_token=`, `code=`, ...) are replaced by `[redacted]`. - **Clients.** `AddContextClient(...)` registers a `ContextClientProvider`; `await provider.GetClientAsync()` connects on first use (the era is negotiated as usual) and reconnects after a closed connection. - **Clock.** A `TimeProvider` registered in the container becomes the server's and client's clock. ## `RecursiveAI.OneModelContext.Extensions.AI` References `Microsoft.Extensions.AI.Abstractions` only. - `client.ToAIFunctionsAsync()` lists the server's tools as `AIFunction`s with the tool's name, description and input schema. The result of an invocation is the structured content when there is some, the text when every block is text, otherwise a list of `AIContent` (text, `DataContent` for images, audio and blobs, `UriContent` for resource links). A tool failure comes back as text starting with "Error: " so the model can react. - `server.AddAIFunction(f)` / `AddAIFunctions(tools)` serve functions as tools. The function's arguments are `JsonElement`s. An exception in the function is a tool failure with the fixed text "The tool failed.". - `ChatClientSampling.CreateHandler(chatClient)` answers `sampling/createMessage` for `ContextClientOptions.OnSample`. Sampling is deprecated in 2026-07-28 (`OMC0002`); the adapter suppresses the warning in its own implementation and your use of `OnSample` raises it at your call site. The server's `tools` and `toolChoice` are not forwarded. - AOT: the adapter is trim- and AOT-annotated. Arguments from code (not from a model) are serialized with the function's `JsonSerializerOptions`, so types outside MEAI's defaults need a source-generated context on that function. Model Context Protocol® is a registered trademark of LF Projects, LLC. OneModelContext is an independent project and is not affiliated with or endorsed by LF Projects, LLC or the Model Context Protocol project. # Entity Framework Core stores `RecursiveAI.OneModelContext.EntityFrameworkCore` keeps the authorization server's state and a client's tokens in your own database, so several instances of an application can share them. It references the core package and Entity Framework Core (the base package and `Microsoft.EntityFrameworkCore.Relational`) and nothing else; your provider package is yours to choose. | Store | Implements | Tables | |---|---|---| | `EfAuthorizationStore` | `IAuthorizationStore` | `…Clients`, `…AuthorizationCodes`, `…RefreshTokens`, `…Revocations`, `…SeenAssertions` | | `EfTokenStore` | `ITokenStore` | `…TokenRecords` | | `EfTaskStore` | `ITaskStore` (Tasks extension, experimental) | `…Tasks` | Table names start with `ModelContext` (change it with the `tablePrefix` argument; `schema` sets the database schema). ## Setting up ```csharp public sealed class AppDbContext(DbContextOptions options) : DbContext(options) { protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.ApplyContextStores(); // or ApplyAuthorizationStore() / ApplyTokenStore() / ApplyTaskStore() } services.AddDbContextFactory(o => o.UseNpgsql(connectionString)); var store = new EfAuthorizationStore(factory); // one per process; it opens a context per call var server = new AuthorizationServer(new AuthorizationServerOptions { /* … */ Store = store }); var tokens = new EfTokenStore(factory, protector); // protector: an optional ITokenProtector ``` Create the tables with `Database.EnsureCreated()` for a database that only this model uses, or add a migration (`dotnet ef migrations add AddContextStores`) to a database you already migrate. The row types are internal, so the migration is generated from the model your context builds; no hand-written SQL is needed. ## What holds across instances - **A code redeems once.** Redeeming is one statement, `UPDATE … SET redeemed = 1 WHERE code_hash = @h AND redeemed = 0`, and the number of rows it changed says who won. Of two instances racing on one code, one gets `Redeemed` and the other `AlreadyRedeemed`, which makes the authorization server revoke the family exactly as it does with the memory store. - **A refresh token rotates once**, the same way, so a rotated token used from another instance is recognized as reuse. - **An assertion identifier is accepted once** (`client_assertion` and ID-JAG `jti`): the first insert wins on the primary key; a record that has expired is taken over by one conditional update. - **Revocations keep the later expiry**; saves of the same client, code or token overwrite. - Everything else is the memory store's behavior, including that an expired code or token is spent when it is presented. The authorization server itself keeps no state between requests beyond the store, but both instances need the same signing keys and the same `ResumeKey`. ## Tasks across instances `EfTaskStore` is the store to give `TaskServerOptions.Store` when more than one server instance serves the Tasks extension. `UpdateAsync` is the compare-and-swap the interface defines: `UPDATE … SET Version = Version + 1 WHERE TaskId = @id AND Version = @version`, and the affected-row count says whether the change applied. A task created through one instance can be polled, answered and canceled through another; the instance running the work sees the answer and the cancel request when it next reads the row. The class is marked `[Experimental("OMCX0001")]` because the interface it implements is: using it needs the same opt-in as the Tasks extension (`#pragma warning disable OMCX0001`, or `NoWarn`). The adapter itself builds without suppressing anything. The capacity limit (default 10,000) counts rows before the insert, so with several instances it can be overshot by about the number of concurrent creators. Task ids are at most 128 characters. Results and errors are stored as the JSON text the service wrote; protect the table like any other table of user data. ## At rest - Codes, refresh tokens and client secrets reach the store as SHA-256 hashes; the tables hold the hashes. - Revocation and assertion keys can be long and are chosen by clients, so their tables hold a SHA-256 hash of the key, not the key. - `EfTokenStore` keeps access tokens, refresh tokens and client secrets inside one value per record. With an `ITokenProtector` that value is sealed before it is written; without one it is plain JSON, so give the store a protector, or encrypt the database, wherever someone other than the application can read the tables. A value the protector cannot open reads as absent and is never put in an error. - Client ids longer than 450 characters are refused on save (a portable key limit). ## Limits - The stores need `ExecuteUpdate`/`ExecuteDelete`, which every relational provider supports. The in-memory provider does not. - Instants are stored as UTC ticks in `bigint` columns, so comparisons run in SQL on every provider. - Entity Framework Core builds its model by reflection and is not trim- or AOT-safe, so this package is not marked trimmable or AOT compatible. Building it with the trim analyzer on reports IL2067 and IL2091 on the generic `DbSet` helpers. Servers that publish with NativeAOT use the memory or file stores, or their own `IAuthorizationStore`. - Purging expired rows is the server's job: it calls `PurgeAsync` periodically. ## OneLite stores (a single host) `RecursiveAI.OneModelContext.OneLite` keeps the same three stores in one embedded OneLite database file, for an application that runs as a single host and wants durable state without a database server or Entity Framework. It references the core package and `RecursiveAI.OneLite` and nothing else, and it is trim and NativeAOT safe. | Store | Implements | |---|---| | `OneLiteAuthorizationStore` | `IAuthorizationStore` | | `OneLiteTokenStore` | `ITokenStore` | | `OneLiteTaskStore` | `ITaskStore` (Tasks extension, experimental, `OMCX0001`) | ```csharp await using var auth = await OneLiteAuthorizationStore.OpenAsync("state/mcp.db"); // creates the file and the tables var server = new AuthorizationServer(new AuthorizationServerOptions { /* … */ Store = auth }); await using var tokens = await OneLiteTokenStore.OpenAsync("state/mcp.db", protector); // protector: an optional ITokenProtector await using var tasks = await OneLiteTaskStore.OpenAsync("state/mcp.db", capacity: 1000); ``` `OpenAsync(path)` opens its own connection and the store closes it on dispose. `AttachAsync(database)` uses a `Database` you opened, creates the tables in it and leaves it open on dispose. Several stores, in one process or several, can use one file. - **Same behavior as the other stores.** A code redeems once, a refresh token rotates once, an assertion id is accepted once until it expires, revocations keep the later expiry, a spent code is remembered until its family expires, and a task update succeeds only at the version the caller read. Redeeming and rotating read and mark in one write transaction; assertions, revocations and task updates are single statements. Codes, refresh tokens and client secrets are hashes, and assertion and revocation keys are stored as a SHA-256 hash. - **Processes.** Correctness rests on OneLite's write-ahead-log file locking, where a write transaction holds the file's one write lock, so two processes (or two connections) racing on one code or one task version get one winner. A writer that finds the file locked waits up to OneLite's busy timeout (5 seconds by default) and then fails with `OneLiteException`. All processes must be on one machine and a local disk: do not put the file on a network share. The tests race separate connections to one file; for several machines use the Entity Framework stores. - **One connection, one call at a time.** A store serializes its calls on its connection, which is plenty for sign-ins and task polling. Open a store per component if one is busy. - **Schema.** The package creates its tables (`ModelContext…`, the same names as the Entity Framework stores) and records their version in a table of its own, `ModelContextSchema`, so `PRAGMA user_version` and your own tables in the same file are untouched. Opening a file whose stored version is newer than the package fails with an `InvalidOperationException` that says to update the package. Upgrades from an older version run when the file is opened, in one transaction. - **At rest.** As for the Entity Framework token store: records are sealed by the `ITokenProtector` when one is given, otherwise plain JSON, so protect the file or give the store a protector. The task store holds results as the JSON text the service wrote. - **Capacity.** `OneLiteTaskStore` checks capacity and inserts in one write transaction, so the limit is exact even across processes. - **Purging.** The server calls `PurgeAsync` periodically, as with every store. # Conformance OneModelContext is checked against the protocol's official conformance suite, run as a black box: it is installed from its published package and driven through its command line. Its files are never read or copied into this repository. | | | |---|---| | Suite | `@modelcontextprotocol/conformance` **0.2.0-alpha.12** (npm) | | Licence | MIT (from the package's npm metadata) | | Revisions with scenarios | 2025-11-25 and 2026-07-28 as requirement sets (`--requirements`), plus scenarios that apply to 2025-03-26, 2025-06-18 and extensions | | Not covered by the suite | 2024-11-05 has no scenarios | The newest stable release, 0.1.16, has no 2026-07-28 scenarios, which is why an alpha is pinned. A newer suite does not retroactively change what a released version of this library was checked against. ## How the suite tests - **Servers** are tested over HTTP only: `server --url `. The suite connects as a client; there is no stdio server mode. - **Clients** are tested by running them: `client --command ""`. The suite starts a test server, appends its URL to the command and sets `MCP_CONFORMANCE_SCENARIO` (and `MCP_CONFORMANCE_PROTOCOL_VERSION` when a version is requested). - `--scenario ` runs one scenario; `--requirements ` runs what a revision requires, frozen at its release. ## Running it ```sh export ONEMODELCONTEXT_CONFORMANCE_CACHE=/some/directory/outside/the/repository conformance/run.sh install # once; installs the pinned suite there conformance/run.sh list # the suite's scenarios conformance/run.sh server --url http://127.0.0.1:PORT/mcp --out results --scenario tools-list conformance/run.sh client --command "dotnet run --project ..." --out results --requirements 2026-07-28 conformance/run.sh regression --out results --url http://127.0.0.1:PORT/mcp --command "..." ``` `regression` runs the scenarios named in [scenarios.txt](https://github.com/recursive-ai-oss/onemodelcontext-csharp/blob/d72d78c39c934697857be92dd8514efd4c415a88/conformance/scenarios.txt), which is what continuous integration runs. Each line names the revision whose variant of the scenario runs (`--spec-version`). Servers must bind a loopback address and a free port; read the port back instead of fixing one. ## The conformance server `conformance/Server` is a small program that serves a `ContextServer` full of the tools, resources and prompts the scenarios call. It ships in no package. ```sh dotnet run --project conformance/Server -- --http --port 0 # Streamable HTTP on 127.0.0.1; prints the URL dotnet run --project conformance/Server -- --stdio # newline-delimited JSON-RPC on stdin and stdout ``` The suite has no 2024-11-05 scenarios, and `--requirements` exists only for 2025-11-25 and 2026-07-28; the 2025-03-26 and 2025-06-18 variants run with `--scenario ... --spec-version`, or `--spec-version` alone for every applicable scenario. ## Results (suite 0.2.0-alpha.12, every scenario the suite lists) Server, run by `gate-server.sh` on both hosts (`listener` and `aspnetcore`), with the same result on each: | Revision | Scenarios | Result | |---|---|---| | 2026-07-28 | 35 | all pass | | 2025-11-25 | 34 | all pass | | 2025-06-18 | 27 | all pass | | 2025-03-26 | 1 | all pass | | extensions (Tasks, Skills) | 13 | all pass; the suite itself skips `tasks-status-notifications` | Client, run by `gate-client.sh` with the conformance client: | Revision | Scenarios | Result | |---|---|---| | 2026-07-28 | 33 | all pass | | 2025-11-25 | 19 | all pass | | 2025-06-18 | 5 | all pass | | 2025-03-26 | 2 | all pass | | extensions | 10 | 7 pass; 3 disputed / out of scope (below) | The suite lists no scenarios for 2024-11-05; that revision is covered by the interop harness (`interop/`). ### Disputed / out of scope These scenarios fail because they test authorization extensions this library does not implement. They are listed here rather than hidden, and they are not in the regression list. | Scenario | Why it is out of scope | |---|---| | `auth/dpop` | DPoP for MCP (SEP-1932, RFC 9449) is an Experimental extension (`ext-auth/specification/experimental/`). The extensions overview says the Experimental stage guarantees nothing and the design may change or disappear. This library ships Stable extensions only. | | `auth/dpop-nonce` | Same as `auth/dpop`. | | `auth/wif-jwt-bearer` | Workload Identity Federation (SEP-1933) is a proposal, not yet an extension at any stage. | The Stable authorization extensions (`modelcontextprotocol.io/extensions/auth/overview`) are Client Credentials and Enterprise-Managed Authorization; both are implemented and their client scenarios pass. DPoP will be added in a minor release once it reaches Beta or Stable. ## The authorization server The suite's `authorization` command tests an authorization server. `conformance/AuthorizationServer` is a small host for `AuthorizationServer` that approves every request for one client, because the suite plays the user's browser, and `conformance/authorization-server.sh` runs the suite against it, answering the "open this URL in your browser" step itself with curl: ```sh export ONEMODELCONTEXT_CONFORMANCE_CACHE=/some/directory/outside/the/repository export ONEMODELCONTEXT_AS_PORT=PORT ONEMODELCONTEXT_CALLBACK_PORT=OTHER_PORT conformance/authorization-server.sh --out results --spec-version 2026-07-28 ``` The suite has two authorization-server scenarios, `authorization-server-metadata-endpoint` and `authorization-code-grant`. The second needs the first to have run in the same process, which is what the command does without `--scenario`. Both pass at every `--spec-version` they apply to (2025-03-26: 2 of 2 checks; 2025-06-18: 2 of 2; 2025-11-25 and 2026-07-28: 3 of 3, including the check that the metadata advertises `client_id_metadata_document_supported`). ## The suite's scenario list (`list` output, 0.2.0-alpha.12) Bracketed revisions are where a scenario applies. ```text Server scenarios (test against a server): - server-initialize [2025-06-18,2025-11-25] - server-session-lifecycle [2025-03-26,2025-06-18,2025-11-25] - server-stateless [2026-07-28] - logging-set-level [2025-06-18,2025-11-25] - ping [2025-06-18,2025-11-25] - completion-complete [2025-06-18,2025-11-25,2026-07-28] - tools-list [2025-06-18,2025-11-25,2026-07-28] - tools-call-simple-text [2025-06-18,2025-11-25,2026-07-28] - tools-call-image [2025-06-18,2025-11-25,2026-07-28] - tools-call-audio [2025-06-18,2025-11-25,2026-07-28] - tools-call-embedded-resource [2025-06-18,2025-11-25,2026-07-28] - tools-call-mixed-content [2025-06-18,2025-11-25,2026-07-28] - tools-call-with-logging [2025-06-18,2025-11-25] - tools-call-error [2025-06-18,2025-11-25,2026-07-28] - tools-call-with-progress [2025-06-18,2025-11-25,2026-07-28] - tools-call-sampling [2025-06-18,2025-11-25] - tools-call-elicitation [2025-06-18,2025-11-25] - json-schema-2020-12 [2025-11-25,2026-07-28] - elicitation-sep1034-defaults [2025-11-25] - server-sse-polling [2025-11-25] - server-sse-multiple-streams [2025-11-25,2026-07-28] - elicitation-sep1330-enums [2025-11-25] - resources-list [2025-06-18,2025-11-25,2026-07-28] - resources-read-text [2025-06-18,2025-11-25,2026-07-28] - resources-read-binary [2025-06-18,2025-11-25,2026-07-28] - resources-templates-read [2025-06-18,2025-11-25,2026-07-28] - resources-subscribe [2025-06-18,2025-11-25] - resources-unsubscribe [2025-06-18,2025-11-25] - sep-2164-resource-not-found [2026-07-28] - sep-2640-skills-directory [extension] - sep-2640-skills-enumeration [extension] - sep-2640-skills-manifest [extension] - prompts-list [2025-06-18,2025-11-25,2026-07-28] - prompts-get-simple [2025-06-18,2025-11-25,2026-07-28] - prompts-get-with-args [2025-06-18,2025-11-25,2026-07-28] - prompts-get-embedded-resource [2025-06-18,2025-11-25,2026-07-28] - prompts-get-with-image [2025-06-18,2025-11-25,2026-07-28] - dns-rebinding-protection [2025-11-25,2026-07-28] - caching [2026-07-28] - http-header-validation [2026-07-28] - http-custom-header-server-validation [2026-07-28] - tasks-lifecycle [extension] - tasks-capability-negotiation [extension] - tasks-wire-fields [extension] - tasks-request-state-removal [extension] - tasks-mrtr-input [extension] - tasks-request-headers [extension] - tasks-dispatch-and-envelope [extension] - tasks-status-notifications [extension] - tasks-required-task-error [extension] - tasks-mrtr-composition [extension] - input-required-result-basic-elicitation [2026-07-28] - input-required-result-basic-sampling [2026-07-28] - input-required-result-basic-list-roots [2026-07-28] - input-required-result-request-state [2026-07-28] - input-required-result-multiple-input-requests [2026-07-28] - input-required-result-multi-round [2026-07-28] - input-required-result-missing-input-response [2026-07-28] - input-required-result-non-tool-request [2026-07-28] - input-required-result-result-type [2026-07-28] - input-required-result-unsupported-methods [2026-07-28] - input-required-result-tampered-state [2026-07-28] - input-required-result-capability-check [2026-07-28] - input-required-result-ignore-extra-params [2026-07-28] - input-required-result-validate-input [2026-07-28] Client scenarios (test against a client): - initialize [2025-06-18,2025-11-25] - tools_call [2025-06-18,2025-11-25,2026-07-28] - elicitation-sep1034-client-defaults [2025-11-25] - sse-retry [2025-11-25] - request-metadata [2026-07-28] - auth/metadata-default [2025-11-25,2026-07-28] - auth/metadata-var1 [2025-11-25,2026-07-28] - auth/metadata-var2 [2025-11-25,2026-07-28] - auth/metadata-var3 [2025-11-25,2026-07-28] - auth/basic-cimd [2025-11-25,2026-07-28] - auth/scope-from-www-authenticate [2025-11-25,2026-07-28] - auth/scope-from-scopes-supported [2025-11-25,2026-07-28] - auth/scope-omitted-when-undefined [2025-11-25,2026-07-28] - auth/scope-step-up [2025-11-25,2026-07-28] - auth/scope-retry-limit [2025-11-25,2026-07-28] - auth/token-endpoint-auth-basic [2025-06-18,2025-11-25,2026-07-28] - auth/token-endpoint-auth-post [2025-06-18,2025-11-25,2026-07-28] - auth/token-endpoint-auth-none [2025-06-18,2025-11-25,2026-07-28] - auth/pre-registration [2025-11-25,2026-07-28] - auth/2025-03-26-oauth-metadata-backcompat [2025-03-26] - auth/2025-03-26-oauth-endpoint-fallback [2025-03-26] - auth/resource-mismatch [2026-07-28] - auth/offline-access-scope [2026-07-28] - auth/offline-access-not-supported [2026-07-28] - auth/authorization-server-migration [2026-07-28] - auth/iss-supported [2026-07-28] - auth/iss-not-advertised [2026-07-28] - auth/iss-supported-missing [2026-07-28] - auth/iss-wrong-issuer [2026-07-28] - auth/iss-unexpected [2026-07-28] - auth/iss-normalized [2026-07-28] - auth/metadata-issuer-mismatch [2026-07-28] - auth/client-credentials-jwt [extension] - auth/client-credentials-basic [extension] - auth/enterprise-managed-authorization [extension] - auth/dpop [extension] - auth/dpop-nonce [extension] - auth/wif-jwt-bearer [extension] - sep-2322-client-request-state [2026-07-28] - http-standard-headers [2026-07-28] - http-custom-headers [2026-07-28] - http-invalid-tool-headers [2026-07-28] - json-schema-ref-no-deref [2026-07-28] - json-schema-2020-12-preservation [2025-11-25,2026-07-28] - sep-2640-client-no-prefetch [extension] - sep-2640-client-verify-digest [extension] - sep-2640-client-verify-size [extension] - sep-2640-client-verify-frontmatter [extension] Authorization server scenarios (test against an authorization server): - authorization-server-metadata-endpoint [2025-03-26,2025-06-18,2025-11-25,2026-07-28] - authorization-code-grant [2025-03-26,2025-06-18,2025-11-25,2026-07-28] ``` Model Context Protocol® is a registered trademark of LF Projects, LLC. OneModelContext is an independent project and is not affiliated with or endorsed by LF Projects, LLC or the Model Context Protocol project. # Interop OneModelContext is checked against other implementations of the protocol, run as black boxes: installed from their published releases at pinned versions and driven through their command lines, their wire behaviour and small programs in this directory written from each SDK's published documentation. Their files are never opened or copied. ## What a run covers | Direction | Oracles | Transports | Eras | |---|---|---|---| | Their client → our server | TypeScript SDK client, Python SDK client, official C# SDK client, Inspector CLI | stdio, Streamable HTTP (ASP.NET Core host; the built-in listener in the full matrix) | each era the oracle can speak: Python `auto`/`legacy` (and a 2026-07-28 pin in the full matrix), Inspector `modern`/`legacy`, TypeScript and C# SDK as they negotiate | | Our client → their server | TypeScript, Python and C# SDK fixture servers; the TypeScript SDK's HTTP+SSE server | stdio, Streamable HTTP, HTTP+SSE | `--era auto`, forced `legacy`, and forced `modern` in the full matrix (a legacy-only server must answer with a typed error naming both sides' eras) | | Our client → published servers | `server-everything`, `mcp-server-time` (reduced); plus `server-filesystem` and `server-memory` (full) | stdio | as above | Checks per pairing (`connect` records the negotiated version): tools list, a text call, an image call, resources list and read, prompts list and get, an unknown tool (error), progress, cancellation (the call is abandoned and the connection still works), and an elicitation round (server asks, client answers). The Inspector and the published servers get the subset those tools can drive. A check a given SDK cannot perform in an era is a result, not an omission: the row shows what it did on the wire. ## Running ```sh export INTEROP_CACHE=/some/directory/outside/the/repository interop/install.sh # Node (user-local if missing), the npm packages, a uv virtual environment interop/run.sh --out results --matrix reduced # one pairing per kind interop/run.sh --out results --matrix full # every pairing above interop/run.sh --out results --only ours__py-server # cases whose id contains the text ``` `run.sh` builds the three .NET drivers (output outside the repository: set `ArtifactsPath`, or it uses `$INTEROP_WORK/artifacts`), starts each server on loopback, runs each client with a timeout and stops only the processes it started. Ports come from `INTEROP_PORT_BASE` (default 49300) upward; our servers pick port 0 and print the one they got. It writes `results.md`, `results.json`, and per case `cases/ID.jsonl` (one line per check), `ID.err`, `ID.exit` and the server logs. Case ids read `client__server__transport__era`. Nothing here runs in continuous integration; it is run at milestones. ## Pins | Oracle | Version | Source | |---|---|---| | `@modelcontextprotocol/sdk` (TypeScript) | 1.32.2 | npm | | `@modelcontextprotocol/inspector` | 2.14.0 | npm | | `mcp` (Python) | 2.3.0 | PyPI | | `ModelContextProtocol`, `ModelContextProtocol.AspNetCore` (C#) | 2.4.0 | NuGet, this directory's `cs/` project only | | `@modelcontextprotocol/server-everything` | 2.2.1 | npm | | `@modelcontextprotocol/server-filesystem` | 2.2.0 | npm | | `@modelcontextprotocol/server-memory` | 2.1.0 | npm | | `mcp-server-time` | 2026.11.4 | PyPI | | `zod` (the TypeScript server's schemas) | 3.25.76 | npm | | Node | 22.22.1 | official tarball when `node` is not on PATH | | Python | 3.13 | `uv` | ## Layout - `ours/Server`, `ours/Client`: our fixture server (stdio, ASP.NET Core, built-in listener) and our probe client. - `ts/`, `py/`, `cs/`: a probe client and a fixture server on each reference SDK. The fixture is the same everywhere: tools `echo`, `image`, `slow` (progress), `wait` (until cancelled), `confirm` (elicitation), the resource `mem://hello` and the prompt `greet`. - `inspector.sh`: the Inspector CLI probes; `report.mjs`: results table. ## Notes on observed behaviour - The official C# SDK server over HTTP asks the client for input only in its stateful mode (`Stateless = false`), which negotiates a legacy revision; stateless it answers elicitation with a tool error. The fixture server runs stateful. - A server that does not declare a capability is not asked for that list by the generic probe. - Python SDK `ctx.elicit` is a server-to-client request and exists only on legacy sessions; the fixture asks through the SDK's resolver, which works in both eras. Model Context Protocol® is a registered trademark of LF Projects, LLC. OneModelContext is an independent project and is not affiliated with or endorsed by LF Projects, LLC or the Model Context Protocol project. # Changelog All notable changes are recorded here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ## [1.0.1] ### Added - Tool-call argument overloads for ordinary C# objects and source-generated JSON metadata. - Compile checks for documentation C# samples. ### Fixed - EdDSA guidance describes the custom-validator workaround rather than an unavailable package. - CLI inspection counts resource templates separately from concrete resources. - Analyzer release tracking records the diagnostics shipped in 1.0.0; the diagnostic range includes `OMCG0012`. - Documentation examples and US English spelling. ## [1.0.0] ### Fixed - Stdio output shutdown follows write progress within the EOF linger bound; queued writes apply backpressure to input and are safe against concurrent disposal. Pipelined legacy requests wait for initialization. Invalid tool-name encoding is invalid params, and HTTP response-body I/O failures are no longer mistaken for size limits. The protected-server sample requires its token in the environment. - Stdio and stream servers finish admitted requests after input EOF before closing their output. `EndOfStreamTimeout` gives handlers a 5-second grace period before cancellation; shutdown still has a bounded wait for handlers that ignore cancellation. ### Changed - OAuth client hardening: a client secret, signing key or identity assertion is sent only to an issuer the application pinned (`ExpectedIssuer`, `PreRegisteredClient.Issuer`; error `issuer_required`); the loopback receiver ignores callbacks whose `state` is not the one in progress; a stale process adopts a refresh token another process rotated; plain-HTTP OAuth endpoints are allowed only for loopback resources; `OAuthClientOptions.HttpHandler` replaces `HttpClient` and must not follow redirects; a token file that cannot be read or is group/world-writable is never overwritten; a scope-less `insufficient_scope` no longer starts an authorization per request (`StepUpCooldown`). CLI: `--issuer`, secrets from `env:NAME`, `-` or `@file`, and `login` replaces stored tokens only after the new sign-in succeeds. - `AuthorizationServer` hardening: the metadata-document cache evicts instead of switching itself off, with a per-host fetch cap and a short per-host backoff (`MaxFetchesPerHost`, `HostFailureBackoff`); Client Credentials and dynamically registered clients are refused scopes unless a scope allow-list applies; scope strings must be RFC 6749 scope tokens; redirect URIs with control characters, whitespace, backslashes or userinfo are refused and a redirect is never sent without a safe `Location`; `RequireConfidentialClient` defaults to true; the default ID-JAG subject is `issuer#sub`; a spent authorization code is remembered until its refresh family expires (`AuthorizationCodeGrant.FamilyExpiresAt`), in the memory, file and EF stores; the server's own validator requires `typ` `at+jwt` (`JwtTokenValidator.RequireAccessTokenType` for others); client secrets use a salted HMAC-SHA-256 hash (PBKDF2 hashes still verify). `ApplyAuthorizationStore` takes an optional binary collation for `client_id`. ### Added - `RecursiveAI.OneModelContext.OneLite`: `OneLiteAuthorizationStore`, `OneLiteTokenStore` and `OneLiteTaskStore` (experimental) in one embedded OneLite database file, for a single host with no database server and no Entity Framework. The tables are created and versioned by the package itself (a file written by a newer schema is refused), single-use codes, refresh rotation, assertion replay checks and the task compare-and-swap hold across threads and processes, and the package is trim and NativeAOT safe. See `docs/stores.md`. - Listener and endpoint hardening: `MaxConnectionsPerAddress`, `FirstByteTimeout` and TCP keep-alive on the built-in listener; `MaxInFlightBodyBytes` (request bodies are sized to their declared length) and `MaxStreamLifetime` on `StreamableHttpOptions`; POST needs `Content-Type: application/json` (415); `ModelContext.ServeHttpAsync` caps bodies at 1 MiB; the non-loopback warning reaches standard error when nothing subscribes to diagnostics. See `docs/http-limits.md`. - Experimental extensions (`OMCX0001` Tasks, `OMCX0002` Skills, `OMCX0003` Server Cards), off until the host opts in. Tasks: tools that answer with a task handle, `tasks/get`, `tasks/update` and `tasks/cancel`, client polling, and `ITaskStore` with in-memory and file stores. Skills over MCP: `SkillDefinition`, `UseSkills`/`AddSkill`, `skills/list`, `skills/get` and `resources/directory/read` on the server; listing, loading and reading with digest, size and frontmatter verification on the client. Server Cards: published at `/server-card` by the HTTP core, the listener and `MapModelContext`, fetched through the guarded fetcher with AI Catalog discovery. See `docs/extensions.md`. - CLI `login` and `logout` for http(s) servers (browser and loopback redirect, tokens in an owner-only file); the other commands use and refresh the stored tokens, and an authorization failure ends with exit code 4 and the challenge summarised, never the token. - `RecursiveAI.OneModelContext.EntityFrameworkCore`: `EfAuthorizationStore` and `EfTokenStore` over your own `DbContext` (`modelBuilder.ApplyContextStores()`), for authorization servers and clients that run as several instances. Single-use codes, refresh rotation and assertion replay checks are conditional updates, so they hold when instances race. `EfTaskStore` (experimental) shares Tasks between instances with a compare-and-swap on `Version`. See `docs/stores.md`. - The JSON-RPC 2.0 base layer: message model, a reader that never throws on bad input, a writer, newline-delimited framing over any duplex stream with a bounded line length, and a connection that correlates responses, runs incoming requests concurrently and supports cancellation. - System.Text.Json source-generated records for the messages of the 2026-07-28 revision and the legacy revisions back to 2024-11-05. - `ContextServer`, `ContextClient`, `RequestContext` and `ContextTransport`, with the codec contract that the two protocol eras implement. - Typed errors, an `ActivitySource` and `Meter` named `RecursiveAI.OneModelContext`, a diagnostics event stream and a per-request audit hook. - The `RecursiveAI.OneModelContext` and `RecursiveAI.OneModelContext.Cli` packages, with the source generator bundled as an analyzer. - `[Tool]`, `[Resource]` and `[Prompt]` with a source generator that writes JSON Schema 2020-12 input and output schemas from signatures and doc comments, and invokers that bind arguments through source-generated System.Text.Json metadata without reflection (diagnostics `OMCG0001` to `OMCG0012`). See `docs/tool-schemas.md`. - `server.Tool(...)`, `server.Resource(...)` and `server.Prompt(...)`: a reflection quick path that writes the same schemas and is annotated as not trim- or AOT-safe. - `ModelContext.ServeAsync` and `ModelContext.ConnectAsync` for the happy path, and a NativeAOT stdio server sample. - `AuthorizationServer`: an OAuth 2.1 authorization server for an MCP endpoint with host-owned login and consent. PKCE (S256), exact redirect URI matching, `iss` on every authorization response, RFC 8707 `resource` bound into the token audience, single-use codes, rotating refresh tokens with reuse detection, revocation, key rotation with a published key set, Client ID Metadata Documents under an SSRF guard, pre-registered public and confidential clients (`client_secret_basic`, `client_secret_post`, `private_key_jwt`), Client Credentials, the Enterprise-Managed Authorization JWT-bearer grant, and the deprecated registration endpoint (`OMC0004`). See `docs/authorization-server.md`. - `IAuthorizationStore` with in-memory and file implementations; `GuardedHttpFetcher` and `AddressPolicy` for fetching remote-supplied URLs safely; `JsonWebSigningKey` for signing JWTs with BCL keys; `MapAuthorizationServer` for ASP.NET Core. - The Streamable HTTP client (`ContextTransport.Http`, `ModelContext.ConnectAsync(Uri)`), speaking both eras with sessions, the GET stream and `Last-Event-ID` resumption, a client-only fallback to the deprecated HTTP+SSE transport, an SSE reader with bounded events, a hook for adding credentials to every request, and `subscriptions/listen` on the client. See `docs/http-client.md`. - `ModelContext.ConnectAsync("https://...")` connects a URL over Streamable HTTP, with the same callbacks as a command line. - The OAuth 2.1 client (`OAuthClient`, `OAuthHttpHandler`, `LoopbackRedirectReceiver`, `ITokenStore` with memory and file stores): discovery, Client ID Metadata Documents, pre-registered and dynamic registration, PKCE, `resource`, RFC 9207 `iss` checks, step-up, refresh, Client Credentials and Enterprise-Managed Authorization; `AuthorizationException` for 401 and 403 on the HTTP client; `IHttpRequest.Query`. See `docs/oauth-client.md`. - `RecursiveAI.OneModelContext.Hosting`: `services.AddContextServer()` for the generic host, with tool classes created by the container, a DI scope per request (`context.GetRequiredService()`), hosted stdio, stream and built-in listener lifetimes that stop with the host, an `ILogger` bridge for the diagnostics stream that redacts credentials, and `AddContextClient` for apps that consume a server. - `RecursiveAI.OneModelContext.Extensions.AI`: a client's tools as `AIFunction`s for any `IChatClient`, `AIFunction`s served as tools (`server.AddAIFunction`), and Sampling answered by any `IChatClient` (`ChatClientSampling.CreateHandler`). See `docs/adapters.md`.