|
1 | 1 | # DotnetMcpTemplate |
2 | 2 |
|
3 | | -An MCP server on ASP.NET Core (.NET 10) with the official MCP C# SDK 2.0. |
| 3 | +A [Model Context Protocol](https://modelcontextprotocol.io) server on ASP.NET Core (.NET 10) with the official MCP C# |
| 4 | +SDK 2.0. It exposes tools, resources and prompts over Streamable HTTP, signs users in through any OpenID Connect |
| 5 | +identity provider, and renders interactive views in the chat with MCP Apps. |
| 6 | + |
| 7 | +- [Quick start](#quick-start) |
| 8 | +- [Endpoints](#endpoints) |
| 9 | +- [Configuration](#configuration) |
| 10 | +- [Authentication](#authentication) |
| 11 | +- [Connecting clients](#connecting-clients) |
| 12 | +- [MCP App UI](#mcp-app-ui) |
| 13 | +- [Project structure](#project-structure) |
| 14 | +- [Development](#development) |
| 15 | +- [Testing](#testing) |
| 16 | +- [Docker](#docker) |
| 17 | +- [Documentation](#documentation) |
| 18 | +- [Troubleshooting](#troubleshooting) |
| 19 | + |
| 20 | +## Quick start |
| 21 | + |
| 22 | +**Requirements:** [.NET 10 SDK](https://dotnet.microsoft.com/download) (version pinned in `global.json`). |
| 23 | +Optional: Docker; Node 22+ with pnpm (`corepack enable`) for the MCP App UI. |
4 | 24 |
|
5 | 25 | ```bash |
6 | 26 | cp .env.example .env |
| 27 | +``` |
| 28 | + |
| 29 | +Set `MCP_AUTH_MODE=none` in `.env` for a first run without an identity provider (only allowed with `APP_ENV=dev`), |
| 30 | +then start the server: |
| 31 | + |
| 32 | +```bash |
7 | 33 | dotnet run --project src/DotnetMcpTemplate |
| 34 | +# info: Server running at http://localhost:5080 |
| 35 | +``` |
| 36 | + |
| 37 | +Try it with the MCP Inspector: |
| 38 | + |
| 39 | +```bash |
| 40 | +npx @modelcontextprotocol/inspector |
| 41 | +# Transport: Streamable HTTP · URL: http://localhost:5080/mcp · Connect → Tools → List Tools |
| 42 | +``` |
| 43 | + |
| 44 | +## Endpoints |
| 45 | + |
| 46 | +| Path | Purpose | |
| 47 | +| --- | --- | |
| 48 | +| `/mcp` | MCP endpoint (Streamable HTTP, stateless). Change with `MCP_PATH` | |
| 49 | +| `/` | Server info: `{ name, version, status, mcp }` | |
| 50 | +| `/health` | Health check for load balancers and orchestrators | |
| 51 | +| `/.well-known/oauth-protected-resource/mcp` | Protected resource metadata (tells clients where to sign in) | |
| 52 | +| `/.well-known/oauth-authorization-server` | OAuth metadata of the built-in OIDC proxy | |
| 53 | +| `/oauth/*` | OIDC proxy: register, authorize, consent, callback, token | |
| 54 | + |
| 55 | +## Configuration |
| 56 | + |
| 57 | +All settings are environment variables, usually from `.env` (never committed; `.env.example` lists every variable). |
| 58 | +Real environment variables (Docker, CI, shell) take precedence. Settings are validated at startup and every problem |
| 59 | +is reported by variable name. |
| 60 | + |
| 61 | +| Variable | Default | Purpose | |
| 62 | +| --- | --- | --- | |
| 63 | +| `APP_ENV` | `dev` | `dev`, `stage` or `prod` | |
| 64 | +| `APP_PORT` | `5080` | Listening port (IPv4 and IPv6: `localhost`, `127.0.0.1`, `[::1]`) | |
| 65 | +| `APP_URL` | `http://localhost:{APP_PORT}` | Public URL; set it behind a reverse proxy | |
| 66 | +| `MCP_SERVER_NAME`, `MCP_PATH`, `MCP_INSTRUCTIONS` | | Server identity and endpoint | |
| 67 | +| `MCP_AUTH_MODE` | `required` | `required`, `mixed` or `none` (dev only) | |
| 68 | +| `AUTH_PROVIDER` | `oidc` | `oidc`, `jwt` or a custom provider | |
| 69 | +| `OIDC_*` | | Identity provider: discovery URL, client id and secret, scopes, claims | |
| 70 | +| `AUTH_*` | | Token signing key and lifetimes, allowed redirect URIs, store (`memory` or `redis`) | |
| 71 | +| `SAMPLE_API_*` | | Example external API client; one `{PREFIX}_*` set per API | |
| 72 | + |
| 73 | +Details: [configuration guide](docs/development/configuration-and-environments.md). |
| 74 | + |
| 75 | +## Authentication |
| 76 | + |
| 77 | +With `AUTH_PROVIDER=oidc` the server is the OAuth authorization server for MCP clients and signs users in at your |
| 78 | +identity provider (Entra ID, Google, Auth0, Okta, Keycloak, Zitadel, ...): |
| 79 | + |
| 80 | +1. Register one web application at the identity provider with the redirect URI |
| 81 | + `http://localhost:5080/oauth/callback` (in general `${APP_URL}/oauth/callback`). |
| 82 | +2. Set `OIDC_DISCOVERY_URL`, `OIDC_CLIENT_ID` and `OIDC_CLIENT_SECRET` in `.env`, and `MCP_AUTH_MODE=required`. |
| 83 | +3. Connect a client: it registers, shows the consent page, and sends you to the identity provider to sign in. |
| 84 | + |
| 85 | +Tools, resources and prompts declare `[Authorize]` or `[AllowAnonymous]`. Scopes are defined in |
| 86 | +`Core/Auth/AppScopes.cs` and policies in `Core/Auth/Policies.cs`. For multiple instances set `AUTH_STORE=redis`, |
| 87 | +`REDIS_URL` and the same `AUTH_TOKEN_SIGNING_KEY` everywhere. |
| 88 | + |
| 89 | +Details: [authentication and authorization](docs/development/authentication-and-authorization.md). |
| 90 | + |
| 91 | +## Connecting clients |
| 92 | + |
| 93 | +The server URL is `APP_URL` + `MCP_PATH`, for example `https://mcp.example.com/mcp`. |
| 94 | + |
| 95 | +| Client | How | |
| 96 | +| --- | --- | |
| 97 | +| Claude (claude.ai, Desktop) | Settings → Connectors → Add custom connector → the URL | |
| 98 | +| VS Code | `.vscode/mcp.json`: `{ "servers": { "my-server": { "type": "http", "url": "…/mcp" } } }` | |
| 99 | +| Cursor | `~/.cursor/mcp.json`: `{ "mcpServers": { "my-server": { "url": "…/mcp" } } }` | |
| 100 | +| MCP Inspector | `npx @modelcontextprotocol/inspector` → Streamable HTTP → the URL | |
| 101 | + |
| 102 | +A client's redirect URI must match `AUTH_ALLOWED_REDIRECT_URIS`. Details: [connecting clients](docs/development/connecting-clients.md). |
| 103 | + |
| 104 | +## MCP App UI |
| 105 | + |
| 106 | +MCP Apps let a tool render an interactive view inside the conversation. The views live in `ui/` (React 19, Vite, |
| 107 | +Tailwind 4, `@modelcontextprotocol/ext-apps` 2). Each view builds to one self-contained HTML file in |
| 108 | +`src/DotnetMcpTemplate/ui_dist/`, which the server serves as a `ui://` resource. The built bundles are committed, so |
| 109 | +the server runs without Node; you only need the UI toolchain to change a view. |
| 110 | + |
| 111 | +The sample view `projects-dashboard` belongs to the `show_projects_dashboard` tool. It renders the tool's result and |
| 112 | +loads an owner's projects by calling `list_projects` from the view. |
| 113 | + |
| 114 | +### Setup |
| 115 | + |
| 116 | +```bash |
| 117 | +corepack enable # once per machine: provides pnpm |
| 118 | +cd ui |
| 119 | +pnpm install |
| 120 | +``` |
| 121 | + |
| 122 | +### Develop |
| 123 | + |
| 124 | +**Sandbox** (no server, no host, no login). Each view renders with sample data from `ui/src/apps/<entry>/sandbox.ts`, |
| 125 | +including the tools it calls, so drill-down, paging and loading states all work: |
| 126 | + |
| 127 | +```bash |
| 128 | +cd ui |
| 129 | +pnpm dev # http://localhost:5173: sandbox index; open a view from there |
8 | 130 | ``` |
9 | 131 |
|
10 | | -Connect MCP clients to `http://localhost:5080/mcp`. `GET /` returns server info. |
| 132 | +Edits hot-reload in the browser. This is the fastest loop for layout and interaction work. |
| 133 | + |
| 134 | +**With the server** (real data, real tool calls). Run the server and the UI watcher side by side: |
| 135 | + |
| 136 | +```bash |
| 137 | +# terminal 1: server with hot reload |
| 138 | +dotnet watch --project src/DotnetMcpTemplate |
| 139 | + |
| 140 | +# terminal 2: rebuild bundles on save (all views, or name one) |
| 141 | +cd ui && pnpm run watch # or: pnpm run watch projects-dashboard |
| 142 | +``` |
| 143 | + |
| 144 | +In development the server reads bundles straight from `src/DotnetMcpTemplate/ui_dist/`, so each rebuild is served on |
| 145 | +the next `resources/read` without a restart. Open the view from an MCP client that supports MCP Apps by asking it to |
| 146 | +run the tool (e.g. "show the projects dashboard"). |
| 147 | + |
| 148 | +### Build |
| 149 | + |
| 150 | +```bash |
| 151 | +cd ui |
| 152 | +pnpm run build # typecheck + build every view |
| 153 | +pnpm run build projects-dashboard # typecheck + build only the named views |
| 154 | +pnpm run entries # list the views |
| 155 | +pnpm run typecheck # typecheck only |
| 156 | +``` |
| 157 | + |
| 158 | +Output: `src/DotnetMcpTemplate/ui_dist/<entry>.html`. **Commit it with your UI changes.** Builds are reproducible, |
| 159 | +and CI fails when the committed bundles don't match the source. The Docker image rebuilds the UI from |
| 160 | +`ui/pnpm-lock.yaml` as well. Production bundles contain no sample data: opening one directly shows |
| 161 | +"Open this view in an MCP host". |
| 162 | + |
| 163 | +### Add a view |
| 164 | + |
| 165 | +1. Create `ui/src/entries/<entry>.html` and `<entry>.tsx` (copy the `projects-dashboard` pair). Names are kebab-case. |
| 166 | +2. Create `ui/src/apps/<entry>/`: `schema.ts` (zod schemas mirroring the C# response records), `sandbox.ts`, |
| 167 | + `lib/use<Entry>.ts`, `components/`, `App.tsx`. |
| 168 | +3. Add a resource in `Capabilities/Resources/UiResources.cs` that returns `bundles.Read("<entry>")` as |
| 169 | + `ui://<entry>`, and put `[McpAppUi(ResourceUri = "ui://<entry>")]` and `UseStructuredContent = true` on the tool. |
| 170 | +4. `pnpm run build <entry>` and commit the bundle. |
| 171 | + |
| 172 | +Patterns (host connection, schemas, errors, theming): [ui/README.md](ui/README.md) · |
| 173 | +[MCP Apps guide](docs/development/mcp-apps.md). |
| 174 | + |
| 175 | +## Project structure |
11 | 176 |
|
12 | 177 | ```text |
13 | 178 | . |
14 | 179 | ├── src/DotnetMcpTemplate/ |
15 | 180 | │ ├── Program.cs |
16 | 181 | │ ├── Capabilities/ What the server exposes (thin): Tools/, Resources/, Prompts/ |
17 | 182 | │ ├── Services/ Business logic |
18 | | -│ ├── Models/ Request/response DTOs |
| 183 | +│ ├── Models/ Request / response DTOs |
19 | 184 | │ ├── Integrations/ One folder per external API (typed client + its models), IntegrationsSetup.cs |
20 | | -│ └── Core/ Plumbing you rarely touch |
21 | | -│ ├── ApiClients/ ApiClient base, registration, resilience, auth handlers |
22 | | -│ ├── Auth/ Providers (OIDC proxy for any IdP, JWT, Dev), scopes, policies, AppUser |
23 | | -│ ├── Mcp/ Server setup, handler registry, Filters/ (logging, errors), Apps/ (MCP App bundles) |
24 | | -│ ├── Validation/ Argument validation for every call |
25 | | -│ ├── Common/ Settings/ (.env, validated typed settings), Errors/, Middleware/ |
26 | | -│ └── ServerInfo/ GET / server info |
27 | | -├── tests/ Unit, Integration (real MCP client, full OAuth flow), Architecture; mirror src/ |
28 | | -├── ui/ React + Vite workspace for MCP App views |
| 185 | +│ ├── Core/ Plumbing you rarely touch |
| 186 | +│ │ ├── ApiClients/ ApiClient base, registration, resilience, auth handlers |
| 187 | +│ │ ├── Auth/ Providers (OIDC proxy, JWT, dev), scopes, policies, AppUser |
| 188 | +│ │ ├── Mcp/ Server setup, handler registry, Filters/ (logging, errors), Apps/ (bundles) |
| 189 | +│ │ ├── Validation/ Argument validation for every call |
| 190 | +│ │ ├── Common/ Settings/ (.env, typed settings), Errors/, Middleware/ |
| 191 | +│ │ └── ServerInfo/ GET / |
| 192 | +│ └── ui_dist/ Built MCP App bundles (committed) |
| 193 | +├── ui/ MCP App views: entries/, apps/<entry>/, shared/ |
| 194 | +├── tests/ Unit, Integration, Architecture (mirror src/) |
| 195 | +├── docs/ Guides (development/) and architecture decisions (adr/) |
29 | 196 | ├── docker/ Dockerfile, Compose |
30 | | -├── docs/ ADRs (adr/) and guides (development/) |
31 | 197 | ├── global.json Pinned .NET SDK |
32 | 198 | └── Directory.*.props Shared build settings, central package versions |
33 | 199 | ``` |
34 | 200 |
|
35 | | -Guides, organized by task: [docs/development/README.md](docs/development/README.md). Architecture decisions: |
36 | | -[docs/README.md](docs/README.md). MCP App views: [ui/README.md](ui/README.md). |
| 201 | +Calls flow one way: **Capabilities → Services → Integrations → external APIs**. Architecture tests enforce it. |
| 202 | + |
| 203 | +## Development |
| 204 | + |
| 205 | +```bash |
| 206 | +dotnet watch --project src/DotnetMcpTemplate # hot reload; press Ctrl+R to restart after editing .env |
| 207 | +``` |
| 208 | + |
| 209 | +Adding a tool is one class; tools are discovered automatically: |
| 210 | + |
| 211 | +```csharp |
| 212 | +[McpServerToolType] |
| 213 | +[Authorize] |
| 214 | +public sealed class InvoiceTools(IInvoiceService invoices) |
| 215 | +{ |
| 216 | + [McpServerTool(Name = "get_invoice", ReadOnly = true, UseStructuredContent = true)] |
| 217 | + [Description("Gets an invoice by number.")] |
| 218 | + public Task<InvoiceDto> GetInvoice( |
| 219 | + [Required, RegularExpression("^INV-[0-9]{6}$"), Description("Invoice number, e.g. INV-000123.")] string number, |
| 220 | + AppUser user, |
| 221 | + CancellationToken cancellationToken) => |
| 222 | + invoices.GetAsync(number, user, cancellationToken); |
| 223 | +} |
| 224 | +``` |
| 225 | + |
| 226 | +Keep tools thin: validate input with attributes, call one service, return a DTO. Business rules go in `Services/`, |
| 227 | +external calls in `Integrations/`. Throw `AppException.NotFound/Conflict/Rule(...)` for expected errors; the model |
| 228 | +receives the message. See [tools, resources and prompts](docs/development/tools-resources-prompts.md). |
| 229 | + |
| 230 | +## Testing |
| 231 | + |
| 232 | +```bash |
| 233 | +dotnet test # unit, integration and architecture tests |
| 234 | +dotnet test --filter "FullyQualifiedName~ToolTests" |
| 235 | +cd ui && pnpm run typecheck # MCP App views |
| 236 | +``` |
| 237 | + |
| 238 | +Integration tests start the server in memory and call it through a real MCP client, including the complete OAuth |
| 239 | +flow against a fake identity provider. Architecture tests fail the build when a tool skips `[Authorize]` / |
| 240 | +`[AllowAnonymous]`, calls an API client directly, or a service leaks external models. |
| 241 | +See [testing](docs/development/testing.md). |
| 242 | + |
| 243 | +## Docker |
| 244 | + |
| 245 | +```bash |
| 246 | +docker compose -f docker/docker-compose.yml up --build # local, uses .env |
| 247 | +docker build -f docker/Dockerfile -t dotnetmcptemplate-slug . # image only |
| 248 | +``` |
| 249 | + |
| 250 | +The image builds the MCP App bundles when `ui/pnpm-lock.yaml` exists, then runs the server on `APP_PORT` over plain |
| 251 | +HTTP. In production, terminate TLS at a reverse proxy and set `APP_URL` to the public HTTPS URL. |
| 252 | + |
| 253 | +## Documentation |
| 254 | + |
| 255 | +| | | |
| 256 | +| --- | --- | |
| 257 | +| [Development guides](docs/development/README.md) | Every guide, organized by task: run, build, connect, secure, test | |
| 258 | +| [MCP App UI workspace](ui/README.md) | Layout and patterns of the `ui/` workspace | |
| 259 | +| [Architecture decisions](docs/README.md) | Why the code is shaped this way (ADRs) | |
| 260 | + |
| 261 | +## Troubleshooting |
| 262 | + |
| 263 | +| Symptom | Fix | |
| 264 | +| --- | --- | |
| 265 | +| Settings error at startup | The message names each variable; compare `.env` with `.env.example` | |
| 266 | +| `MCP_AUTH_MODE=none` is refused | Only allowed with `APP_ENV=dev` | |
| 267 | +| Login fails with `IDX20803: Unable to obtain configuration` | Open `OIDC_DISCOVERY_URL` in a browser: it must return JSON (note the dot in `/.well-known/`) | |
| 268 | +| Identity provider says the redirect URI is invalid | Register `${APP_URL}/oauth/callback` exactly, not `APP_URL` alone | |
| 269 | +| A client can't register (`invalid_redirect_uri`) | Add its redirect URI pattern to `AUTH_ALLOWED_REDIRECT_URIS` | |
| 270 | +| Clients are signed out after every restart | `AUTH_STORE=memory` keeps state in memory; use `redis` to persist it | |
| 271 | +| `.env` changes have no effect under `dotnet watch` | Settings are read at startup: press Ctrl+R in the watch terminal | |
| 272 | +| `UI bundle not found at …/ui_dist/<entry>.html` | `cd ui && pnpm install && pnpm run build` | |
| 273 | +| CI fails on "Committed bundles match the source" | `cd ui && pnpm run build`, then commit `src/DotnetMcpTemplate/ui_dist/` | |
0 commit comments