Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/agents/backend-engineer.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ dotnet build src/backend/MyProject.slnx && dotnet test src/backend/MyProject.sln
```
Fix failures. Loop until green. Never commit broken code.

## MCP Tools

The API embeds an MCP server (`WebApi/Mcp/`) with dev-only tools for Claude Code. When adding a new service or feature, consider whether an MCP tool would help Claude interact with the running application. MCP tools are simple: one static method per tool, `[McpServerTool]` + `[Description]` attributes, DI-injected parameters, auto-discovered by `WithToolsFromAssembly()`. See existing tools in `WebApi/Mcp/` for the pattern.

## Rules

- Match existing patterns exactly - read sibling files first
Expand Down
4 changes: 4 additions & 0 deletions .claude/agents/fullstack-engineer.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ When modifying existing API contracts:
4. If breaking: update all consumers in the same PR
5. Document the breaking change in the commit body

## MCP Tools

The API embeds an MCP server (`WebApi/Mcp/`) with dev-only tools for Claude Code. When adding a new service or feature, consider whether an MCP tool would help Claude interact with the running application. MCP tools are simple: one static method per tool, `[McpServerTool]` + `[Description]` attributes, DI-injected parameters, auto-discovered by `WithToolsFromAssembly()`. See existing tools in `WebApi/Mcp/` for the pattern.

## Rules

- Always regenerate types after API changes
Expand Down
8 changes: 8 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"netrock": {
"type": "http",
"url": "http://localhost:{INIT_API_PORT}/mcp"
}
}
}
2 changes: 2 additions & 0 deletions FILEMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ Quick-reference for "when you change X, also update Y" and "where does X live?"
| **Connection string config** (change format/name) | Verify `MyProject.AppHost/Program.cs` environment variable mapping still works |
| **`MyProject.ServiceDefaults/Extensions.cs`** | All projects referencing ServiceDefaults, `Program.cs` `AddServiceDefaults()` call |
| **`MyProject.AppHost/Program.cs`** | Verify resource names match `ConnectionStrings:*` and `WithEnvironment` keys match `appsettings.json` option paths |
| **`WebApi/Mcp/*.cs`** (MCP tool classes) | `Program.cs` MCP registration, `ModelContextProtocol.AspNetCore` package |
| **`.mcp.json`** (Claude Code MCP config) | Must match API port; init scripts replace `{INIT_API_PORT}` |
| **`ProblemDetailsAuthorizationHandler`** | `ProblemDetails` shape, `ErrorMessages.Auth` constants, `Program.cs` registration |
| **`CaptchaOptions`** (Infrastructure - Captcha config) | `appsettings.json`, `appsettings.Development.json`, `appsettings.Testing.json`, `TurnstileCaptchaService`, `ServiceCollectionExtensions` |
| **`TurnstileCaptchaService`** (Infrastructure - Captcha service) | `ICaptchaService` interface, `CaptchaOptions`, `AuthController` captcha gate |
Expand Down
19 changes: 19 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,25 @@ The project context files (`CLAUDE.md`, `FILEMAP.md`) plus specialized agents, c

---

## MCP Server (Claude Code integration)

The API embeds an MCP (Model Context Protocol) server at `/mcp`, enabled in Development only. When the API is running (via Aspire), Claude Code can use it to query the database, inspect schema, check health, list users, and manage background jobs - all through the running application's DI container.

The `.mcp.json` at the project root configures Claude Code to connect automatically. Tools available:

| Tool | Description |
|---|---|
| `get-health` | Health status of all dependencies (DB, S3) |
| `query-database` | Execute read-only SQL (SELECT only, 100-row limit) |
| `get-schema` | Database schema from EF Core model |
| `list-users` | Paginated user list with search |
| `list-jobs` | All recurring background jobs |
| `trigger-job` | Trigger immediate job execution |

The MCP endpoint is never exposed in production - gated by `IsDevelopment()`.

---

## Database Migrations

```bash
Expand Down
87 changes: 87 additions & 0 deletions docs/sessions/2026-03-14-mcp-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# MCP Server for Claude Code Integration

**Date**: 2026-03-14
**Scope**: Embed a dev-only MCP (Model Context Protocol) server in WebApi

## Summary

Added an MCP endpoint at `/mcp` in the WebApi project, gated by `IsDevelopment()`. When the API runs locally via Aspire, Claude Code connects automatically (via `.mcp.json`) and gains 6 tools for interacting with the running application - health checks, database queries, schema introspection, user listing, and job management. All tools reuse existing DI services with zero duplication.

## Changes Made

| File | Change | Reason |
|------|--------|--------|
| `Directory.Packages.props` | Added `ModelContextProtocol.AspNetCore` v1.1.0 | NuGet package for MCP server support |
| `MyProject.WebApi.csproj` | Added package reference | WebApi needs the MCP library |
| `MyProject.Infrastructure.csproj` | Added `InternalsVisibleTo` for WebApi | MCP database tools need access to `internal` `MyProjectDbContext` |
| `WebApi/Mcp/HealthTools.cs` | New - `get-health` tool | Structured health status from `HealthCheckService` |
| `WebApi/Mcp/DatabaseTools.cs` | New - `query-database` and `get-schema` tools | Read-only SQL (SELECT only, 100-row limit) and EF Core model introspection |
| `WebApi/Mcp/AdminTools.cs` | New - `list-users` tool | Paginated user list via `IAdminService` |
| `WebApi/Mcp/JobTools.cs` | New - `list-jobs` and `trigger-job` tools | Recurring job management via `IJobManagementService` |
| `WebApi/Program.cs` | Two isolated `IsDevelopment()` blocks | Service registration and endpoint mapping for MCP |
| `.mcp.json` | New - Claude Code MCP config | Auto-connects Claude Code to the MCP endpoint; `{INIT_API_PORT}` replaced by init scripts |
| `FILEMAP.md` | Added MCP entries | Change impact tracking |
| `docs/development.md` | Added MCP Server section | Developer documentation |
| `.claude/agents/backend-engineer.md` | Added MCP Tools section | Agent awareness of MCP tool pattern |
| `.claude/agents/fullstack-engineer.md` | Added MCP Tools section | Agent awareness of MCP tool pattern |

## Decisions & Reasoning

### Dev-only gating

- **Choice**: Gate MCP with `IsDevelopment()`, no auth on the endpoint
- **Alternatives considered**: Always-on with JWT auth; configurable via appsettings
- **Reasoning**: The tools bypass the permission system (raw SQL, unscoped user list) - they're developer conveniences, not a production API surface. Production MCP would be a separate feature with its own auth story.

### Static tool classes with DI injection

- **Choice**: Static methods with `[McpServerTool]` attribute, DI parameters injected by the framework
- **Alternatives considered**: Instance-based tool classes, manual tool registration
- **Reasoning**: `WithToolsFromAssembly()` auto-discovers static tools - no registration boilerplate. DI parameters are resolved per-call from the existing container. Matches the simplest pattern from the ModelContextProtocol library.

### SELECT-only validation in query-database

- **Choice**: Keyword blocklist (INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, TRUNCATE, EXEC, EXECUTE, GRANT, REVOKE) plus SELECT prefix check
- **Alternatives considered**: Read-only transaction, database role with SELECT-only permissions
- **Reasoning**: Simple and effective for dev-time safety. A read-only DB role would be more robust but adds infrastructure complexity for a dev-only tool.

### InternalsVisibleTo for WebApi

- **Choice**: Grant WebApi access to Infrastructure internals
- **Alternatives considered**: Make `MyProjectDbContext` public; create a public wrapper service
- **Reasoning**: `InternalsVisibleTo` is the narrowest change - keeps `MyProjectDbContext` internal for all other consumers. The MCP tools are the only WebApi code that needs direct DbContext access.

## Diagrams

```mermaid
flowchart TD
CC[Claude Code] -->|HTTP| MCP["/mcp endpoint"]
MCP --> HT[HealthTools]
MCP --> DT[DatabaseTools]
MCP --> AT[AdminTools]
MCP --> JT[JobTools]
HT --> HCS[HealthCheckService]
DT --> DB[(MyProjectDbContext)]
AT --> AS[IAdminService]
JT --> JMS[IJobManagementService]

subgraph "WebApi Process (Development only)"
MCP
HT
DT
AT
JT
end

subgraph "Existing DI Container"
HCS
DB
AS
JMS
end
```

## Follow-Up Items

- [ ] Production MCP server - separate feature with JWT auth, permission-scoped tools, rate limiting, audit logging
- [ ] Generator extraction - add `@feature mcp` markers when syncing to netrock-cli templates
1 change: 1 addition & 0 deletions src/backend/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.12.0-beta.2" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.0" />
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="1.1.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.13.2" />
<PackageVersion Include="Serilog" Version="4.3.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
Expand Down
29 changes: 29 additions & 0 deletions src/backend/MyProject.WebApi/Mcp/AdminTools.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.ComponentModel;
using System.Text.Json;
using MyProject.Application.Features.Admin;
using ModelContextProtocol.Server;

namespace MyProject.WebApi.Mcp;

/// <summary>
/// MCP tools for querying user data through the admin service.
/// </summary>
[McpServerToolType]
internal static class AdminTools
{
/// <summary>
/// Lists users with optional search and pagination, using the existing admin service.
/// </summary>
[McpServerTool(Name = "list-users"), Description("List users with optional search and pagination.")]
public static async Task<string> ListUsers(
IAdminService adminService,
[Description("Optional search term to filter by name or email.")] string? search = null,
[Description("Page number (1-based). Defaults to 1.")] int pageNumber = 1,
[Description("Page size. Defaults to 20.")] int pageSize = 20,
CancellationToken cancellationToken = default)
{
var result = await adminService.GetUsersAsync(pageNumber, pageSize, search, cancellationToken);

return JsonSerializer.Serialize(result, JsonSerializerOptions.Web);
}
}
158 changes: 158 additions & 0 deletions src/backend/MyProject.WebApi/Mcp/DatabaseTools.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
using System.ComponentModel;
using System.Text.Json;
using ModelContextProtocol.Server;
using Npgsql;

namespace MyProject.WebApi.Mcp;

/// <summary>
/// MCP tools for querying the database schema and executing read-only SQL.
/// Uses a direct <see cref="NpgsqlConnection"/> from the connection string to avoid
/// coupling WebApi to Infrastructure internals (no DbContext dependency).
/// </summary>
[McpServerToolType]
internal static class DatabaseTools
{
private const int MaxRows = 100;

/// <summary>
/// Executes a read-only SQL query against the database and returns the results as a JSON array.
/// Safety is enforced at the database level via a read-only transaction - PostgreSQL rejects any
/// write operation (INSERT, UPDATE, DELETE, DROP, etc.) regardless of SQL content.
/// Limited to 100 rows via subquery wrapper.
/// </summary>
[McpServerTool(Name = "query-database"), Description("Execute a read-only SQL query. Only SELECT/WITH statements are allowed. Returns up to 100 rows as JSON. Enforced read-only at the database level.")]
public static async Task<string> QueryDatabase(
IConfiguration configuration,
[Description("The SQL query to execute. Must be a SELECT or WITH (CTE) statement.")] string sql,
CancellationToken cancellationToken)
{
var normalized = sql.Trim().TrimEnd(';');
var upper = normalized.ToUpperInvariant();

if (!upper.StartsWith("SELECT") && !upper.StartsWith("WITH"))
{
return JsonSerializer.Serialize(new { error = "Only SELECT and WITH (CTE) queries are allowed." });
}

var connectionString = configuration.GetConnectionString("Database");
if (string.IsNullOrEmpty(connectionString))
{
return JsonSerializer.Serialize(new { error = "Database connection string not configured." });
}

await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);

try
{
// Enforce read-only at the database level - PostgreSQL rejects any write attempt
await using (var readOnlyCmd = new NpgsqlCommand("SET TRANSACTION READ ONLY", connection, transaction))
{
await readOnlyCmd.ExecuteNonQueryAsync(cancellationToken);
}

// Wrap in subquery to guarantee LIMIT applies to outermost result
await using var command = new NpgsqlCommand(
$"SELECT * FROM ({normalized}) AS _q LIMIT {MaxRows}",
connection,
transaction);

await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var results = new List<Dictionary<string, object?>>();

while (await reader.ReadAsync(cancellationToken))
{
var row = new Dictionary<string, object?>();
for (var i = 0; i < reader.FieldCount; i++)
{
row[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i);
}

results.Add(row);
}

return JsonSerializer.Serialize(new { rowCount = results.Count, rows = results }, JsonSerializerOptions.Web);
}
catch (PostgresException ex) when (ex.SqlState == "25006") // READ ONLY SQL TRANSACTION
{
return JsonSerializer.Serialize(new { error = "Write operations are not allowed. Only read-only queries are permitted." });
}
catch (Exception)
{
return JsonSerializer.Serialize(new { error = "Query execution failed. Check your SQL syntax and try again." });
}
}

/// <summary>
/// Returns the database schema by querying PostgreSQL's <c>information_schema</c> directly.
/// Includes tables, columns, types, nullability, primary keys, and foreign keys.
/// </summary>
[McpServerTool(Name = "get-schema"), Description("Get the database schema - tables, columns, types, nullability, primary keys, and foreign keys.")]
public static async Task<string> GetSchema(
IConfiguration configuration,
CancellationToken cancellationToken)
{
var connectionString = configuration.GetConnectionString("Database");
if (string.IsNullOrEmpty(connectionString))
{
return JsonSerializer.Serialize(new { error = "Database connection string not configured." });
}

await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);

const string schemaSql = """
SELECT
c.table_schema,
c.table_name,
c.column_name,
c.data_type,
c.is_nullable,
CASE WHEN kcu.column_name IS NOT NULL THEN true ELSE false END AS is_primary_key
FROM information_schema.columns c
LEFT JOIN information_schema.table_constraints tc
ON tc.table_schema = c.table_schema
AND tc.table_name = c.table_name
AND tc.constraint_type = 'PRIMARY KEY'
LEFT JOIN information_schema.key_column_usage kcu
ON kcu.constraint_name = tc.constraint_name
AND kcu.table_schema = tc.table_schema
AND kcu.table_name = tc.table_name
AND kcu.column_name = c.column_name
WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY c.table_schema, c.table_name, c.ordinal_position
""";

await using var command = new NpgsqlCommand(schemaSql, connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);

var tables = new Dictionary<string, TableSchema>();

while (await reader.ReadAsync(cancellationToken))
{
var tableSchema = reader.GetString(0);
var tableName = reader.GetString(1);
var key = $"{tableSchema}.{tableName}";

if (!tables.TryGetValue(key, out var table))
{
table = new TableSchema(tableSchema, tableName, []);
tables[key] = table;
}

table.Columns.Add(new ColumnSchema(
reader.GetString(2),
reader.GetString(3),
reader.GetString(4) == "YES",
reader.GetBoolean(5)));
}

return JsonSerializer.Serialize(tables.Values, JsonSerializerOptions.Web);
}

private sealed record TableSchema(string Schema, string Table, List<ColumnSchema> Columns);

private sealed record ColumnSchema(string Name, string Type, bool Nullable, bool IsPrimaryKey);
}
38 changes: 38 additions & 0 deletions src/backend/MyProject.WebApi/Mcp/HealthTools.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.ComponentModel;
using System.Text.Json;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using ModelContextProtocol.Server;

namespace MyProject.WebApi.Mcp;

/// <summary>
/// MCP tools for querying application health status.
/// </summary>
[McpServerToolType]
internal static class HealthTools
{
/// <summary>
/// Returns the structured health status of all registered health checks (database, S3, etc.).
/// </summary>
[McpServerTool(Name = "get-health"), Description("Get the health status of all application dependencies (database, S3, etc.)")]
public static async Task<string> GetHealth(HealthCheckService healthCheckService, CancellationToken cancellationToken)
{
var report = await healthCheckService.CheckHealthAsync(cancellationToken);

var result = new
{
status = report.Status.ToString(),
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
description = e.Value.Description,
duration = e.Value.Duration.ToString(),
hasError = e.Value.Exception is not null
}),
totalDuration = report.TotalDuration.ToString()
};

return JsonSerializer.Serialize(result, JsonSerializerOptions.Web);
}
}
Loading
Loading