A .NET 10 Web API demonstrating clean architecture with both a traditional REST interface and a Model Context Protocol (MCP) interface, so LLMs can interact with the same domain logic.
- Architecture Overview
- Project Structure
- Getting Started
- Web API
- MCP Interface
- Security Summary
- Configuration Reference
The solution follows Clean Architecture with a strict separation of concerns across four layers:
┌────────────────────────────────────────────────────┐
│ API Layer (McpExample.API) │
│ • REST Controllers (JWT-secured) │
│ • MCP Tools (API Key-secured) │
│ • Middleware (McpApiKeyMiddleware) │
│ • Security helpers (JwtTokenService) │
└──────────────┬───────────────────────────────────┘
│ depends on
┌──────────────▼───────────────────────────────────┐
│ Application Layer (McpExample.Application) │
│ • IProductService interface + ProductService │
│ • DTOs (ProductDto, CreateProductDto, …) │
│ NO dependency on infrastructure or frameworks │
└──────────────┬───────────────────────────────────┘
│ depends on
┌──────────────▼───────────────────────────────────┐
│ Domain Layer (McpExample.Domain) │
│ • Product entity (rich domain model) │
│ • IProductRepository interface │
│ Pure C# — no external dependencies │
└──────────────────────────────────────────────────┘
▲
┌──────────────┴───────────────────────────────────┐
│ Infrastructure Layer (McpExample.Infrastructure)│
│ • InMemoryProductRepository │
│ Implements domain interfaces │
└──────────────────────────────────────────────────┘
Key design points:
- Domain knows nothing about ASP.NET, EF, or MCP.
- Application only depends on Domain abstractions.
- Infrastructure provides concrete implementations (swap to EF Core / SQL without touching domain or application code).
- API wires everything together; both the REST controllers and the MCP tools call the same
IProductService.
mcp-example/
├── McpExample.slnx
├── src/
│ ├── McpExample.Domain/
│ │ ├── Entities/Product.cs
│ │ └── Interfaces/IProductRepository.cs
│ ├── McpExample.Application/
│ │ ├── DTOs/
│ │ ├── Interfaces/IProductService.cs
│ │ ├── Services/ProductService.cs
│ │ └── Extensions/ServiceCollectionExtensions.cs
│ ├── McpExample.Infrastructure/
│ │ ├── Repositories/InMemoryProductRepository.cs
│ │ └── Extensions/ServiceCollectionExtensions.cs
│ └── McpExample.API/
│ ├── Controllers/
│ │ ├── AuthController.cs ← issues JWT tokens
│ │ └── ProductsController.cs ← CRUD, [Authorize]
│ ├── McpTools/
│ │ └── ProductMcpTools.cs ← MCP tools for LLMs
│ ├── Middleware/
│ │ └── McpApiKeyMiddleware.cs ← guards /mcp/*
│ ├── Security/JwtTokenService.cs
│ ├── Program.cs
│ └── appsettings*.json
└── tests/
└── McpExample.Application.Tests/
├── ProductServiceTests.cs
└── ProductEntityTests.cs
cd src/McpExample.API
dotnet runThe API listens on https://localhost:7254 (HTTPS) and http://localhost:5109 (HTTP) by default when using the https launch profile. The actual port is printed in the console on startup.
dotnet testAll product endpoints require a valid JWT Bearer token (see JWT Authentication below).
| Method | Path | Description | Auth |
|---|---|---|---|
POST |
/api/auth/token |
Issue a JWT token | ❌ Public |
Request body:
{
"username": "alice",
"password": "any-password"
}
⚠️ The demo accepts any non-empty username/password. In production, validate against a real user store.
Response:
{
"token": "<jwt-token>"
}| Method | Path | Description |
|---|---|---|
GET |
/api/products |
List all products |
GET |
/api/products/{id} |
Get product by ID |
GET |
/api/products/category/{category} |
Filter by category |
POST |
/api/products |
Create a product |
PUT |
/api/products/{id} |
Update a product |
DELETE |
/api/products/{id} |
Delete a product |
Create / Update body:
{
"name": "Mechanical Keyboard",
"description": "Tactile switches, RGB backlit",
"price": 129.99,
"stockQuantity": 40,
"category": "Electronics"
}- Obtain a token:
curl -X POST https://localhost:7254/api/auth/token \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"secret"}'- Use the token in subsequent requests:
curl https://localhost:7254/api/products \
-H "Authorization: Bearer <jwt-token>"Requests without a valid token receive 401 Unauthorized.
The MCP endpoint is exposed at /mcp using the ModelContextProtocol.AspNetCore library. LLM clients that support MCP (e.g. Claude Desktop, VS Code Copilot, or any custom MCP client) can connect to this URL to discover and call the available tools.
| Tool Name | Description |
|---|---|
list_products |
Returns all available products |
get_product |
Returns a single product by GUID |
get_products_by_category |
Returns products in a category |
create_product |
Creates a new product |
update_product |
Updates an existing product |
delete_product |
Deletes a product by GUID |
All tools delegate to the same IProductService used by the REST controllers — the domain logic is shared and never duplicated.
MCP requests to /mcp must include the X-Api-Key header.
Note: The MCP endpoint uses the HTTP+SSE transport (Server-Sent Events). Requests must first
initializethe session, then use the returnedMcp-Session-Idfor subsequent calls.
# Step 1 – Initialize a session (captures the Mcp-Session-Id header)
SESSION_ID=$(curl -si -X POST https://localhost:7254/mcp \
-H "Content-Type: application/json" \
-H "X-Api-Key: dev-mcp-api-key-do-not-use-in-production" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' \
| grep -i "mcp-session-id" | awk '{print $2}' | tr -d '\r')
# Step 2 – List available tools
curl -X POST https://localhost:7254/mcp \
-H "Content-Type: application/json" \
-H "X-Api-Key: dev-mcp-api-key-do-not-use-in-production" \
-H "Mcp-Session-Id: $SESSION_ID" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'Without the X-Api-Key header → 401 Unauthorized
With a wrong key → 403 Forbidden
{
"mcpServers": {
"product-api": {
"url": "https://localhost:7254/mcp",
"headers": {
"X-Api-Key": "<your-mcp-api-key>"
}
}
}
}| Interface | Mechanism | Header / Token |
|---|---|---|
Web API (/api/**) |
JWT Bearer (HS256) | Authorization: Bearer <token> |
MCP (/mcp) |
API Key | X-Api-Key: <key> |
- Client calls
POST /api/auth/tokenwith credentials. JwtTokenServiceissues a signed HS256 token (configurable expiry, issuer, audience).- Client attaches the token to every request via the
Authorizationheader. - ASP.NET Core's
JwtBearermiddleware validates the signature, issuer, audience, and expiry on each request before the controller executes.
McpApiKeyMiddlewareintercepts every request whose path starts with/mcp.- It reads the
X-Api-Keyheader and performs a constant-time string comparison against the configured key. - Missing key →
401; wrong key →403; correct key → request passes through to the MCP handler.
- Replace the
JwtSettings:SecretKeywith a cryptographically strong random value (≥ 32 bytes). - Replace
McpSettings:ApiKeywith a securely generated random value. - Store secrets in environment variables, Azure Key Vault, or another secret manager — never hard-code them.
- Add real credential validation in
AuthController(database lookup, password hashing, etc.). - Enable HTTPS-only in production (
UseHsts, proper TLS certificate). - Consider adding rate limiting to the
/api/auth/tokenendpoint to prevent brute-force attacks.
appsettings.json (production placeholder values):
{
"JwtSettings": {
"SecretKey": "CHANGE_ME_TO_A_LONG_RANDOM_SECRET_KEY_AT_LEAST_32_CHARS",
"Issuer": "McpExample",
"Audience": "McpExampleClients",
"ExpiryMinutes": "60"
},
"McpSettings": {
"ApiKey": "CHANGE_ME_TO_A_SECURE_MCP_API_KEY"
}
}appsettings.Development.json (safe development defaults already populated):
{
"JwtSettings": {
"SecretKey": "dev-secret-key-do-not-use-in-production-must-be-at-least-32-chars!",
"Issuer": "McpExample",
"Audience": "McpExampleClients",
"ExpiryMinutes": "60"
},
"McpSettings": {
"ApiKey": "dev-mcp-api-key-do-not-use-in-production"
}
}