diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml index 8644b5e..93e3ec4 100644 --- a/.github/workflows/build-samples.yml +++ b/.github/workflows/build-samples.yml @@ -7,6 +7,7 @@ on: - "dotnet-ai/provider-agnostic-chat-gateway/**" - "dotnet-ai/hybrid-search-ef-core-pgvector/**" - "dotnet-ai/agent-framework-incident-triage/**" + - "dotnet-ai/mcp-server-csharp-dotnet/**" - "dotnet-aspire/orchestrate-distributed-system/**" - "software-architecture/architecture-testing-dotnet/**" - "distributed-systems/transactional-outbox-ef-core/**" @@ -39,6 +40,7 @@ on: - "dotnet-ai/provider-agnostic-chat-gateway/**" - "dotnet-ai/hybrid-search-ef-core-pgvector/**" - "dotnet-ai/agent-framework-incident-triage/**" + - "dotnet-ai/mcp-server-csharp-dotnet/**" - "dotnet-aspire/orchestrate-distributed-system/**" - "software-architecture/architecture-testing-dotnet/**" - "distributed-systems/transactional-outbox-ef-core/**" @@ -2052,3 +2054,51 @@ jobs: dotnet-ai/agent-framework-incident-triage/AgentFrameworkIncidentTriage.slnx --configuration Release --no-build + + test-mcp-server-csharp-dotnet: + name: Test first MCP server sample + runs-on: ubuntu-latest + + defaults: + run: + working-directory: dotnet-ai/mcp-server-csharp-dotnet + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Install .NET 10 SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.401" + + - name: Show selected .NET SDK + run: dotnet --version + + - name: Restore + run: > + dotnet restore + FirstMcpServer.slnx + + - name: Build + run: > + dotnet build + FirstMcpServer.slnx + --configuration Release + --no-restore + + - name: Test deterministic layers + run: > + dotnet test + tests/FirstMcpServer.Tests/FirstMcpServer.Tests.csproj + --configuration Release + --no-build + + - name: Verify MCP stdio end to end + run: > + dotnet run + --project src/McpClientDemo/McpClientDemo.csproj + --configuration Release + --no-build + -- + src/FirstMcpServer/bin/Release/net10.0/FirstMcpServer.dll diff --git a/README.md b/README.md index dc0b307..602f919 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Each sample folder contains a focused implementation of one tutorial topic. The | [`dotnet-ai/provider-agnostic-chat-gateway`](dotnet-ai/provider-agnostic-chat-gateway/) | .NET 10 `Microsoft.Extensions.AI` gateway with allow-listed `IChatClient` providers, bounded conversation state, SSE streaming, safe function calling, and deterministic tests | [Microsoft.Extensions.AI Tutorial: Build a Multi-Provider IChatClient Gateway](https://www.dotnet-guide.com/tutorials/dotnet-ai/provider-agnostic-chat-gateway/) | | [`dotnet-ai/hybrid-search-ef-core-pgvector`](dotnet-ai/hybrid-search-ef-core-pgvector/) | Verified .NET 10 API combining PostgreSQL full-text search (GIN) and pgvector cosine retrieval (HNSW), fused with Reciprocal Rank Fusion | [Hybrid Search in .NET with EF Core 10 and pgvector](https://www.dotnet-guide.com/tutorials/dotnet-ai/hybrid-search-ef-core-pgvector/) | | [`dotnet-ai/agent-framework-incident-triage`](dotnet-ai/agent-framework-incident-triage/) | Evidence-first Microsoft Agent Framework sample with read-only tools, deterministic incident evidence, `AgentSession` reuse, and correlation-vs-causation boundaries | [Microsoft Agent Framework Tutorial in C#: Build a Tool-Using AI Agent](https://www.dotnet-guide.com/tutorials/dotnet-ai/agent-framework-incident-triage/) | +| [`dotnet-ai/mcp-server-csharp-dotnet`](dotnet-ai/mcp-server-csharp-dotnet/) | .NET 10 MCP stdio server with three read-only tools, deterministic JSON-backed data, real client discovery/invocation, protocol error verification, and deterministic tests | [Build Your First MCP Server in C# and .NET](https://www.dotnet-guide.com/tutorials/dotnet-ai/mcp-server-csharp-dotnet/) | | [`dotnet-aspire/orchestrate-distributed-system`](dotnet-aspire/orchestrate-distributed-system/) | Minimal Aspire AppHost coordinating a web project and API with service discovery and startup ordering | [Aspire in .NET: Orchestrate, Run, and Deploy a Distributed System from One App Host](https://www.dotnet-guide.com/tutorials/dotnet-aspire/orchestrate-distributed-system/) | | [`software-architecture/architecture-testing-dotnet`](software-architecture/architecture-testing-dotnet/) | Minimal NetArchTest.eNhancedEdition rule that prevents Domain from depending on outer layers | [Architecture Testing in .NET: Enforce Layer and Module Boundaries with NetArchTest and ArchUnitNET](https://www.dotnet-guide.com/tutorials/software-architecture/architecture-testing-dotnet/) | | [`distributed-systems/transactional-outbox-ef-core`](distributed-systems/transactional-outbox-ef-core/) | Minimal EF Core and SQLite demonstration that saves business state and an outbox message atomically, then publishes it through a one-shot relay | [Transactional Outbox Pattern in .NET with EF Core (.NET 10): Fix the Dual-Write Problem](https://www.dotnet-guide.com/tutorials/distributed-systems/transactional-outbox-ef-core/) | @@ -81,6 +82,15 @@ dotnet restore dotnet test ``` +### First MCP server in C# and .NET + +```powershell +cd dotnet-ai\mcp-server-csharp-dotnet +dotnet restore .\FirstMcpServer.slnx +dotnet build .\FirstMcpServer.slnx --configuration Release --no-restore +dotnet test .\tests\FirstMcpServer.Tests\FirstMcpServer.Tests.csproj --configuration Release --no-build +dotnet run --project .\src\McpClientDemo\McpClientDemo.csproj --configuration Release --no-build -- .\src\FirstMcpServer\bin\Release\net10.0\FirstMcpServer.dll +``` ## Repository structure ```text @@ -94,18 +104,28 @@ tutorials/ | | |-- docs/ | | |-- src/ | | `-- tests/ -| `-- hybrid-search-ef-core-pgvector/ -| |-- HybridSearch.slnx -| |-- docker-compose.yml -| |-- README.md -| |-- src/HybridSearch.Api/ -| |-- tests/HybridSearch.Api.Tests/ -| `-- scripts/verify.ps1| `-- agent-framework-incident-triage/ -| |-- AgentFrameworkIncidentTriage.slnx +| |-- hybrid-search-ef-core-pgvector/ +| | |-- HybridSearch.slnx +| | |-- docker-compose.yml +| | |-- README.md +| | |-- src/HybridSearch.Api/ +| | |-- tests/HybridSearch.Api.Tests/ +| | `-- scripts/verify.ps1 +| |-- agent-framework-incident-triage/ +| | |-- AgentFrameworkIncidentTriage.slnx +| | |-- README.md +| | |-- global.json +| | |-- verified-environment.json +| | |-- docs/ +| | |-- src/ +| | `-- tests/ +| `-- mcp-server-csharp-dotnet/ +| |-- FirstMcpServer.slnx | |-- README.md | |-- global.json | |-- verified-environment.json | |-- docs/ +| |-- scripts/ | |-- src/ | `-- tests/ |-- dotnet-aspire/ diff --git a/dotnet-ai/mcp-server-csharp-dotnet/.gitignore b/dotnet-ai/mcp-server-csharp-dotnet/.gitignore new file mode 100644 index 0000000..b48e083 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/.gitignore @@ -0,0 +1,33 @@ +# Build outputs +bin/ +obj/ +TestResults/ + +# Local verification output +verification-results/ + +# IDE / user state +.vs/ +.vscode/ +.idea/ +*.user +*.suo + +# Secrets / local overrides +.env +.env.* +secrets/ +*.pfx +*.p12 + +# Logs / temp +*.log +*.tmp +*.bak +*~ + +# Builder safety marker (never publish) +.dotnet-guide-builder-marker + +_verification-metadata-patch-backup/ +_publication-hardening-backup/ diff --git a/dotnet-ai/mcp-server-csharp-dotnet/FirstMcpServer.slnx b/dotnet-ai/mcp-server-csharp-dotnet/FirstMcpServer.slnx new file mode 100644 index 0000000..f9fa509 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/FirstMcpServer.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dotnet-ai/mcp-server-csharp-dotnet/README.md b/dotnet-ai/mcp-server-csharp-dotnet/README.md new file mode 100644 index 0000000..d1e2b65 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/README.md @@ -0,0 +1,188 @@ +# MCP in .NET — First stdio MCP Server with Read-Only Tools + +Full tutorial: [Build Your First MCP Server in C# and .NET](https://www.dotnet-guide.com/tutorials/dotnet-ai/mcp-server-csharp-dotnet/) + +This companion sample demonstrates a small, deterministic Model Context Protocol (MCP) workflow in .NET 10. It uses a real MCP client and server over **stdio**, exposes exactly three read-only tools, and verifies discovery and invocation without requiring an LLM, cloud API, API key, database, Docker container, or external service. + +> This sample is an independent educational reference from dotnet-guide.com. It is not an official Microsoft or Model Context Protocol specification/sample. + +## What this sample demonstrates + +- a .NET 10 MCP server hosted with `Microsoft.Extensions.Hosting`; +- `ModelContextProtocol` 2.2.0; +- `AddMcpServer()`, `WithStdioServerTransport()`, and assembly-based tool discovery; +- `[McpServerToolType]` and `[McpServerTool]` tool declarations; +- dependency-injected JSON-backed data stores; +- a real `StdioClientTransport` client; +- `McpClient.CreateAsync(...)`; +- tool discovery with `ListToolsAsync()`; +- direct protocol calls with `CallToolAsync(...)`; +- protocol-level rejection of an unknown tool; +- `McpClientTool` compatibility with `Microsoft.Extensions.AI.AIFunction`; +- deterministic tests that require no model provider. + +The three exposed tools are: + +```text +get_service_health +get_recent_deployment +get_runbook +``` + +The data is synthetic and deterministic. `get_service_health` and `get_recent_deployment` return operational evidence records; `get_runbook` returns investigation guidance. Runbook guidance is deliberately kept conceptually separate from incident evidence. + +## Architecture + +```text +McpClientDemo + | + | stdio + v +FirstMcpServer + | + +-- get_service_health + | -> ServiceHealthStore + | + +-- get_recent_deployment + | -> DeploymentStore + | + +-- get_runbook + -> RunbookStore + | + v + deterministic JSON data +``` + +See [`docs/architecture.md`](docs/architecture.md) for the focused architecture notes and [`docs/protocol-evidence.md`](docs/protocol-evidence.md) for the verification/evidence boundary. + +## Project structure + +```text +FirstMcpServer.slnx +global.json +verified-environment.json +docs/ +scripts/ + verify.ps1 +src/ + FirstMcpServer/ + Data/ + Models/ + Stores/ + Tools/ + McpClientDemo/ +tests/ + FirstMcpServer.Tests/ +``` + +`src/FirstMcpServer` is the MCP server. `src/McpClientDemo` launches that server as a child process over stdio, discovers the tools, invokes all three, and verifies an invalid tool call is rejected. The test project covers the deterministic store and tool layers. + +## Prerequisites + +- .NET SDK **10.0.401** or a compatible .NET 10 environment that honors this sample's `global.json`; +- PowerShell only if you want to run the convenience verification script. + +No OpenAI, Azure OpenAI, OpenRouter, Ollama, API key, database, or Docker installation is required. + +## Restore, build, and test + +From this sample directory: + +```powershell +dotnet restore .\FirstMcpServer.slnx +dotnet build .\FirstMcpServer.slnx --configuration Release --no-restore +dotnet test .\tests\FirstMcpServer.Tests\FirstMcpServer.Tests.csproj --configuration Release --no-build +``` + +The verified local baseline is **13/13 deterministic tests passing**, with a Release build at **0 warnings and 0 errors**. + +## Run the real MCP stdio demo + +Build first, then run: + +```powershell +dotnet run ` + --project .\src\McpClientDemo\McpClientDemo.csproj ` + --configuration Release ` + --no-build ` + -- ` + .\src\FirstMcpServer\bin\Release\net10.0\FirstMcpServer.dll +``` + +The demo launches `FirstMcpServer`, performs the MCP handshake, lists the exposed tools, calls all three tools, deliberately calls `does_not_exist`, confirms the protocol rejects it, and finishes with: + +```text +VERIFY|STDIO_END_TO_END|PASS|... +FINAL|PASS +``` + +For the complete automated local check: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\verify.ps1 +``` + +That script restores, builds, runs the deterministic tests, captures the package graph, runs the real MCP stdio client/server path, and updates the verification documents. + +## Why stdout and stderr matter + +With stdio transport, **stdout belongs to the MCP protocol**. Ordinary application diagnostics must not be mixed into that protocol stream. This sample configures host logging so normal diagnostics go to stderr while MCP messages continue over the transport channel. + +`McpClientDemo` captures the server's stderr separately and prefixes those lines with `SERVER_STDERR|`. Seeing normal hosting or MCP diagnostic lines there is expected and does not mean the verification failed. + +## Environment and secret boundary + +The client does not blindly pass every parent-process environment variable to the child MCP server. It disables broad environment inheritance and uses the SDK's safe default environment set required to launch the child process. + +This sample has no provider key and no `.env` requirement. Do not add real credentials to source, documentation, tests, committed JSON, CI configuration, or pull-request text. + +## Verification + +The committed evidence separates what was actually proved from what is merely possible. + +| Check | Verified result | +| --- | --- | +| .NET SDK baseline | 10.0.401 | +| Target framework | net10.0 | +| `ModelContextProtocol` | 2.2.0 | +| `Microsoft.Extensions.Hosting` | 10.0.12 | +| `xunit.v3.mtp-v2` | 4.0.1 | +| Release build | PASS — 0 warnings, 0 errors | +| Deterministic tests | PASS — 13/13 | +| MCP stdio client/server | PASS | +| Discovered tools | PASS — exactly 3 | +| All three MCP tool calls | PASS | +| Unknown-tool rejection | PASS | +| `McpClientTool` -> `AIFunction` assignability | PASS | +| LLM/cloud API call | Not required / not performed | + +See [`docs/verified-environment.md`](docs/verified-environment.md), [`verified-environment.json`](verified-environment.json), and [`docs/sample-runs.md`](docs/sample-runs.md) for the captured evidence. + +The verified local run date is recorded in those generated evidence files rather than hard-coded here. + +## Important boundary + +This first MCP sample intentionally does **not** implement or verify: + +- Agent Framework integration; +- LLM-driven tool selection; +- Streamable HTTP or remote MCP hosting; +- OAuth, authentication, or authorization; +- MCP resources; +- MCP prompts; +- sampling; +- elicitation; +- MCP Tasks; +- progress notifications; +- write-capable or destructive tools; +- databases or Redis; +- RAG/vector search; +- multi-agent orchestration; +- Docker/Kubernetes/cloud deployment; +- production readiness. + +Those are valid follow-up topics, but adding them here would blur the purpose of this first protocol-focused sample. + +## License + +Repository-owned sample code is covered by the repository's [MIT License](../../LICENSE). diff --git a/dotnet-ai/mcp-server-csharp-dotnet/SCOPE.md b/dotnet-ai/mcp-server-csharp-dotnet/SCOPE.md new file mode 100644 index 0000000..aa8eae8 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/SCOPE.md @@ -0,0 +1,40 @@ +# MCP #1 Scope + +## In scope + +- .NET 10 / SDK 10.0.401 +- ModelContextProtocol 2.2.0 +- stdio transport +- MCP client/server handshake +- attribute-based tool discovery +- dependency injection into MCP tools +- three deterministic read-only tools +- local JSON-backed data stores +- direct MCP tool invocation +- unknown-tool rejection +- deterministic tests +- local end-to-end verification +- CI-suitable verification without secrets + +## Out of scope + +- Agent Framework +- IChatClient model execution +- LLM-driven tool selection +- OpenAI / Azure OpenAI / OpenRouter / Ollama +- Streamable HTTP / remote MCP hosting +- OAuth / authentication / authorization +- MCP resources +- MCP prompts +- sampling +- elicitation +- MCP Tasks +- progress notifications +- databases / Redis +- write-capable or destructive tools +- automatic remediation +- RAG / vector search +- multi-agent orchestration +- Docker / Kubernetes / cloud deployment + +The goal is to teach and verify the MCP protocol boundary before adding model-driven behavior. diff --git a/dotnet-ai/mcp-server-csharp-dotnet/docs/architecture.md b/dotnet-ai/mcp-server-csharp-dotnet/docs/architecture.md new file mode 100644 index 0000000..e37a6fc --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/docs/architecture.md @@ -0,0 +1,25 @@ +# Architecture + +```text +McpClientDemo + | + | stdio + v +FirstMcpServer + | + +-- get_service_health + | -> ServiceHealthStore + | + +-- get_recent_deployment + | -> DeploymentStore + | + +-- get_runbook + -> RunbookStore + | + v + deterministic JSON data +``` + +The MCP server owns trusted tool registration and deterministic local data. The client can discover and invoke only the tools exposed by the server. + +The sample deliberately uses stdio so protocol behavior can be demonstrated without networking, authentication, hosting infrastructure, or an LLM. diff --git a/dotnet-ai/mcp-server-csharp-dotnet/docs/package-graph.txt b/dotnet-ai/mcp-server-csharp-dotnet/docs/package-graph.txt new file mode 100644 index 0000000..8197f9c --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/docs/package-graph.txt @@ -0,0 +1,112 @@ + Determining projects to restore... + All projects are up-to-date for restore. +Project 'FirstMcpServer' has the following package references + [net10.0]: + Top-level Package Requested Resolved + > Microsoft.Extensions.Hosting 10.0.12 10.0.12 + > ModelContextProtocol 2.2.0 2.2.0 + + Transitive Package Resolved + > Microsoft.Extensions.AI.Abstractions 10.8.3 + > Microsoft.Extensions.Caching.Abstractions 10.0.10 + > Microsoft.Extensions.Configuration 10.0.12 + > Microsoft.Extensions.Configuration.Abstractions 10.0.12 + > Microsoft.Extensions.Configuration.Binder 10.0.12 + > Microsoft.Extensions.Configuration.CommandLine 10.0.12 + > Microsoft.Extensions.Configuration.EnvironmentVariables 10.0.12 + > Microsoft.Extensions.Configuration.FileExtensions 10.0.12 + > Microsoft.Extensions.Configuration.Json 10.0.12 + > Microsoft.Extensions.Configuration.UserSecrets 10.0.12 + > Microsoft.Extensions.DependencyInjection 10.0.12 + > Microsoft.Extensions.DependencyInjection.Abstractions 10.0.12 + > Microsoft.Extensions.Diagnostics 10.0.12 + > Microsoft.Extensions.Diagnostics.Abstractions 10.0.12 + > Microsoft.Extensions.FileProviders.Abstractions 10.0.12 + > Microsoft.Extensions.FileProviders.Physical 10.0.12 + > Microsoft.Extensions.FileSystemGlobbing 10.0.12 + > Microsoft.Extensions.Hosting.Abstractions 10.0.12 + > Microsoft.Extensions.Logging 10.0.12 + > Microsoft.Extensions.Logging.Abstractions 10.0.12 + > Microsoft.Extensions.Logging.Configuration 10.0.12 + > Microsoft.Extensions.Logging.Console 10.0.12 + > Microsoft.Extensions.Logging.Debug 10.0.12 + > Microsoft.Extensions.Logging.EventLog 10.0.12 + > Microsoft.Extensions.Logging.EventSource 10.0.12 + > Microsoft.Extensions.Options 10.0.12 + > Microsoft.Extensions.Options.ConfigurationExtensions 10.0.12 + > Microsoft.Extensions.Primitives 10.0.12 + > ModelContextProtocol.Core 2.2.0 + > System.Diagnostics.EventLog 10.0.12 + +Project 'McpClientDemo' has the following package references + [net10.0]: + Top-level Package Requested Resolved + > ModelContextProtocol 2.2.0 2.2.0 + + Transitive Package Resolved + > Microsoft.Extensions.AI.Abstractions 10.8.3 + > Microsoft.Extensions.Caching.Abstractions 10.0.10 + > Microsoft.Extensions.Configuration.Abstractions 10.0.10 + > Microsoft.Extensions.DependencyInjection.Abstractions 10.0.10 + > Microsoft.Extensions.Diagnostics.Abstractions 10.0.10 + > Microsoft.Extensions.FileProviders.Abstractions 10.0.10 + > Microsoft.Extensions.Hosting.Abstractions 10.0.10 + > Microsoft.Extensions.Logging.Abstractions 10.0.10 + > Microsoft.Extensions.Options 10.0.10 + > Microsoft.Extensions.Primitives 10.0.10 + > ModelContextProtocol.Core 2.2.0 + +Project 'FirstMcpServer.Tests' has the following package references + [net10.0]: + Top-level Package Requested Resolved + > xunit.v3.mtp-v2 4.0.1 4.0.1 + + Transitive Package Resolved + > Microsoft.ApplicationInsights 2.23.0 + > Microsoft.Bcl.AsyncInterfaces 6.0.0 + > Microsoft.Extensions.AI.Abstractions 10.8.3 + > Microsoft.Extensions.Caching.Abstractions 10.0.10 + > Microsoft.Extensions.Configuration 10.0.12 + > Microsoft.Extensions.Configuration.Abstractions 10.0.12 + > Microsoft.Extensions.Configuration.Binder 10.0.12 + > Microsoft.Extensions.Configuration.CommandLine 10.0.12 + > Microsoft.Extensions.Configuration.EnvironmentVariables 10.0.12 + > Microsoft.Extensions.Configuration.FileExtensions 10.0.12 + > Microsoft.Extensions.Configuration.Json 10.0.12 + > Microsoft.Extensions.Configuration.UserSecrets 10.0.12 + > Microsoft.Extensions.DependencyInjection 10.0.12 + > Microsoft.Extensions.DependencyInjection.Abstractions 10.0.12 + > Microsoft.Extensions.Diagnostics 10.0.12 + > Microsoft.Extensions.Diagnostics.Abstractions 10.0.12 + > Microsoft.Extensions.FileProviders.Abstractions 10.0.12 + > Microsoft.Extensions.FileProviders.Physical 10.0.12 + > Microsoft.Extensions.FileSystemGlobbing 10.0.12 + > Microsoft.Extensions.Hosting 10.0.12 + > Microsoft.Extensions.Hosting.Abstractions 10.0.12 + > Microsoft.Extensions.Logging 10.0.12 + > Microsoft.Extensions.Logging.Abstractions 10.0.12 + > Microsoft.Extensions.Logging.Configuration 10.0.12 + > Microsoft.Extensions.Logging.Console 10.0.12 + > Microsoft.Extensions.Logging.Debug 10.0.12 + > Microsoft.Extensions.Logging.EventLog 10.0.12 + > Microsoft.Extensions.Logging.EventSource 10.0.12 + > Microsoft.Extensions.Options 10.0.12 + > Microsoft.Extensions.Options.ConfigurationExtensions 10.0.12 + > Microsoft.Extensions.Primitives 10.0.12 + > Microsoft.Testing.Extensions.Telemetry 2.4.0 + > Microsoft.Testing.Extensions.TrxReport.Abstractions 2.4.0 + > Microsoft.Testing.Platform 2.4.0 + > Microsoft.Testing.Platform.MSBuild 2.4.0 + > Microsoft.Win32.Registry 5.0.0 + > ModelContextProtocol 2.2.0 + > ModelContextProtocol.Core 2.2.0 + > System.Diagnostics.EventLog 10.0.12 + > System.Security.AccessControl 6.0.1 + > xunit.analyzers 2.1.0 + > xunit.v3.assert 4.0.1 + > xunit.v3.common 4.0.1 + > xunit.v3.core.mtp-v2 4.0.1 + > xunit.v3.extensibility.core 4.0.1 + > xunit.v3.runner.common 4.0.1 + > xunit.v3.runner.inproc.console 4.0.1 + diff --git a/dotnet-ai/mcp-server-csharp-dotnet/docs/protocol-evidence.md b/dotnet-ai/mcp-server-csharp-dotnet/docs/protocol-evidence.md new file mode 100644 index 0000000..0270019 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/docs/protocol-evidence.md @@ -0,0 +1,12 @@ +# Protocol Evidence + +The verification workflow distinguishes several evidence levels. + +- **COMPILE VERIFIED** — the pinned project/package/API surface compiles. +- **DETERMINISTICALLY TESTED** — store and tool behavior passes local automated tests. +- **MCP STDIO E2E VERIFIED** — a real MCP client launches a real MCP server, discovers the tools, invokes them over stdio, and observes expected results. +- **CI VERIFIED** — reserved for a later GitHub Actions run after publication. + +The sample does not claim LLM-driven tool selection, HTTP transport, authentication, production readiness, or coverage of all MCP features. + +`McpClientTool` being assignable to `Microsoft.Extensions.AI.AIFunction` is recorded as an extension point only; the sample does not use an LLM. diff --git a/dotnet-ai/mcp-server-csharp-dotnet/docs/sample-runs.md b/dotnet-ai/mcp-server-csharp-dotnet/docs/sample-runs.md new file mode 100644 index 0000000..003abe3 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/docs/sample-runs.md @@ -0,0 +1,23 @@ +# Sample Runs + +Captured from the successful local MCP stdio verification on 2026-10-05. + +` ext +VERIFY|SERVER_DLL|PASS|\src\FirstMcpServer\bin\Release\net10.0\FirstMcpServer.dll +RUNTIME|TransportType|ModelContextProtocol.Client.StdioClientTransport +RUNTIME|ClientType|ModelContextProtocol.Client.McpClientImpl +RUNTIME|ToolCount|3 +TOOL|get_recent_deployment|ModelContextProtocol.Client.McpClientTool|Returns deterministic synthetic evidence for the most recent known deployment of a service. +TOOL|get_runbook|ModelContextProtocol.Client.McpClientTool|Returns deterministic runbook guidance for a known service. Guidance is not incident evidence. +TOOL|get_service_health|ModelContextProtocol.Client.McpClientTool|Returns deterministic synthetic service-health evidence for a known service. +VERIFY|TOOL_DISCOVERY|PASS|get_recent_deployment,get_runbook,get_service_health +VERIFY|AIFUNCTION_ASSIGNABILITY|PASS|McpClientTool derives from AIFunction +VERIFY|SERVICE_HEALTH_CALL|PASS|{"evidenceId":"E-101","service":"checkout-api","observedAt":"2026-09-24T14:12:00Z","status":"degraded","errorRate":18.7,"errorSpikeStarted":"2026-09-24T14:09:00Z","databasePoolUtilization":96} +VERIFY|DEPLOYMENT_CALL|PASS|{"evidenceId":"E-201","service":"checkout-api","deployment":"deploy-1842","deploymentStatus":"succeeded","completedAt":"2026-09-24T14:02:00Z","previousDeployment":"deploy-1839","changeSummary":"pricing-rule refresh and structured logging update","rollbackPerformed":false} +VERIFY|RUNBOOK_CALL|PASS|{"runbookId":"RB-CHECKOUT-03","service":"checkout-api","title":"Checkout API degraded-service investigation","guidance":["Compare error-rate onset with recent deployments.","Inspect database connection-pool utilization.","Check connection counts before and after deployment.","Review traces for timeout or connection-acquisition failures.","Compare configuration with the previous known-good release."],"caution":"Runbook guidance suggests investigation steps; it does not establish root cause."} +VERIFY|UNKNOWN_TOOL_REJECTION|PASS|McpProtocolException: Request failed (remote): Unknown tool: 'does_not_exist' +VERIFY|STDIO_END_TO_END|PASS|Client launched server, discovered exactly three tools, and invoked all three over MCP stdio +FINAL|PASS +` + +Server diagnostic lines are omitted from this curated view. The raw local output remains in erification-results/mcp-client-output.txt. \ No newline at end of file diff --git a/dotnet-ai/mcp-server-csharp-dotnet/docs/verified-environment.md b/dotnet-ai/mcp-server-csharp-dotnet/docs/verified-environment.md new file mode 100644 index 0000000..3241281 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/docs/verified-environment.md @@ -0,0 +1,32 @@ +# Verified Environment + +- Verification timestamp: 2026-10-05T21:36:33+05:30 +- .NET SDK: 10.0.401 +- Target framework: net10.0 +- ModelContextProtocol: 2.2.0 +- Microsoft.Extensions.Hosting: 10.0.12 +- xunit.v3.mtp-v2: 4.0.1 +- Transport: stdio +- Release build: PASS +- Deterministic tests: 13/13 passed; 0 failed; 0 skipped +- MCP stdio end-to-end: PASS +- Runtime transport type: ModelContextProtocol.Client.StdioClientTransport +- Runtime client type: ModelContextProtocol.Client.McpClientImpl +- Discovered tool count: 3 + +## Verified behaviors + +- real MCP client/server handshake; +- exactly three tool definitions discovered; +- get_service_health invoked over MCP stdio; +- get_recent_deployment invoked over MCP stdio; +- get_runbook invoked over MCP stdio; +- unknown tool rejected; +- McpClientTool assignable to AIFunction; +- no LLM or cloud API required. + +## Evidence boundary + +This verification does not establish HTTP transport behavior, authentication/OAuth, LLM-driven tool selection, Agent Framework integration, production readiness, or coverage of MCP features outside this sample. + +Raw local outputs are under erification-results/ and are intentionally gitignored. \ No newline at end of file diff --git a/dotnet-ai/mcp-server-csharp-dotnet/global.json b/dotnet-ai/mcp-server-csharp-dotnet/global.json new file mode 100644 index 0000000..195b536 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/global.json @@ -0,0 +1,10 @@ +{ + "sdk": { + "version": "10.0.401", + "rollForward": "latestPatch", + "allowPrerelease": false + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/dotnet-ai/mcp-server-csharp-dotnet/scripts/verify.ps1 b/dotnet-ai/mcp-server-csharp-dotnet/scripts/verify.ps1 new file mode 100644 index 0000000..336e039 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/scripts/verify.ps1 @@ -0,0 +1,388 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +$RequiredSdk = '10.0.401' +$TargetFramework = 'net10.0' +$McpVersion = '2.2.0' +$HostingVersion = '10.0.12' +$XunitVersion = '4.0.1' + +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$SolutionPath = Join-Path $Root 'FirstMcpServer.slnx' +$ServerProject = Join-Path $Root 'src\FirstMcpServer\FirstMcpServer.csproj' +$ClientProject = Join-Path $Root 'src\McpClientDemo\McpClientDemo.csproj' +$TestProject = Join-Path $Root 'tests\FirstMcpServer.Tests\FirstMcpServer.Tests.csproj' +$ServerDll = Join-Path $Root 'src\FirstMcpServer\bin\Release\net10.0\FirstMcpServer.dll' + +$Results = Join-Path $Root 'verification-results' +$PackageGraphFile = Join-Path $Root 'docs\package-graph.txt' +$RawClientFile = Join-Path $Results 'mcp-client-output.txt' +$RawTestFile = Join-Path $Results 'test-output.txt' +$RawBuildFile = Join-Path $Results 'build-output.txt' +$VerifiedMd = Join-Path $Root 'docs\verified-environment.md' +$SampleRunsMd = Join-Path $Root 'docs\sample-runs.md' +$VerifiedJson = Join-Path $Root 'verified-environment.json' + +function Write-Section([string]$Text) { + Write-Host '' + Write-Host ('=' * 78) -ForegroundColor DarkGray + Write-Host $Text -ForegroundColor Cyan + Write-Host ('=' * 78) -ForegroundColor DarkGray +} + +function Write-Step([string]$Text) { + Write-Host "[STEP] $Text" -ForegroundColor Yellow +} + +function Write-Pass([string]$Text) { + Write-Host "[PASS] $Text" -ForegroundColor Green +} + +function Write-Fail([string]$Text) { + Write-Host "[FAIL] $Text" -ForegroundColor Red +} + +function Write-Utf8NoBom([string]$Path, [string]$Content) { + $parent = Split-Path -Parent $Path + if ($parent -and -not (Test-Path $parent)) { + New-Item -ItemType Directory -Force -Path $parent | Out-Null + } + [System.IO.File]::WriteAllText($Path, $Content, [System.Text.UTF8Encoding]::new($false)) +} + +function Invoke-Native { + param( + [Parameter(Mandatory=$true)][string]$FilePath, + [Parameter(Mandatory=$false)][string[]]$Arguments = @(), + [Parameter(Mandatory=$false)][string]$WorkingDirectory = $Root, + [Parameter(Mandatory=$false)][switch]$Capture, + [Parameter(Mandatory=$false)][string]$CapturePath + ) + + Push-Location $WorkingDirectory + try { + if ($Capture) { + $output = & $FilePath @Arguments 2>&1 + $exitCode = $LASTEXITCODE + $text = ($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine + + if ($CapturePath) { + Write-Utf8NoBom $CapturePath ($text + [Environment]::NewLine) + } + + $output | ForEach-Object { Write-Host $_ } + + if ($exitCode -ne 0) { + throw "Command failed with exit code ${exitCode}: $FilePath $($Arguments -join ' ')" + } + + return $text + } + + & $FilePath @Arguments + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + throw "Command failed with exit code ${exitCode}: $FilePath $($Arguments -join ' ')" + } + } + finally { + Pop-Location + } +} + +Write-Section 'FIRST MCP SERVER - LOCAL VERIFICATION' +Write-Host "Root: $Root" +Write-Host "Required SDK: $RequiredSdk" +Write-Host "Target framework: $TargetFramework" +Write-Host "ModelContextProtocol: $McpVersion" +Write-Host "Microsoft.Extensions.Hosting: $HostingVersion" +Write-Host "xunit.v3.mtp-v2: $XunitVersion" +Write-Host 'Transport: stdio' +Write-Host 'LLM/API calls: NONE' + +try { + if (Test-Path $Results) { + Remove-Item -Recurse -Force $Results + } + New-Item -ItemType Directory -Force -Path $Results | Out-Null + + Write-Step 'Check required .NET SDK' + $installedSdks = & dotnet --list-sdks 2>&1 + if ($LASTEXITCODE -ne 0) { + throw 'dotnet --list-sdks failed.' + } + + if (-not ($installedSdks | Where-Object { $_ -match "^$([regex]::Escape($RequiredSdk))\s" })) { + throw "Required .NET SDK $RequiredSdk is not installed." + } + + $selectedSdk = Invoke-Native -FilePath 'dotnet' -Arguments @('--version') -Capture + if ($selectedSdk.Trim() -ne $RequiredSdk) { + throw "global.json selected '$($selectedSdk.Trim())' instead of '$RequiredSdk'." + } + Write-Pass ".NET SDK $RequiredSdk selected" + + Write-Step 'Restore solution' + Invoke-Native -FilePath 'dotnet' -Arguments @('restore', $SolutionPath) + Write-Pass 'Restore PASS' + + Write-Step 'Build solution in Release' + $buildText = Invoke-Native -FilePath 'dotnet' -Arguments @('build', $SolutionPath, '-c', 'Release', '--no-restore') -Capture -CapturePath $RawBuildFile + if (-not (Test-Path $ServerDll)) { + throw "Expected server DLL was not produced: $ServerDll" + } + Write-Pass 'Release build PASS' + + Write-Step 'Run deterministic test project' + $testText = Invoke-Native -FilePath 'dotnet' -Arguments @('test', $TestProject, '-c', 'Release', '--no-build') -Capture -CapturePath $RawTestFile + Write-Pass 'Deterministic tests PASS' + + $testTotal = $null + $testPassed = $null + $testFailed = $null + $testSkipped = $null + + # .NET 10 + Microsoft Testing Platform emits: + # Test run summary: Passed! + # total: 13 + # failed: 0 + # succeeded: 13 + # skipped: 0 + # Parse the four summary lines independently so CRLF/wording changes in the + # heading do not erase otherwise valid counts. + $testLines = $testText -split '\r?\n' + + foreach ($line in $testLines) { + if ($null -eq $testTotal -and $line -match '^\s*total:\s*(\d+)\s*$') { + $testTotal = [int]$Matches[1] + continue + } + if ($null -eq $testFailed -and $line -match '^\s*failed:\s*(\d+)\s*$') { + $testFailed = [int]$Matches[1] + continue + } + if ($null -eq $testPassed -and $line -match '^\s*succeeded:\s*(\d+)\s*$') { + $testPassed = [int]$Matches[1] + continue + } + if ($null -eq $testSkipped -and $line -match '^\s*skipped:\s*(\d+)\s*$') { + $testSkipped = [int]$Matches[1] + continue + } + } + + if ($null -eq $testTotal -or + $null -eq $testFailed -or + $null -eq $testPassed -or + $null -eq $testSkipped) { + throw 'Deterministic tests passed, but the verifier could not parse the Microsoft Testing Platform summary counts.' + } + + if ($testFailed -ne 0 -or ($testPassed + $testFailed + $testSkipped) -ne $testTotal) { + throw "Parsed test counts are inconsistent: total=$testTotal passed=$testPassed failed=$testFailed skipped=$testSkipped." + } + + Write-Step 'Capture package graph' + Invoke-Native -FilePath 'dotnet' -Arguments @('list', $SolutionPath, 'package', '--include-transitive') -Capture -CapturePath $PackageGraphFile | Out-Null + Write-Pass 'Package graph captured' + + Write-Step 'Run real MCP stdio client/server end-to-end verification' + $clientText = Invoke-Native -FilePath 'dotnet' -Arguments @( + 'run', + '--project', $ClientProject, + '-c', 'Release', + '--no-build', + '--', + $ServerDll + ) -Capture -CapturePath $RawClientFile + + $requiredPassKeys = @( + 'SERVER_DLL', + 'TOOL_DISCOVERY', + 'AIFUNCTION_ASSIGNABILITY', + 'SERVICE_HEALTH_CALL', + 'DEPLOYMENT_CALL', + 'RUNBOOK_CALL', + 'UNKNOWN_TOOL_REJECTION', + 'STDIO_END_TO_END' + ) + + foreach ($key in $requiredPassKeys) { + if ($clientText -notmatch "(?m)^VERIFY\|$([regex]::Escape($key))\|PASS\|") { + throw "Expected PASS marker was not found for $key." + } + } + + if ($clientText -notmatch '(?m)^FINAL\|PASS$') { + throw 'McpClientDemo did not emit FINAL|PASS.' + } + + Write-Pass 'MCP stdio E2E PASS' + + $clientLines = $clientText -split '\r?\n' + + $runtimeTransportLine = $clientLines | + Where-Object { $_ -match '^RUNTIME\|TransportType\|(.+)$' } | + Select-Object -First 1 + $runtimeClientLine = $clientLines | + Where-Object { $_ -match '^RUNTIME\|ClientType\|(.+)$' } | + Select-Object -First 1 + $toolCountLine = $clientLines | + Where-Object { $_ -match '^RUNTIME\|ToolCount\|(\d+)$' } | + Select-Object -First 1 + + if (-not $runtimeTransportLine -or -not $runtimeClientLine -or -not $toolCountLine) { + throw 'MCP E2E passed, but runtime metadata lines could not be parsed.' + } + + [void]($runtimeTransportLine -match '^RUNTIME\|TransportType\|(.+)$') + $runtimeTransport = $Matches[1].Trim() + + [void]($runtimeClientLine -match '^RUNTIME\|ClientType\|(.+)$') + $runtimeClient = $Matches[1].Trim() + + [void]($toolCountLine -match '^RUNTIME\|ToolCount\|(\d+)$') + $toolCount = [int]$Matches[1] + + if ($toolCount -ne 3) { + throw "Expected exactly 3 discovered tools, but parsed $toolCount." + } + + $evidenceLines = $clientLines | + Where-Object { $_ -match '^(RUNTIME|TOOL|VERIFY|FINAL)\|' } + + $timestamp = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ssK') + $dateOnly = (Get-Date).ToString('yyyy-MM-dd') + + $testSummaryText = "$testPassed/$testTotal passed; $testFailed failed; $testSkipped skipped" + + $verifiedMdContent = @" +# Verified Environment + +- Verification timestamp: $timestamp +- .NET SDK: $RequiredSdk +- Target framework: $TargetFramework +- ModelContextProtocol: $McpVersion +- Microsoft.Extensions.Hosting: $HostingVersion +- xunit.v3.mtp-v2: $XunitVersion +- Transport: stdio +- Release build: PASS +- Deterministic tests: $testSummaryText +- MCP stdio end-to-end: PASS +- Runtime transport type: $runtimeTransport +- Runtime client type: $runtimeClient +- Discovered tool count: $toolCount + +## Verified behaviors + +- real MCP client/server handshake; +- exactly three tool definitions discovered; +- `get_service_health` invoked over MCP stdio; +- `get_recent_deployment` invoked over MCP stdio; +- `get_runbook` invoked over MCP stdio; +- unknown tool rejected; +- `McpClientTool` assignable to `AIFunction`; +- no LLM or cloud API required. + +## Evidence boundary + +This verification does not establish HTTP transport behavior, authentication/OAuth, LLM-driven tool selection, Agent Framework integration, production readiness, or coverage of MCP features outside this sample. + +Raw local outputs are under `verification-results/` and are intentionally gitignored. +"@ + Write-Utf8NoBom $VerifiedMd $verifiedMdContent + + $sampleRun = @" +# Sample Runs + +Captured from the successful local MCP stdio verification on $dateOnly. + +```text +$($evidenceLines -join [Environment]::NewLine) +``` + +Server diagnostic lines are omitted from this curated view. The raw local output remains in `verification-results/mcp-client-output.txt`. +"@ + Write-Utf8NoBom $SampleRunsMd $sampleRun + + $jsonObject = [ordered]@{ + status = 'PASS' + verificationTimestamp = $timestamp + sdk = $RequiredSdk + targetFramework = $TargetFramework + packages = [ordered]@{ + ModelContextProtocol = $McpVersion + MicrosoftExtensionsHosting = $HostingVersion + xunitV3MtpV2 = $XunitVersion + } + transport = 'stdio' + tests = [ordered]@{ + status = 'PASS' + total = $testTotal + passed = $testPassed + failed = $testFailed + skipped = $testSkipped + } + runtime = [ordered]@{ + transportType = $runtimeTransport + clientType = $runtimeClient + discoveredToolCount = $toolCount + } + checks = [ordered]@{ + restore = 'PASS' + releaseBuild = 'PASS' + deterministicTests = 'PASS' + toolDiscovery = 'PASS' + aiFunctionAssignability = 'PASS' + serviceHealthCall = 'PASS' + deploymentCall = 'PASS' + runbookCall = 'PASS' + unknownToolRejection = 'PASS' + stdioEndToEnd = 'PASS' + } + exclusions = @( + 'LLM-driven tool selection', + 'Agent Framework', + 'Streamable HTTP', + 'OAuth/authentication/authorization', + 'MCP resources/prompts/sampling/elicitation/tasks', + 'write-capable tools', + 'database/Redis', + 'cloud deployment', + 'production readiness' + ) + } + + Write-Utf8NoBom $VerifiedJson ($jsonObject | ConvertTo-Json -Depth 8) + + Write-Section 'FINAL RESULT' + Write-Host 'FIRST MCP SERVER VERIFICATION: PASS' -ForegroundColor Green + Write-Host '' + Write-Host "Project: $Root" + Write-Host "Build: PASS" + Write-Host "Tests: $testSummaryText" + Write-Host "MCP stdio E2E: PASS" + Write-Host "Discovered tools: $toolCount" + Write-Host "Verified JSON: $VerifiedJson" + Write-Host "Verified Markdown: $VerifiedMd" + Write-Host "Sample run: $SampleRunsMd" + Write-Host "Package graph: $PackageGraphFile" + Write-Host '' + Write-Host 'Please send the complete console output back to ChatGPT.' -ForegroundColor Cyan + exit 0 +} +catch { + Write-Section 'FINAL RESULT' + Write-Fail 'FIRST MCP SERVER VERIFICATION: FAIL' + Write-Host $_.Exception.Message -ForegroundColor Red + Write-Host '' + Write-Host "Project has been preserved for diagnosis: $Root" + Write-Host "Local raw outputs, if created: $Results" + Write-Host '' + Write-Host 'Please send the complete console output back to ChatGPT.' -ForegroundColor Cyan + exit 1 +} diff --git a/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Data/deployments.json b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Data/deployments.json new file mode 100644 index 0000000..89cd32b --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Data/deployments.json @@ -0,0 +1,22 @@ +[ + { + "evidenceId": "E-201", + "service": "checkout-api", + "deployment": "deploy-1842", + "deploymentStatus": "succeeded", + "completedAt": "2026-09-24T14:02:00Z", + "previousDeployment": "deploy-1839", + "changeSummary": "pricing-rule refresh and structured logging update", + "rollbackPerformed": false + }, + { + "evidenceId": "E-202", + "service": "catalog-api", + "deployment": "deploy-3104", + "deploymentStatus": "succeeded", + "completedAt": "2026-09-24T13:45:00Z", + "previousDeployment": "deploy-3099", + "changeSummary": "catalog metadata refresh", + "rollbackPerformed": false + } +] diff --git a/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Data/runbooks.json b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Data/runbooks.json new file mode 100644 index 0000000..b33bb3b --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Data/runbooks.json @@ -0,0 +1,26 @@ +[ + { + "runbookId": "RB-CHECKOUT-03", + "service": "checkout-api", + "title": "Checkout API degraded-service investigation", + "guidance": [ + "Compare error-rate onset with recent deployments.", + "Inspect database connection-pool utilization.", + "Check connection counts before and after deployment.", + "Review traces for timeout or connection-acquisition failures.", + "Compare configuration with the previous known-good release." + ], + "caution": "Runbook guidance suggests investigation steps; it does not establish root cause." + }, + { + "runbookId": "RB-CATALOG-02", + "service": "catalog-api", + "title": "Catalog API health investigation", + "guidance": [ + "Confirm upstream dependency health.", + "Review recent deployment changes.", + "Inspect request latency and error-rate trends." + ], + "caution": "Runbook guidance is operational guidance, not incident evidence." + } +] diff --git a/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Data/services.json b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Data/services.json new file mode 100644 index 0000000..2ca9712 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Data/services.json @@ -0,0 +1,20 @@ +[ + { + "evidenceId": "E-101", + "service": "checkout-api", + "observedAt": "2026-09-24T14:12:00Z", + "status": "degraded", + "errorRate": 18.7, + "errorSpikeStarted": "2026-09-24T14:09:00Z", + "databasePoolUtilization": 96.0 + }, + { + "evidenceId": "E-102", + "service": "catalog-api", + "observedAt": "2026-09-24T14:12:00Z", + "status": "healthy", + "errorRate": 0.2, + "errorSpikeStarted": "", + "databasePoolUtilization": 41.0 + } +] diff --git a/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/FirstMcpServer.csproj b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/FirstMcpServer.csproj new file mode 100644 index 0000000..067f056 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/FirstMcpServer.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + PreserveNewest + + + + diff --git a/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Models/DeploymentRecord.cs b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Models/DeploymentRecord.cs new file mode 100644 index 0000000..d070253 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Models/DeploymentRecord.cs @@ -0,0 +1,13 @@ +namespace FirstMcpServer.Models; + +public sealed record DeploymentRecord +{ + public string EvidenceId { get; init; } = string.Empty; + public string Service { get; init; } = string.Empty; + public string Deployment { get; init; } = string.Empty; + public string DeploymentStatus { get; init; } = string.Empty; + public string CompletedAt { get; init; } = string.Empty; + public string PreviousDeployment { get; init; } = string.Empty; + public string ChangeSummary { get; init; } = string.Empty; + public bool RollbackPerformed { get; init; } +} diff --git a/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Models/RunbookRecord.cs b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Models/RunbookRecord.cs new file mode 100644 index 0000000..263bf0a --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Models/RunbookRecord.cs @@ -0,0 +1,10 @@ +namespace FirstMcpServer.Models; + +public sealed record RunbookRecord +{ + public string RunbookId { get; init; } = string.Empty; + public string Service { get; init; } = string.Empty; + public string Title { get; init; } = string.Empty; + public IReadOnlyList Guidance { get; init; } = []; + public string Caution { get; init; } = string.Empty; +} diff --git a/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Models/ServiceHealthRecord.cs b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Models/ServiceHealthRecord.cs new file mode 100644 index 0000000..b0ac035 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Models/ServiceHealthRecord.cs @@ -0,0 +1,12 @@ +namespace FirstMcpServer.Models; + +public sealed record ServiceHealthRecord +{ + public string EvidenceId { get; init; } = string.Empty; + public string Service { get; init; } = string.Empty; + public string ObservedAt { get; init; } = string.Empty; + public string Status { get; init; } = string.Empty; + public double ErrorRate { get; init; } + public string ErrorSpikeStarted { get; init; } = string.Empty; + public double DatabasePoolUtilization { get; init; } +} diff --git a/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Program.cs b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Program.cs new file mode 100644 index 0000000..afbfd69 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Program.cs @@ -0,0 +1,24 @@ +using FirstMcpServer.Stores; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// stdio is the MCP protocol channel. Keep ordinary diagnostics off stdout. +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(options => +{ + options.LogToStandardErrorThreshold = LogLevel.Trace; +}); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithToolsFromAssembly(); + +await builder.Build().RunAsync(); diff --git a/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Stores/DeploymentStore.cs b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Stores/DeploymentStore.cs new file mode 100644 index 0000000..869d077 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Stores/DeploymentStore.cs @@ -0,0 +1,34 @@ +using FirstMcpServer.Models; + +namespace FirstMcpServer.Stores; + +public sealed class DeploymentStore +{ + private readonly IReadOnlyList _records; + + public DeploymentStore() + : this(JsonStoreLoader.Load("deployments.json")) + { + } + + private DeploymentStore(IReadOnlyList records) + { + _records = records; + } + + public static DeploymentStore FromRecords(IEnumerable records) + => new(records.ToArray()); + + public DeploymentRecord? GetRecent(string serviceName) + { + string normalized = Normalize(serviceName); + + return _records + .Where(x => string.Equals(x.Service, normalized, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(x => x.CompletedAt, StringComparer.Ordinal) + .FirstOrDefault(); + } + + private static string Normalize(string value) + => value?.Trim() ?? string.Empty; +} diff --git a/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Stores/JsonStoreLoader.cs b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Stores/JsonStoreLoader.cs new file mode 100644 index 0000000..2e6734f --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Stores/JsonStoreLoader.cs @@ -0,0 +1,26 @@ +using System.Text.Json; + +namespace FirstMcpServer.Stores; + +internal static class JsonStoreLoader +{ + internal static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public static IReadOnlyList Load(string fileName) + { + string path = Path.Combine(AppContext.BaseDirectory, "Data", fileName); + + if (!File.Exists(path)) + { + throw new FileNotFoundException($"Required deterministic data file was not found: {path}", path); + } + + using FileStream stream = File.OpenRead(path); + return JsonSerializer.Deserialize>(stream, Options) ?? []; + } +} diff --git a/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Stores/RunbookStore.cs b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Stores/RunbookStore.cs new file mode 100644 index 0000000..3d81bd6 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Stores/RunbookStore.cs @@ -0,0 +1,32 @@ +using FirstMcpServer.Models; + +namespace FirstMcpServer.Stores; + +public sealed class RunbookStore +{ + private readonly IReadOnlyList _records; + + public RunbookStore() + : this(JsonStoreLoader.Load("runbooks.json")) + { + } + + private RunbookStore(IReadOnlyList records) + { + _records = records; + } + + public static RunbookStore FromRecords(IEnumerable records) + => new(records.ToArray()); + + public RunbookRecord? Get(string serviceName) + { + string normalized = Normalize(serviceName); + + return _records.FirstOrDefault( + x => string.Equals(x.Service, normalized, StringComparison.OrdinalIgnoreCase)); + } + + private static string Normalize(string value) + => value?.Trim() ?? string.Empty; +} diff --git a/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Stores/ServiceHealthStore.cs b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Stores/ServiceHealthStore.cs new file mode 100644 index 0000000..8a692b5 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Stores/ServiceHealthStore.cs @@ -0,0 +1,34 @@ +using FirstMcpServer.Models; + +namespace FirstMcpServer.Stores; + +public sealed class ServiceHealthStore +{ + private readonly IReadOnlyList _records; + + public ServiceHealthStore() + : this(JsonStoreLoader.Load("services.json")) + { + } + + private ServiceHealthStore(IReadOnlyList records) + { + _records = records; + } + + public static ServiceHealthStore FromRecords(IEnumerable records) + => new(records.ToArray()); + + public ServiceHealthRecord? Get(string serviceName) + { + string normalized = Normalize(serviceName); + + return _records + .Where(x => string.Equals(x.Service, normalized, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(x => x.ObservedAt, StringComparer.Ordinal) + .FirstOrDefault(); + } + + private static string Normalize(string value) + => value?.Trim() ?? string.Empty; +} diff --git a/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Tools/DeploymentTools.cs b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Tools/DeploymentTools.cs new file mode 100644 index 0000000..cdebcd3 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Tools/DeploymentTools.cs @@ -0,0 +1,28 @@ +using FirstMcpServer.Stores; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace FirstMcpServer.Tools; + +[McpServerToolType] +public static class DeploymentTools +{ + [McpServerTool( + Name = "get_recent_deployment", + ReadOnly = true, + Destructive = false, + Idempotent = true, + OpenWorld = false)] + [Description("Returns deterministic synthetic evidence for the most recent known deployment of a service.")] + public static string GetRecentDeployment( + DeploymentStore store, + [Description("Service name, for example checkout-api.")] string serviceName) + { + string normalized = serviceName?.Trim() ?? string.Empty; + var record = store.GetRecent(normalized); + + return record is null + ? ToolJson.Serialize(new { found = false, service = normalized }) + : ToolJson.Serialize(record); + } +} diff --git a/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Tools/RunbookTools.cs b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Tools/RunbookTools.cs new file mode 100644 index 0000000..f89896c --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Tools/RunbookTools.cs @@ -0,0 +1,28 @@ +using FirstMcpServer.Stores; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace FirstMcpServer.Tools; + +[McpServerToolType] +public static class RunbookTools +{ + [McpServerTool( + Name = "get_runbook", + ReadOnly = true, + Destructive = false, + Idempotent = true, + OpenWorld = false)] + [Description("Returns deterministic runbook guidance for a known service. Guidance is not incident evidence.")] + public static string GetRunbook( + RunbookStore store, + [Description("Service name, for example checkout-api.")] string serviceName) + { + string normalized = serviceName?.Trim() ?? string.Empty; + var record = store.Get(normalized); + + return record is null + ? ToolJson.Serialize(new { found = false, service = normalized }) + : ToolJson.Serialize(record); + } +} diff --git a/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Tools/ServiceHealthTools.cs b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Tools/ServiceHealthTools.cs new file mode 100644 index 0000000..3e73348 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Tools/ServiceHealthTools.cs @@ -0,0 +1,28 @@ +using FirstMcpServer.Stores; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace FirstMcpServer.Tools; + +[McpServerToolType] +public static class ServiceHealthTools +{ + [McpServerTool( + Name = "get_service_health", + ReadOnly = true, + Destructive = false, + Idempotent = true, + OpenWorld = false)] + [Description("Returns deterministic synthetic service-health evidence for a known service.")] + public static string GetServiceHealth( + ServiceHealthStore store, + [Description("Service name, for example checkout-api.")] string serviceName) + { + string normalized = serviceName?.Trim() ?? string.Empty; + var record = store.Get(normalized); + + return record is null + ? ToolJson.Serialize(new { found = false, service = normalized }) + : ToolJson.Serialize(record); + } +} diff --git a/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Tools/ToolJson.cs b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Tools/ToolJson.cs new file mode 100644 index 0000000..27ea76d --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/src/FirstMcpServer/Tools/ToolJson.cs @@ -0,0 +1,15 @@ +using System.Text.Json; + +namespace FirstMcpServer.Tools; + +internal static class ToolJson +{ + internal static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public static string Serialize(T value) + => JsonSerializer.Serialize(value, Options); +} diff --git a/dotnet-ai/mcp-server-csharp-dotnet/src/McpClientDemo/McpClientDemo.csproj b/dotnet-ai/mcp-server-csharp-dotnet/src/McpClientDemo/McpClientDemo.csproj new file mode 100644 index 0000000..b085608 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/src/McpClientDemo/McpClientDemo.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/dotnet-ai/mcp-server-csharp-dotnet/src/McpClientDemo/Program.cs b/dotnet-ai/mcp-server-csharp-dotnet/src/McpClientDemo/Program.cs new file mode 100644 index 0000000..dfd7ada --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/src/McpClientDemo/Program.cs @@ -0,0 +1,143 @@ +using Microsoft.Extensions.AI; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +static void Check(bool condition, string key, string detail) +{ + if (!condition) + { + Console.WriteLine($"VERIFY|{key}|FAIL|{detail}"); + throw new InvalidOperationException($"Verification failed: {key}: {detail}"); + } + + Console.WriteLine($"VERIFY|{key}|PASS|{detail}"); +} + +static string TextOf(CallToolResult result) + => result.Content.OfType().FirstOrDefault()?.Text ?? string.Empty; + +if (args.Length != 1) +{ + Console.Error.WriteLine("Usage: McpClientDemo "); + return 2; +} + +string serverDll = Path.GetFullPath(args[0]); +Check(File.Exists(serverDll), "SERVER_DLL", serverDll); + +var safeEnvironment = StdioClientTransportOptions.GetDefaultEnvironmentVariables(); + +var transport = new StdioClientTransport(new StdioClientTransportOptions +{ + Name = "DOTNET-GUIDE-First-MCP-Server", + Command = "dotnet", + Arguments = [serverDll], + WorkingDirectory = Path.GetDirectoryName(serverDll), + InheritEnvironmentVariables = false, + EnvironmentVariables = safeEnvironment, + ShutdownTimeout = TimeSpan.FromSeconds(10), + StandardErrorLines = line => Console.WriteLine($"SERVER_STDERR|{line}") +}); + +Console.WriteLine($"RUNTIME|TransportType|{transport.GetType().FullName}"); + +await using McpClient client = await McpClient.CreateAsync(transport); +Console.WriteLine($"RUNTIME|ClientType|{client.GetType().FullName}"); + +IList tools = await client.ListToolsAsync(); +Console.WriteLine($"RUNTIME|ToolCount|{tools.Count}"); + +foreach (var tool in tools.OrderBy(t => t.Name, StringComparer.Ordinal)) +{ + Console.WriteLine($"TOOL|{tool.Name}|{tool.GetType().FullName}|{tool.Description}"); +} + +string[] expectedTools = +[ + "get_service_health", + "get_recent_deployment", + "get_runbook" +]; + +string[] discovered = tools.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray(); + +Check( + tools.Count == expectedTools.Length && + expectedTools.All(name => tools.Any(t => string.Equals(t.Name, name, StringComparison.Ordinal))), + "TOOL_DISCOVERY", + string.Join(",", discovered)); + +Check( + typeof(AIFunction).IsAssignableFrom(typeof(McpClientTool)), + "AIFUNCTION_ASSIGNABILITY", + "McpClientTool derives from AIFunction"); + +CallToolResult healthResult = await client.CallToolAsync( + "get_service_health", + new Dictionary { ["serviceName"] = "checkout-api" }, + cancellationToken: CancellationToken.None); +string healthText = TextOf(healthResult); +Check( + healthResult.IsError is not true && + healthText.Contains("\"evidenceId\":\"E-101\"", StringComparison.Ordinal) && + healthText.Contains("\"status\":\"degraded\"", StringComparison.Ordinal) && + healthText.Contains("\"errorRate\":18.7", StringComparison.Ordinal), + "SERVICE_HEALTH_CALL", + healthText); + +CallToolResult deploymentResult = await client.CallToolAsync( + "get_recent_deployment", + new Dictionary { ["serviceName"] = "checkout-api" }, + cancellationToken: CancellationToken.None); +string deploymentText = TextOf(deploymentResult); +Check( + deploymentResult.IsError is not true && + deploymentText.Contains("\"evidenceId\":\"E-201\"", StringComparison.Ordinal) && + deploymentText.Contains("\"deployment\":\"deploy-1842\"", StringComparison.Ordinal) && + deploymentText.Contains("\"previousDeployment\":\"deploy-1839\"", StringComparison.Ordinal), + "DEPLOYMENT_CALL", + deploymentText); + +CallToolResult runbookResult = await client.CallToolAsync( + "get_runbook", + new Dictionary { ["serviceName"] = "checkout-api" }, + cancellationToken: CancellationToken.None); +string runbookText = TextOf(runbookResult); +Check( + runbookResult.IsError is not true && + runbookText.Contains("\"runbookId\":\"RB-CHECKOUT-03\"", StringComparison.Ordinal) && + runbookText.Contains("does not establish root cause", StringComparison.Ordinal), + "RUNBOOK_CALL", + runbookText); + +bool unknownRejected = false; +string unknownDetail = string.Empty; + +try +{ + CallToolResult unknown = await client.CallToolAsync( + "does_not_exist", + new Dictionary(), + cancellationToken: CancellationToken.None); + + if (unknown.IsError is true) + { + unknownRejected = true; + unknownDetail = TextOf(unknown); + } + else + { + unknownDetail = "Unknown tool unexpectedly returned a non-error result."; + } +} +catch (Exception ex) +{ + unknownRejected = true; + unknownDetail = $"{ex.GetType().Name}: {ex.Message}"; +} + +Check(unknownRejected, "UNKNOWN_TOOL_REJECTION", unknownDetail); + +Console.WriteLine("VERIFY|STDIO_END_TO_END|PASS|Client launched server, discovered exactly three tools, and invoked all three over MCP stdio"); +Console.WriteLine("FINAL|PASS"); +return 0; diff --git a/dotnet-ai/mcp-server-csharp-dotnet/tests/FirstMcpServer.Tests/DeploymentStoreTests.cs b/dotnet-ai/mcp-server-csharp-dotnet/tests/FirstMcpServer.Tests/DeploymentStoreTests.cs new file mode 100644 index 0000000..351d118 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/tests/FirstMcpServer.Tests/DeploymentStoreTests.cs @@ -0,0 +1,49 @@ +using FirstMcpServer.Models; +using FirstMcpServer.Stores; + +namespace FirstMcpServer.Tests; + +public sealed class DeploymentStoreTests +{ + private static DeploymentStore CreateStore() + => DeploymentStore.FromRecords( + [ + new DeploymentRecord + { + EvidenceId = "E-201", + Service = "checkout-api", + Deployment = "deploy-1842", + DeploymentStatus = "succeeded", + CompletedAt = "2026-09-24T14:02:00Z", + PreviousDeployment = "deploy-1839", + ChangeSummary = "pricing-rule refresh and structured logging update", + RollbackPerformed = false + } + ]); + + [Fact] + public void GetRecent_ReturnsKnownDeployment() + { + var record = CreateStore().GetRecent("checkout-api"); + + Assert.NotNull(record); + Assert.Equal("E-201", record.EvidenceId); + Assert.Equal("deploy-1842", record.Deployment); + Assert.Equal("deploy-1839", record.PreviousDeployment); + } + + [Fact] + public void GetRecent_NormalizesWhitespaceAndCase() + { + var record = CreateStore().GetRecent(" CHECKOUT-API "); + + Assert.NotNull(record); + Assert.Equal("checkout-api", record.Service); + } + + [Fact] + public void GetRecent_ReturnsNullForUnknownService() + { + Assert.Null(CreateStore().GetRecent("missing-api")); + } +} diff --git a/dotnet-ai/mcp-server-csharp-dotnet/tests/FirstMcpServer.Tests/FirstMcpServer.Tests.csproj b/dotnet-ai/mcp-server-csharp-dotnet/tests/FirstMcpServer.Tests/FirstMcpServer.Tests.csproj new file mode 100644 index 0000000..7c0bb54 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/tests/FirstMcpServer.Tests/FirstMcpServer.Tests.csproj @@ -0,0 +1,25 @@ + + + + Exe + net10.0 + enable + enable + true + false + true + + + + + + + + + + + + + + + diff --git a/dotnet-ai/mcp-server-csharp-dotnet/tests/FirstMcpServer.Tests/RunbookStoreTests.cs b/dotnet-ai/mcp-server-csharp-dotnet/tests/FirstMcpServer.Tests/RunbookStoreTests.cs new file mode 100644 index 0000000..8c5e0c5 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/tests/FirstMcpServer.Tests/RunbookStoreTests.cs @@ -0,0 +1,44 @@ +using FirstMcpServer.Models; +using FirstMcpServer.Stores; + +namespace FirstMcpServer.Tests; + +public sealed class RunbookStoreTests +{ + private static RunbookStore CreateStore() + => RunbookStore.FromRecords( + [ + new RunbookRecord + { + RunbookId = "RB-CHECKOUT-03", + Service = "checkout-api", + Title = "Checkout API degraded-service investigation", + Guidance = ["Inspect database connection-pool utilization."], + Caution = "Guidance does not establish root cause." + } + ]); + + [Fact] + public void Get_ReturnsKnownRunbook() + { + var record = CreateStore().Get("checkout-api"); + + Assert.NotNull(record); + Assert.Equal("RB-CHECKOUT-03", record.RunbookId); + } + + [Fact] + public void Get_NormalizesWhitespaceAndCase() + { + var record = CreateStore().Get(" CHECKOUT-API "); + + Assert.NotNull(record); + Assert.Equal("checkout-api", record.Service); + } + + [Fact] + public void Get_ReturnsNullForUnknownService() + { + Assert.Null(CreateStore().Get("missing-api")); + } +} diff --git a/dotnet-ai/mcp-server-csharp-dotnet/tests/FirstMcpServer.Tests/ServiceHealthStoreTests.cs b/dotnet-ai/mcp-server-csharp-dotnet/tests/FirstMcpServer.Tests/ServiceHealthStoreTests.cs new file mode 100644 index 0000000..4f99098 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/tests/FirstMcpServer.Tests/ServiceHealthStoreTests.cs @@ -0,0 +1,47 @@ +using FirstMcpServer.Models; +using FirstMcpServer.Stores; + +namespace FirstMcpServer.Tests; + +public sealed class ServiceHealthStoreTests +{ + private static ServiceHealthStore CreateStore() + => ServiceHealthStore.FromRecords( + [ + new ServiceHealthRecord + { + EvidenceId = "E-101", + Service = "checkout-api", + ObservedAt = "2026-09-24T14:12:00Z", + Status = "degraded", + ErrorRate = 18.7, + ErrorSpikeStarted = "2026-09-24T14:09:00Z", + DatabasePoolUtilization = 96 + } + ]); + + [Fact] + public void Get_ReturnsKnownService() + { + var record = CreateStore().Get("checkout-api"); + + Assert.NotNull(record); + Assert.Equal("E-101", record.EvidenceId); + Assert.Equal("degraded", record.Status); + } + + [Fact] + public void Get_NormalizesWhitespaceAndCase() + { + var record = CreateStore().Get(" CHECKOUT-API "); + + Assert.NotNull(record); + Assert.Equal("checkout-api", record.Service); + } + + [Fact] + public void Get_ReturnsNullForUnknownService() + { + Assert.Null(CreateStore().Get("missing-api")); + } +} diff --git a/dotnet-ai/mcp-server-csharp-dotnet/tests/FirstMcpServer.Tests/ToolTests.cs b/dotnet-ai/mcp-server-csharp-dotnet/tests/FirstMcpServer.Tests/ToolTests.cs new file mode 100644 index 0000000..6cedb21 --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/tests/FirstMcpServer.Tests/ToolTests.cs @@ -0,0 +1,87 @@ +using FirstMcpServer.Models; +using FirstMcpServer.Stores; +using FirstMcpServer.Tools; + +namespace FirstMcpServer.Tests; + +public sealed class ToolTests +{ + [Fact] + public void ServiceHealthTool_ReturnsEvidence() + { + var store = ServiceHealthStore.FromRecords( + [ + new ServiceHealthRecord + { + EvidenceId = "E-101", + Service = "checkout-api", + ObservedAt = "2026-09-24T14:12:00Z", + Status = "degraded", + ErrorRate = 18.7, + ErrorSpikeStarted = "2026-09-24T14:09:00Z", + DatabasePoolUtilization = 96 + } + ]); + + string result = ServiceHealthTools.GetServiceHealth(store, "checkout-api"); + + Assert.Contains("\"evidenceId\":\"E-101\"", result); + Assert.Contains("\"status\":\"degraded\"", result); + } + + [Fact] + public void DeploymentTool_ReturnsEvidence() + { + var store = DeploymentStore.FromRecords( + [ + new DeploymentRecord + { + EvidenceId = "E-201", + Service = "checkout-api", + Deployment = "deploy-1842", + DeploymentStatus = "succeeded", + CompletedAt = "2026-09-24T14:02:00Z", + PreviousDeployment = "deploy-1839", + ChangeSummary = "pricing-rule refresh", + RollbackPerformed = false + } + ]); + + string result = DeploymentTools.GetRecentDeployment(store, "checkout-api"); + + Assert.Contains("\"evidenceId\":\"E-201\"", result); + Assert.Contains("\"previousDeployment\":\"deploy-1839\"", result); + } + + [Fact] + public void RunbookTool_ReturnsGuidance() + { + var store = RunbookStore.FromRecords( + [ + new RunbookRecord + { + RunbookId = "RB-CHECKOUT-03", + Service = "checkout-api", + Title = "Checkout API degraded-service investigation", + Guidance = ["Inspect database connection-pool utilization."], + Caution = "Guidance does not establish root cause." + } + ]); + + string result = RunbookTools.GetRunbook(store, "checkout-api"); + + Assert.Contains("\"runbookId\":\"RB-CHECKOUT-03\"", result); + Assert.Contains("does not establish root cause", result); + } + + [Fact] + public void Tool_ReturnsFoundFalseForUnknownService() + { + var store = ServiceHealthStore.FromRecords([]); + + string result = ServiceHealthTools.GetServiceHealth(store, "missing-api"); + + Assert.Contains("\"found\":false", result); + Assert.Contains("\"service\":\"missing-api\"", result); + } +} diff --git a/dotnet-ai/mcp-server-csharp-dotnet/verified-environment.json b/dotnet-ai/mcp-server-csharp-dotnet/verified-environment.json new file mode 100644 index 0000000..7ffeb1c --- /dev/null +++ b/dotnet-ai/mcp-server-csharp-dotnet/verified-environment.json @@ -0,0 +1,47 @@ +{ + "status": "PASS", + "verificationTimestamp": "2026-10-05T21:36:33+05:30", + "sdk": "10.0.401", + "targetFramework": "net10.0", + "packages": { + "ModelContextProtocol": "2.2.0", + "MicrosoftExtensionsHosting": "10.0.12", + "xunitV3MtpV2": "4.0.1" + }, + "transport": "stdio", + "tests": { + "status": "PASS", + "total": 13, + "passed": 13, + "failed": 0, + "skipped": 0 + }, + "runtime": { + "transportType": "ModelContextProtocol.Client.StdioClientTransport", + "clientType": "ModelContextProtocol.Client.McpClientImpl", + "discoveredToolCount": 3 + }, + "checks": { + "restore": "PASS", + "releaseBuild": "PASS", + "deterministicTests": "PASS", + "toolDiscovery": "PASS", + "aiFunctionAssignability": "PASS", + "serviceHealthCall": "PASS", + "deploymentCall": "PASS", + "runbookCall": "PASS", + "unknownToolRejection": "PASS", + "stdioEndToEnd": "PASS" + }, + "exclusions": [ + "LLM-driven tool selection", + "Agent Framework", + "Streamable HTTP", + "OAuth/authentication/authorization", + "MCP resources/prompts/sampling/elicitation/tasks", + "write-capable tools", + "database/Redis", + "cloud deployment", + "production readiness" + ] +} \ No newline at end of file