One process, eleven endpoints.
A separate .NET process that exposes every external integration as an MCP endpoint. Some endpoints proxy upstream MCP servers (vBox, Jira). Most are native — the service implements the tools itself, talking to Microsoft Graph, Tavily, az CLI, our own database, and the file system.
Why a separate service
- Process boundary
- The MCP service can crash, deadlock, or hang on a third-party API without affecting the main agent loop. The API container retries and surfaces a tool error; the user keeps chatting.
- Different cadence
- Adding a new MCP tool ships independently of API rebuilds.
- Smaller footprint
- The MCP image doesn't carry EF Core migrations, the Langfuse client, or the Azure OpenTelemetry exporters. Just the MCP SDK and the few HTTP clients we need.
- Shared DB only
- The MCP service reads from the same Postgres for token lookups (
integration_tokens) but otherwise has its own isolated state.
On-disk layout
mcp-service/
├── backend/src/McpService/
│ ├── Program.cs — endpoint registration, DI, MCP framework wiring
│ ├── Data/
│ │ ├── IIntegrationTokenStore.cs
│ │ ├── IntegrationTokenStore.cs — encrypted token reads
│ │ ├── TokenDecryptor.cs — shared key with API
│ │ └── *TokenRefresher.cs — vBox, Jira, msgraph, sharepoint
│ ├── Health/HealthEndpoint.cs — /health (90 s startup grace)
│ ├── Infrastructure/
│ │ ├── McpServerAttribute.cs — [McpServer("Name")]
│ │ ├── McpServerToolFilter.cs — server-name → tool subset
│ │ └── LenientBoolConverter.cs
│ ├── Proxy/ — vBox + Jira upstream proxies
│ ├── Servers/
│ │ ├── Azure/ — AzureToolFactory, az credential store
│ │ ├── PriceCalculator/ — pricing data loader, calculator
│ │ ├── WebSearch/ — Tavily wrapper
│ │ ├── Schedule/ — job scheduler tools
│ │ ├── TeamsMeetings/ — Graph calendar / meetings / transcripts
│ │ ├── Outlook/ — Graph mail
│ │ ├── Documents/ — DOCX / XLSX / PPTX / PDF read
│ │ ├── SharePointSearch/ — Graph search
│ │ └── ProjectPlan/ — project-plan tools
│ ├── Services/ — cross-cutting
│ ├── Testing/ — mock services for test-mode
│ └── VBox/ — vBox-specific token refresh
└── infra/docker/Dockerfile.mcpEndpoint registration
mcp-service/backend/src/McpService/Program.cs
Each app.MapMcp("/mcp/<name>") call mounts a new MCP endpoint on the same HTTP host (:8001). The server-name filter (McpServerToolFilter) picks which subset of tools is visible at each path.
Tool filtering by attribute
Native tool classes are decorated with [McpServer("Name")]. The classes' methods are decorated with [McpServerTool]. At startup the framework scans the assembly and builds a frozen { toolName → serverName } map.
When a request hits /mcp/<name>, the ConfigureSessionOptions callback inspects the URL path, looks up the server name in PathToServerMap, and dynamically populates McpServerOptions.ToolCollection with only the tools whose attribute matches. The result: one binary, one process, eleven distinct tool surfaces.
Health
/health returns 200 OK once the service is ready. The Docker compose health check uses a 90-second startup grace period because token-refresh probes on cold start can be slow.
Token store
The MCP service does not own user tokens; it reads them from the API's database (integration_tokens table) via IntegrationTokenStore. The shared TOKEN_ENCRYPTION_KEY env var lets it decrypt entries the API encrypted.
For RequiresUserContext calls the API forwards X-User-Id; the store looks up that user's Outlook / Jira / SharePoint token, refreshes it if expired, and uses it on the upstream HTTP call.