summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AgentSandboxMcp.slnx1
-rw-r--r--Directory.Packages.props5
-rw-r--r--README.md33
-rw-r--r--config/agent.yaml5
-rw-r--r--config/locales/en.json3
-rw-r--r--config/locales/ja.json3
-rw-r--r--doc/Design.md22
-rw-r--r--doc/Specifications.md21
-rw-r--r--plans/ImplementationPlan.md11
-rw-r--r--plans/RequirementTraceability.md19
-rw-r--r--scripts/Acquire-McpConformance.ps136
-rw-r--r--scripts/Publish-Release.ps18
-rw-r--r--scripts/Run-McpConformance.ps127
-rw-r--r--scripts/Run-McpTasksConformance.ps157
-rw-r--r--src/Agent.App/Configuration/AgentConfiguration.cs4
-rw-r--r--src/Agent.App/Configuration/ConfigurationLoader.cs2
-rw-r--r--src/Agent.App/Logging/McpHostLogObserver.cs76
-rw-r--r--src/Agent.App/Logging/RollingFileLog.cs55
-rw-r--r--src/Agent.App/Logging/RollingFileWriter.cs50
-rw-r--r--src/Agent.App/Program.cs30
-rw-r--r--src/Agent.Core/Tasks/TaskRegistry.cs36
-rw-r--r--src/Agent.Mcp/Agent.Mcp.csproj1
-rw-r--r--src/Agent.Mcp/AgentTools.cs65
-rw-r--r--src/Agent.Mcp/McpHost.cs93
-rw-r--r--src/Agent.Mcp/McpHostObservability.cs37
-rw-r--r--src/Agent.Mcp/PersistentMcpTaskStore.cs190
-rw-r--r--src/Agent.Mcp/QueuedTaskWorker.cs10
-rw-r--r--tests/Agent.Core.Tests/Agent.Core.Tests.csproj1
-rw-r--r--tests/Agent.Core.Tests/McpHttpTests.cs28
-rw-r--r--tests/Agent.Core.Tests/McpStdioTests.cs35
-rw-r--r--tests/Agent.Core.Tests/ObservabilityTests.cs46
-rw-r--r--tests/Agent.Core.Tests/PersistentMcpTaskStoreTests.cs36
-rw-r--r--tests/Agent.Core.Tests/SharedFakeOllamaServer.cs55
-rw-r--r--tests/Agent.Core.Tests/TaskRegistryTests.cs13
-rw-r--r--tests/Agent.Mcp.TestHost/Agent.Mcp.TestHost.csproj8
-rw-r--r--tests/Agent.Mcp.TestHost/ConformanceTools.cs92
-rw-r--r--tests/Agent.Mcp.TestHost/Program.cs72
37 files changed, 1188 insertions, 98 deletions
diff --git a/AgentSandboxMcp.slnx b/AgentSandboxMcp.slnx
index 6a82cfe..36c52dc 100644
--- a/AgentSandboxMcp.slnx
+++ b/AgentSandboxMcp.slnx
@@ -7,6 +7,7 @@
<Project Path="src/Agent.Windows/Agent.Windows.csproj" />
</Folder>
<Folder Name="/tests/">
+ <Project Path="tests/Agent.Mcp.TestHost/Agent.Mcp.TestHost.csproj" />
<Project Path="tests/Agent.Core.Tests/Agent.Core.Tests.csproj" />
<Project Path="tests/Agent.Planner.Tests/Agent.Planner.Tests.csproj" />
<Project Path="tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj" />
diff --git a/Directory.Packages.props b/Directory.Packages.props
index f8a11fd..820818b 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -6,8 +6,9 @@
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.298" />
- <PackageVersion Include="ModelContextProtocol" Version="1.4.1" />
- <PackageVersion Include="ModelContextProtocol.AspNetCore" Version="1.4.1" />
+ <PackageVersion Include="ModelContextProtocol" Version="2.0.0" />
+ <PackageVersion Include="ModelContextProtocol.AspNetCore" Version="2.0.0" />
+ <PackageVersion Include="ModelContextProtocol.Extensions.Tasks" Version="2.0.0" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
</ItemGroup>
diff --git a/README.md b/README.md
index e2a350c..e66f2c9 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# AI Agent Sandbox MCP Server v1
-Windows 11上でlocal Ollamaを利用し、明示的に承認したFilesystem、Process、Network操作だけをAppContainer内で実行するMCP Serverです。MCP公開Toolは `agent_run` と `agent_status` の2つです。
+Windows 11上でlocal Ollamaを利用し、明示的に承認したFilesystem、Process、Network操作だけをAppContainer内で実行するMCP Serverです。MCP `2026-07-28`のstateless HTTPと公式Tasks Extensionに対応し、MCP公開Toolは `agent_run` と `agent_status` の2つです。
## 動作要件
@@ -30,10 +30,29 @@ ollama serve
`administrator=true`、`bfe_engine_open=true`、`wfp_write_transaction=true`、`wfp_filter_add_delete=true` が必要です。probeはdynamic WFP filterを追加後すぐ削除し、残留させません。
+`serve`も待受開始前に同じprobeを必ず実行します。失敗時はendpointをbindせず、失敗項目・Win32 error・対処方法をstderrと`logs/application.log`へ出してexit code 2で終了します。
+
## agent.yaml
Filesystem pathは能力ごとに指定します。未指定能力はdenyです。
+MCP Taskの保持とlog出力は次のtyped settingで変更できます。
+
+```yaml
+mcp:
+ tasks:
+ time_to_live_hours: 24
+ poll_interval_ms: 1000
+logging:
+ directory: logs
+ level: information # information / warning / error / fatal
+ http_requests: true
+ max_file_bytes: 10485760
+ retained_files: 5
+```
+
+Task状態は`state/mcp-tasks/`へatomic保存されます。再起動時の未完了Taskは`failed/server_restarted`となり、自動再実行されません。
+
```yaml
sandbox:
filesystem:
@@ -91,24 +110,29 @@ WFP操作のため、stdioではMCP Client自体を管理者PowerShellから起å
## Streamable HTTP
-管理者PowerShellでserverを起動します。
+管理者PowerShellでserverを起動します。`MCPリクエストを受け付けられます`とendpointが表示されてから接続してください。
```powershell
.\agent-mcp.exe serve --config .\agent.yaml --lang ja --transport http
```
-Client endpointは `http://127.0.0.1:8080/mcp` です。設定できるbindは `127.0.0.1` または `::1` だけです。Streamable HTTP POSTだけを使用し、GET SSE、`/sse`、`/message` Legacy SSEは利用できません。
+Client endpointは `http://127.0.0.1:8080/mcp` です。設定できるbindは `127.0.0.1` または `::1` だけです。HTTPはMCP `2026-07-28` statelessで`Mcp-Session-Id`を使用しません。SDKの旧initialize fallbackは維持しますが、GET SSE、`/sse`、`/message` Legacy SSEは利用できません。
+
+Tasks対応clientは`agent_run`を公式MCP Taskとして受け取り、`tasks/get`と`tasks/cancel`を使用できます。非対応clientは従来どおりqueued textから`task_id`を取得し、`agent_status`でpollします。`session_id`はAgent会話IDでありHTTP transport sessionではありません。
## LogとRecovery
- `logs/application.log`: 起動・診断・管理event
- `logs/llm.jsonl`: LLM request/response、token usage、tool result
- `logs/audit.jsonl`: Task ID付きfile/process/policy/ACL/WFP/recovery event
+- `logs/http.jsonl`: connection/trace ID、実socketの接続元IP/port、method/path/status/duration、MCP routing header
- `state/journals/`: crash recovery journal。正常終了後は空
- `state/backups/`: 大容量file transaction backup。正常終了後は空
log directoryは起動ユーザーだけがアクセスできるprotected DACLへ設定されます。起動時はMCP受付前にRecoveryを実行し、復旧不能なhash競合があれば起動しません。
+HTTP logは`X-Forwarded-For`を接続元として信用せず、Authorization、Cookie、query、body、prompt、画像、Base64を保存しません。stateless HTTPでは永続接続を主張せず、request started/completedとして記録します。
+
正常停止後、`agent-mcp.exe` descendant、追加ACL、WFP filter、loopback exemption、`state/backups`、`state/journals` が残っていないことを確認してください。loopback exemptionは `CheckNetIsolation.exe LoopbackExempt -s` で確認できます。
## よくあるエラー
@@ -126,6 +150,9 @@ log directoryは起動ユーザーだけがアクセスできるprotected DACLã�
dotnet restore AgentSandboxMcp.slnx
dotnet build AgentSandboxMcp.slnx -c Release --no-restore
dotnet test AgentSandboxMcp.slnx -c Release --no-build --no-restore
+.\scripts\Acquire-McpConformance.ps1
+# 管理者権限でserverを起動後:
+.\scripts\Run-McpConformance.ps1 -Url http://127.0.0.1:8080/mcp
.\scripts\Publish-Release.ps1
```
diff --git a/config/agent.yaml b/config/agent.yaml
index 05a5237..26238b5 100644
--- a/config/agent.yaml
+++ b/config/agent.yaml
@@ -10,6 +10,9 @@ mcp:
max_count: 4
max_bytes_each: 10485760
max_total_bytes: 20971520
+ tasks:
+ time_to_live_hours: 24
+ poll_interval_ms: 1000
agent:
user_system_prompt: prompts/user-system.md
max_iterations: 128
@@ -55,5 +58,7 @@ skills:
directory: skills
logging:
directory: logs
+ level: information
+ http_requests: true
max_file_bytes: 10485760
retained_files: 5
diff --git a/config/locales/en.json b/config/locales/en.json
index 4fcdb95..ebc69a1 100644
--- a/config/locales/en.json
+++ b/config/locales/en.json
@@ -1,5 +1,8 @@
{
"app.starting": "Starting agent.",
+ "app.preflight_failed": "Sandbox preflight failed before bind: operation={operation}; error_code={error_code}; administrator={administrator}; BFE={bfe}; WFP transaction={transaction}; WFP add/delete={filters}. Start an elevated terminal, ensure the Base Filtering Engine service is running, and run 'agent-mcp check'.",
+ "app.ready.http": "Agent is ready for MCP requests. endpoint={endpoint}; protocol={protocol}; stateless={stateless}; legacy_initialize_fallback={fallback}; pid={pid}; logs={logs}",
+ "app.ready.stdio": "Agent stdio transport is ready. protocol={protocol}; pid={pid}; logs={logs}",
"check.success": "Configuration and runtime checks passed.",
"check.failure": "Check failed: {message}",
"check.wfp_admin_required": "Windows administrator elevation is required for WFP task isolation. Start agent-mcp.exe from an elevated terminal.",
diff --git a/config/locales/ja.json b/config/locales/ja.json
index 0bdbca8..94348a2 100644
--- a/config/locales/ja.json
+++ b/config/locales/ja.json
@@ -1,5 +1,8 @@
{
"app.starting": "エージェントを起動しています。",
+ "app.preflight_failed": "待受開始前のSandbox診断に失敗しました: operation={operation}; error_code={error_code}; administrator={administrator}; BFE={bfe}; WFP transaction={transaction}; WFP add/delete={filters}。管理者権限のターミナルで起動し、Base Filtering Engineサービスを確認してから 'agent-mcp check' を実行してください。",
+ "app.ready.http": "MCPリクエストを受け付けられます。endpoint={endpoint}; protocol={protocol}; stateless={stateless}; legacy_initialize_fallback={fallback}; pid={pid}; logs={logs}",
+ "app.ready.stdio": "stdio transportでMCPリクエストを受け付けられます。protocol={protocol}; pid={pid}; logs={logs}",
"check.success": "設定と実行環境の確認に成功しました。",
"check.failure": "確認に失敗しました: {message}",
"check.wfp_admin_required": "WFPによるTask分離にはWindows管理者権限が必要です。管理者として起動したターミナルからagent-mcp.exeを起動してください。",
diff --git a/doc/Design.md b/doc/Design.md
index d3ace2a..1ca8cbe 100644
--- a/doc/Design.md
+++ b/doc/Design.md
@@ -198,6 +198,28 @@ message: <localized status>
result: <final natural-language answer; completed only>
```
`result`は選択localeで生成する。内部Tool名、policy internals、chain-of-thoughtは返さない。
+
+### 5.6 MCP 2026-07-28 / dual-era境界
+
+- 公式C# SDK `2.0.0`へwire処理を委譲する。独自に`server/discover`、必須`_meta`、MCP routing header、modern error codeを再実装しない。
+- HTTP transportは`Stateless = true`。modern HTTP requestごとにserver contextを作成し、`Mcp-Session-Id`を使用しない。
+- SDKのlegacy initialize fallbackは維持する。Legacy SSEはSDK defaultの無効状態を使い、独自GET拒否middlewareは置かない。
+- Agent Sessionは`session_id`で明示的に管理するapplication stateであり、transport sessionとは独立する。
+
+### 5.7 Tasks Extension
+
+- `ModelContextProtocol.Extensions.Tasks 2.0.0`の`WithTasks` filterを`agent_run`だけoptional task対応として登録する。`agent_status`は同期Toolのまま維持する。
+- `PersistentMcpTaskStore`は`state/mcp-tasks/<task-id>.json`へWriteThrough後のatomic moveで保存する。directoryのuser-only DACLはApp層で作成する。
+- store作成IDをAgent Task IDとして使用し、Agent registryのterminal completionを待ってSDK filterへ最終結果を返す。非Tasks callは従来どおりenqueue直後に返す。
+- `tasks/cancel`のrequest cancellationをAgent TaskのCancellationTokenへ接続する。restart recoveryではworking/input-requiredを`failed`、reasonを`server_restarted`とする。
+- 既定TTLは24時間、poll intervalは1000 ms。期限切れfileはaccess時に削除する。
+
+### 5.8 起動preflightとHTTP request log
+
+- Appはtransport構築前に`WindowsPlatformCheck`を実行する。administrator/BFE/WFP probeが1つでも失敗した場合はhostをbuild/startせずfatalとする。
+- `StartAsync`完了後にだけ`IMcpHostObserver.Ready`を通知するため、「起動中」と「接続可能」を区別できる。
+- HTTP middlewareはsocket endpointとrouting metadataだけを`logs/http.jsonl`へJSON Linesで保存する。forwarded header、credential header、query、payloadは保存しない。
+- `RollingFileWriter`をapplication/http logで共用し、同じsize/retention policyでrotationする。stdioの運用出力はstderrだけへ送る。
---
## 6. Ollama統合設計
### 6.1 APIモデル
diff --git a/doc/Specifications.md b/doc/Specifications.md
index 2aa19c5..e723d03 100644
--- a/doc/Specifications.md
+++ b/doc/Specifications.md
@@ -173,6 +173,25 @@ agent-mcp check [--config <path>] [--lang <locale>]
CLIは起動方式と表示言語を変更するためのI/Fであり、SandboxPolicyやOllama sampling値を個別overrideするI/Fにしない。
+### 5.7 MCP 2026-07-28とTasks Extension
+
+| ID | 要件 |
+| --- | --- |
+| MCP-2026-01 | HTTPの主経路はMCP `2026-07-28`とし、`server/discover`およびrequest単位の必須`_meta`を処理する。 |
+| MCP-2026-02 | HTTPはstatelessとし、`Mcp-Session-Id`を発行・要求しない。既存`session_id`はAgent会話IDでありtransport sessionではない。 |
+| MCP-2026-03 | 旧initialize clientは公式SDKのdual-era fallbackで受け付けるが、Legacy HTTP+SSE endpointは提供しない。 |
+| MCP-2026-04 | `io.modelcontextprotocol/tasks`を公式Extensionとして宣言する。対応clientの`agent_run`はTask result、非対応clientは従来のqueued textを受け取る。 |
+| MCP-2026-05 | MCP Task IDとAgent Task IDは同一UUIDで相関し、`tasks/get`と`agent_status`のどちらからも同じ作業を追跡可能にする。 |
+| MCP-2026-06 | Agentの`queued/planning/running`はMCP `working`へ、terminal状態は対応するTask terminal状態へ写像する。 |
+| MCP-2026-07 | MCP Taskは設定可能なTTL/poll intervalを持ち、process restart後の未完了Taskは`failed/server_restarted`へ確定する。実行は再開しない。 |
+
+### 5.8 起動・HTTP観測性
+
+- `serve`はbind前に管理者権限、BFE、WFP transaction、filter add/deleteを検査し、不足時は具体的なerrorと対処をstderrへ出してexit code 2で終了しなければならない。
+- ready messageは実際のtransport開始後だけ出力し、HTTPではendpoint/protocol/stateless/PID/log path、stdioではprotocol/PID/log pathを含める。
+- HTTP request logは実socketのremote/local endpoint、method、path、status、duration、標準MCP routing headerを記録可能とする。
+- `Authorization`、`Cookie`、query、body、prompt、画像、Base64をrequest logへ記録してはならず、`X-Forwarded-For`を接続元として信用してはならない。
+
---
## 6. Ollama機能仕様
@@ -437,7 +456,7 @@ Context要約を開始する利用率閾値は設定可能でなければならã
| REC-02 | Process hang | timeout後、安全にJob配下を停止。 |
| REC-03 | Application終了 | Job Object配下を必ずkillする。 |
| REC-04 | stdio EOF/Client消失 | 当該Client所有のTaskを安全停止する。 |
-| REC-05 | Streamable HTTP session終了 | 当該Session所有のTaskを安全停止する。通常のHTTP request完了は切断とみなさない。 |
+| REC-05 | Streamable HTTP server停止/明示cancel | 対象Taskを安全停止する。stateless HTTPのrequest完了やsocket切断はAgent Session終了とみなさない。 |
| REC-06 | crash後起動 | Recovery Journalに基づき残留Task Resourceをcleanにしてからserveする。 |
| REC-07 | cleanup失敗 | serveを開始せずfatal errorとする。 |
diff --git a/plans/ImplementationPlan.md b/plans/ImplementationPlan.md
index 2507f5a..d87a357 100644
--- a/plans/ImplementationPlan.md
+++ b/plans/ImplementationPlan.md
@@ -774,3 +774,14 @@ Codex作業後、人間はこの表を更新する。
- Release Bundle単独でstdio/Streamable HTTPを実利用できる。
- orphan process、ACL、WFP filter、temporary resource、journalの残留が0である。
- READMEだけで初回セットアップからTask実行、Audit確認、安全な終了まで再現できる。
+
+# 24. MCP 2026-07-28 Migration Gate
+
+- 公式C# SDK `2.0.0`とTasks Extension `2.0.0`をCentral Package Managementで固定する。
+- stateless `server/discover`、必須request `_meta`、header mismatch/unsupported version、GET/DELETE 405、Session ID非発行を検証する。
+- modern経路とlegacy initialize fallbackを別々に検証し、Inspector固有の互換middlewareを追加しない。
+- Tasksの対応/非対応client、create/get/cancel、TTL、atomic persistence、restart failure recovery、Agent Task相関を検証する。
+- `@modelcontextprotocol/conformance@0.2.0-alpha.11`を取得処理とoffline実行に分離し、version/integrity/scenario一覧を`artifacts/evidence/mcp-2026-07-28/`へ保存する。
+- conformanceのapplication failure、診断fixture不足、runner自身のalpha schema不整合、upstream skipを別集計する。expected-failure baselineでapplication failureを隠さない。
+- production hostに不要な診断Tool/Resource/Promptはtest-only hostへ隔離し、Release Bundleへ含めない。
+- Gate完了にはRelease/全test/Operational E2Eに加え、applicable conformance checkのapplication failure 0を要求する。
diff --git a/plans/RequirementTraceability.md b/plans/RequirementTraceability.md
index aec82a2..9d6e93b 100644
--- a/plans/RequirementTraceability.md
+++ b/plans/RequirementTraceability.md
@@ -57,10 +57,27 @@
| Requirement | Evidence | Status |
|---|---|---|
| Release build | Release build warning 0/error 0 | PASS |
-| Unit/integration | Planner 10/10, Core 40/40, skip 0 | PASS |
+| Unit/integration | Planner 10/10, Core 47/47, skip 0 | PASS |
| win-x64 self-contained Bundle | publish directory, ZIP, manifest, checksum | PASS |
| Bundle-only elevated startup | `bundle-check.log`: administrator/BFE/WFP probe PASS | PASS |
| E2E-01 stdio | `e2e-01-stdio.log`: local Ollama、read/edit/build/test、日本語、Audit、cleanup | PASS |
| E2E-02 HTTP (`127.0.0.1`, `::1`) | `e2e-02-http-ipv4.log` / `e2e-02-http-ipv6.log` | PASS |
| clean resource state | process/ACL/WFP/loopback/temp/journal/backup残留0 | PASS |
| README-only fresh setup | ZIP fresh extraction、source参照0、README手順による最終runner | PASS |
+
+## MCP 2026-07-28 Migration
+
+| Requirement | Implementation / evidence | Status |
+|---|---|---|
+| SDK 2.0.0 / stateless discover | `Directory.Packages.props`, `McpHost`, official `server-stateless` evidence | PASS (28/28 core checks, unexpected failure 0) |
+| GET/DELETE 405、Legacy SSE無効、Session IDなし | `McpHttpTests` | PASS |
+| stdio / strict two Tool compatibility | test-only `Agent.Mcp.TestHost`, `McpStdioTests` | PASS |
+| official Tasks negotiation/lifecycle | `WithTasks`, `PersistentMcpTaskStore`, `McpHttpTests`, conformance Tasks evidence | PARTIAL: lifecycle/cancel/error/required-task PASS; MRTR fixture composition remains incompatible with alpha.11 |
+| Task persistence/restart/TTL | `PersistentMcpTaskStoreTests` | PASS |
+| CLI preflight / ready | `Program`, `McpHostLogObserver`, locale catalog | PASS (elevated live run required for final Gate) |
+| HTTP request log/redaction | `McpHostLogObserver`, `ObservabilityTests` | PASS |
+| conformance acquisition/offline runner | `Acquire-McpConformance.ps1`, `Run-McpConformance.ps1`, `Run-McpTasksConformance.ps1` | PASS |
+
+`0.2.0-alpha.11`のTasks extension scenarioはrelease requirementに含まれずinformationalとして実行され、正しい`CreateTaskResult`を通常`CallToolResult` schemaで検査する`wire-schema-valid`既知制約がある。これは`runner_limitations`へ分離し、application failureとは数えない。MRTR input/composition scenarioはSDK 2.0.0との組み合わせで未達のため、Tasks Extension GateはPASS扱いにしない。
+
+公式core runnerの個別結果では`server-stateless` 28/28、DNS rebinding、multiple POST streamがPASSした。一方、test-only conformance hostのfixture不足またはscenario間のcapability競合により、`tools-list`(description)、`json-schema-2020-12`(診断Tool)、`caching`(Prompt/Resource/Template)、`http-header-validation`(Tasks必須ToolをTasks非対応clientが呼ぶ組合せ)は未達である。production hostのexpected-failureとして隠さず、conformance総合GateはPARTIALのままとする。
diff --git a/scripts/Acquire-McpConformance.ps1 b/scripts/Acquire-McpConformance.ps1
new file mode 100644
index 0000000..f6fa38f
--- /dev/null
+++ b/scripts/Acquire-McpConformance.ps1
@@ -0,0 +1,36 @@
+[CmdletBinding()]
+param(
+ [string]$EvidenceDirectory = "artifacts/evidence/mcp-2026-07-28/conformance-acquisition"
+)
+
+$ErrorActionPreference = "Stop"
+$repository = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+$runnerDirectory = Join-Path $repository "build/conformance-runner"
+$evidence = [IO.Path]::GetFullPath($EvidenceDirectory, $repository)
+New-Item -ItemType Directory -Force -Path $runnerDirectory, $evidence | Out-Null
+
+$package = "@modelcontextprotocol/conformance@0.2.0-alpha.11"
+& npm install --prefix $runnerDirectory --package-lock=true --ignore-scripts --no-audit --no-fund $package 2>&1 |
+ Tee-Object -FilePath (Join-Path $evidence "npm-install.txt")
+if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE" }
+
+$packageJson = Join-Path $runnerDirectory "node_modules/@modelcontextprotocol/conformance/package.json"
+$metadata = Get-Content -Raw -LiteralPath $packageJson | ConvertFrom-Json
+if ($metadata.version -ne "0.2.0-alpha.11") { throw "Unexpected conformance version: $($metadata.version)" }
+$lockPath = Join-Path $runnerDirectory "package-lock.json"
+$lock = Get-Content -Raw -LiteralPath $lockPath | ConvertFrom-Json -AsHashtable
+$entry = $lock.packages["node_modules/@modelcontextprotocol/conformance"]
+[ordered]@{
+ package = $metadata.name
+ version = $metadata.version
+ integrity = $entry.integrity
+ resolved = $entry.resolved
+ acquired_at = [DateTimeOffset]::UtcNow.ToString("O")
+ node = (& node --version)
+ npm = (& npm --version)
+} | ConvertTo-Json | Set-Content -Encoding utf8 -LiteralPath (Join-Path $evidence "package.json")
+
+$bin = $metadata.bin.conformance
+& node (Join-Path (Split-Path $packageJson) $bin) list --server --requirements 2026-07-28 2>&1 |
+ Tee-Object -FilePath (Join-Path $evidence "server-scenarios.txt")
+if ($LASTEXITCODE -ne 0) { throw "Conformance scenario listing failed with exit code $LASTEXITCODE" }
diff --git a/scripts/Publish-Release.ps1 b/scripts/Publish-Release.ps1
index 8ada466..78b97ef 100644
--- a/scripts/Publish-Release.ps1
+++ b/scripts/Publish-Release.ps1
@@ -17,7 +17,13 @@ $zip = Join-Path $outputRootPath "$bundleName.zip"
$checksum = "$zip.sha256"
New-Item -ItemType Directory -Force -Path $outputRootPath | Out-Null
-if (Test-Path -LiteralPath $bundle) { Remove-Item -LiteralPath $bundle -Recurse -Force }
+if (Test-Path -LiteralPath $bundle) {
+ $resolvedBundle = (Resolve-Path -LiteralPath $bundle).Path
+ if (-not $resolvedBundle.StartsWith($outputRootPath + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'Existing Release Bundle escaped the validated output root.'
+ }
+ Get-ChildItem -LiteralPath $resolvedBundle -Force | Remove-Item -Recurse -Force
+}
if (Test-Path -LiteralPath $zip) { Remove-Item -LiteralPath $zip -Force }
if (Test-Path -LiteralPath $checksum) { Remove-Item -LiteralPath $checksum -Force }
diff --git a/scripts/Run-McpConformance.ps1 b/scripts/Run-McpConformance.ps1
new file mode 100644
index 0000000..d43095c
--- /dev/null
+++ b/scripts/Run-McpConformance.ps1
@@ -0,0 +1,27 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)] [uri]$Url,
+ [string[]]$Scenario = @("server-stateless", "tools-list", "json-schema-2020-12", "server-sse-multiple-streams", "dns-rebinding-protection", "caching", "http-header-validation"),
+ [string]$EvidenceDirectory = "artifacts/evidence/mcp-2026-07-28/conformance-production"
+)
+
+$ErrorActionPreference = "Stop"
+if (-not $Url.IsLoopback) { throw "Conformance target must be loopback." }
+$repository = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+$runnerDirectory = Join-Path $repository "build/conformance-runner"
+$packageJson = Join-Path $runnerDirectory "node_modules/@modelcontextprotocol/conformance/package.json"
+if (-not (Test-Path -LiteralPath $packageJson)) { throw "Offline runner is absent. Run scripts/Acquire-McpConformance.ps1 first." }
+$metadata = Get-Content -Raw -LiteralPath $packageJson | ConvertFrom-Json
+if ($metadata.version -ne "0.2.0-alpha.11") { throw "Unexpected conformance version: $($metadata.version)" }
+$cli = Join-Path (Split-Path $packageJson) $metadata.bin.conformance
+$evidence = [IO.Path]::GetFullPath($EvidenceDirectory, $repository)
+New-Item -ItemType Directory -Force -Path $evidence | Out-Null
+
+$failures = 0
+foreach ($name in $Scenario) {
+ $output = Join-Path $evidence $name
+ & node $cli server --url $Url.AbsoluteUri --scenario $name --spec-version 2026-07-28 --output-dir $output --verbose 2>&1 |
+ Tee-Object -FilePath (Join-Path $evidence "$name.txt")
+ if ($LASTEXITCODE -ne 0) { $failures++ }
+}
+if ($failures -ne 0) { throw "$failures conformance scenario(s) failed; no expected-failure baseline is permitted." }
diff --git a/scripts/Run-McpTasksConformance.ps1 b/scripts/Run-McpTasksConformance.ps1
new file mode 100644
index 0000000..470f6df
--- /dev/null
+++ b/scripts/Run-McpTasksConformance.ps1
@@ -0,0 +1,57 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)] [uri]$Url,
+ [string]$EvidenceDirectory = "artifacts/evidence/mcp-2026-07-28/conformance-tasks"
+)
+
+$ErrorActionPreference = "Stop"
+if (-not $Url.IsLoopback) { throw "Conformance target must be loopback." }
+$repository = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+$packageJson = Join-Path $repository "build/conformance-runner/node_modules/@modelcontextprotocol/conformance/package.json"
+if (-not (Test-Path -LiteralPath $packageJson)) { throw "Offline runner is absent. Run scripts/Acquire-McpConformance.ps1 first." }
+$metadata = Get-Content -Raw -LiteralPath $packageJson | ConvertFrom-Json
+if ($metadata.version -ne "0.2.0-alpha.11") { throw "Unexpected conformance version: $($metadata.version)" }
+$cli = Join-Path (Split-Path $packageJson) $metadata.bin.conformance
+$evidence = [IO.Path]::GetFullPath($EvidenceDirectory, $repository)
+New-Item -ItemType Directory -Force -Path $evidence | Out-Null
+$scenarios = @(
+ "tasks-lifecycle", "tasks-capability-negotiation", "tasks-wire-fields", "tasks-request-state-removal",
+ "tasks-mrtr-input", "tasks-request-headers", "tasks-dispatch-and-envelope", "tasks-status-notifications",
+ "tasks-required-task-error", "tasks-mrtr-composition"
+)
+
+$applicationFailures = @()
+$runnerLimitations = @()
+foreach ($scenario in $scenarios) {
+ $output = Join-Path $evidence $scenario
+ & node $cli server --url $Url.AbsoluteUri --scenario $scenario --spec-version 2026-07-28 --force --output-dir $output 2>&1 |
+ Tee-Object -FilePath (Join-Path $evidence "$scenario.txt")
+ $runnerExitCode = $LASTEXITCODE
+ $resultDirectory = Get-ChildItem -LiteralPath $output -Directory | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1
+ if ($null -eq $resultDirectory) {
+ $runnerLimitations += "$scenario`:runner_exit_$runnerExitCode`:no_result_directory"
+ continue
+ }
+ $checksPath = Join-Path $resultDirectory.FullName "checks.json"
+ if (-not (Test-Path -LiteralPath $checksPath)) {
+ $runnerLimitations += "$scenario`:runner_exit_$runnerExitCode`:checks_missing"
+ continue
+ }
+ foreach ($check in (Get-Content -Raw -LiteralPath $checksPath | ConvertFrom-Json)) {
+ if ($check.status -ne "FAILURE") { continue }
+ if ($check.id -eq "wire-schema-valid" -and $check.errorMessage -match "CallToolResult: must have required property 'content'.*resultType.*task") {
+ $runnerLimitations += "$scenario`:$($check.id)"
+ } else {
+ $applicationFailures += "$scenario`:$($check.id) - $($check.errorMessage)"
+ }
+ }
+}
+[ordered]@{
+ runner_version = $metadata.version
+ scenarios = $scenarios
+ application_failure_count = $applicationFailures.Count
+ application_failures = $applicationFailures
+ runner_limitation_count = $runnerLimitations.Count
+ runner_limitations = $runnerLimitations
+} | ConvertTo-Json -Depth 5 | Set-Content -Encoding utf8 -LiteralPath (Join-Path $evidence "summary.json")
+if ($applicationFailures.Count -ne 0) { throw "$($applicationFailures.Count) application Tasks conformance check(s) failed." }
diff --git a/src/Agent.App/Configuration/AgentConfiguration.cs b/src/Agent.App/Configuration/AgentConfiguration.cs
index 800286b..362b889 100644
--- a/src/Agent.App/Configuration/AgentConfiguration.cs
+++ b/src/Agent.App/Configuration/AgentConfiguration.cs
@@ -23,10 +23,12 @@ public sealed class McpOptions
public string Transport { get; set; } = "stdio";
public HttpOptions Http { get; set; } = new();
public ImageOptions Images { get; set; } = new();
+ public McpTaskOptions Tasks { get; set; } = new();
}
public sealed class HttpOptions { public string Host { get; set; } = "127.0.0.1"; public int Port { get; set; } = 8080; }
public sealed class ImageOptions { public int MaxCount { get; set; } = 4; public long MaxBytesEach { get; set; } = 10 * 1024 * 1024; public long MaxTotalBytes { get; set; } = 20 * 1024 * 1024; }
+public sealed class McpTaskOptions { public int TimeToLiveHours { get; set; } = 24; public int PollIntervalMs { get; set; } = 1000; }
public sealed class RuntimeOptions { public string UserSystemPrompt { get; set; } = "prompts/user-system.md"; public int MaxIterations { get; set; } = 128; public int SameFailureLimit { get; set; } = 5; }
public sealed class OllamaConfiguration
@@ -73,4 +75,4 @@ public sealed class FileEditOptions { public int MemoryBackupLimitMb { get; set;
public sealed class LimitOptions { public long WholeReadBytes { get; set; } = 1024 * 1024; public int SearchMaxResults { get; set; } = 1000; public int TailMaxLines { get; set; } = 10000; public double ContextSummaryRatio { get; set; } = 0.8; }
public sealed class LocalizationOptions { public string Directory { get; set; } = "config/locales"; public string Fallback { get; set; } = "en"; }
public sealed class SkillOptions { public string Directory { get; set; } = "skills"; }
-public sealed class LoggingOptions { public string Directory { get; set; } = "logs"; public long MaxFileBytes { get; set; } = 10 * 1024 * 1024; public int RetainedFiles { get; set; } = 5; }
+public sealed class LoggingOptions { public string Directory { get; set; } = "logs"; public string Level { get; set; } = "information"; public bool HttpRequests { get; set; } = true; public long MaxFileBytes { get; set; } = 10 * 1024 * 1024; public int RetainedFiles { get; set; } = 5; }
diff --git a/src/Agent.App/Configuration/ConfigurationLoader.cs b/src/Agent.App/Configuration/ConfigurationLoader.cs
index b6c4c2d..298a143 100644
--- a/src/Agent.App/Configuration/ConfigurationLoader.cs
+++ b/src/Agent.App/Configuration/ConfigurationLoader.cs
@@ -44,6 +44,7 @@ public static partial class ConfigurationLoader
if (value.Mcp.Http.Port is < 1 or > 65535) Throw("mcp.http.port is outside 1..65535.");
if (value.Mcp.Images.MaxCount < 0 || value.Mcp.Images.MaxBytesEach <= 0 || value.Mcp.Images.MaxTotalBytes <= 0) Throw("mcp.images limits must be positive.");
if (value.Mcp.Images.MaxTotalBytes < value.Mcp.Images.MaxBytesEach) Throw("mcp.images.max_total_bytes must be >= max_bytes_each.");
+ if (value.Mcp.Tasks.TimeToLiveHours <= 0 || value.Mcp.Tasks.PollIntervalMs <= 0) Throw("mcp.tasks TTL and poll interval must be positive.");
if (value.Agent.MaxIterations <= 0 || value.Agent.SameFailureLimit <= 0) Throw("agent iteration limits must be positive.");
if (!Uri.TryCreate(value.Ollama.Endpoint, UriKind.Absolute, out Uri? endpoint) || endpoint.Scheme is not ("http" or "https")) Throw("ollama.endpoint must be an absolute HTTP(S) URI.");
if (string.IsNullOrWhiteSpace(value.Ollama.Model)) Throw("ollama.model is required.");
@@ -62,6 +63,7 @@ public static partial class ConfigurationLoader
}
if (value.FileEdit.MemoryBackupLimitMb <= 0 || value.Limits.WholeReadBytes <= 0 || value.Limits.SearchMaxResults <= 0 || value.Limits.TailMaxLines <= 0 || value.Limits.ContextSummaryRatio is <= 0 or >= 1) Throw("file/context limits are invalid.");
if (value.Logging.MaxFileBytes <= 0 || value.Logging.RetainedFiles < 1) Throw("logging limits are invalid.");
+ if (value.Logging.Level is not ("information" or "warning" or "error" or "fatal")) Throw("logging.level must be information, warning, error, or fatal.");
RequireFile(baseDirectory, value.Agent.UserSystemPrompt, "agent.user_system_prompt");
RequireDirectory(baseDirectory, value.Localization.Directory, "localization.directory");
diff --git a/src/Agent.App/Logging/McpHostLogObserver.cs b/src/Agent.App/Logging/McpHostLogObserver.cs
new file mode 100644
index 0000000..b1410e8
--- /dev/null
+++ b/src/Agent.App/Logging/McpHostLogObserver.cs
@@ -0,0 +1,76 @@
+using System.Globalization;
+using System.Text.Json;
+using Agent.Core.Localization;
+using Agent.Mcp;
+using System.Collections.Concurrent;
+
+namespace Agent.App.Logging;
+
+public sealed class McpHostLogObserver : IMcpHostObserver, IDisposable
+{
+ private readonly RollingFileLog _application;
+ private readonly RollingFileWriter? _http;
+ private readonly LocaleCatalog _locale;
+ private readonly string _logDirectory;
+ private readonly ConcurrentDictionary<string, byte> _observedConnections = new(StringComparer.Ordinal);
+
+ public McpHostLogObserver(RollingFileLog application, LocaleCatalog locale, string logDirectory,
+ bool httpRequests, long maxBytes, int retainedFiles)
+ {
+ _application = application;
+ _locale = locale;
+ _logDirectory = logDirectory;
+ if (httpRequests) _http = new RollingFileWriter(Path.Combine(logDirectory, "http.jsonl"), maxBytes, retainedFiles);
+ }
+
+ public void Ready(McpReadyInfo info)
+ {
+ string message = info.Transport == "http"
+ ? _locale.Format("app.ready.http", new Dictionary<string, object?>
+ {
+ ["endpoint"] = info.Endpoint, ["protocol"] = info.ProtocolVersion, ["stateless"] = info.Stateless.ToString().ToLowerInvariant(),
+ ["fallback"] = info.LegacyFallback.ToString().ToLowerInvariant(), ["pid"] = info.ProcessId, ["logs"] = _logDirectory
+ })
+ : _locale.Format("app.ready.stdio", new Dictionary<string, object?>
+ {
+ ["protocol"] = info.ProtocolVersion, ["pid"] = info.ProcessId, ["logs"] = _logDirectory
+ });
+ _application.Operational(message);
+ }
+
+ public void HttpRequestStarted(McpHttpRequestStartInfo info)
+ {
+ if (_observedConnections.TryAdd(info.ConnectionId, 0))
+ _application.Information($"HTTP connection observed connection={info.ConnectionId} remote={info.RemoteIp}:{info.RemotePort}");
+ _application.Information($"HTTP request started connection={info.ConnectionId} trace={info.TraceId} remote={info.RemoteIp}:{info.RemotePort} method={info.Method} path={info.Path}");
+ }
+
+ public void HttpRequest(McpHttpRequestInfo info)
+ {
+ _application.Information(string.Create(CultureInfo.InvariantCulture,
+ $"HTTP request completed connection={info.ConnectionId} remote={info.RemoteIp}:{info.RemotePort} method={info.Method} path={info.Path} status={info.Status} duration_ms={info.DurationMs:F3}"));
+ _http?.WriteLine(JsonSerializer.Serialize(new
+ {
+ timestamp = info.Timestamp,
+ connection_id = info.ConnectionId,
+ trace_id = info.TraceId,
+ remote_ip = info.RemoteIp,
+ remote_port = info.RemotePort,
+ local_ip = info.LocalIp,
+ local_port = info.LocalPort,
+ method = info.Method,
+ path = info.Path,
+ status = info.Status,
+ duration_ms = info.DurationMs,
+ content_type = info.ContentType,
+ content_length = info.ContentLength,
+ user_agent = info.UserAgent,
+ mcp_protocol_version = NullIfEmpty(info.ProtocolVersion),
+ mcp_method = NullIfEmpty(info.McpMethod),
+ mcp_name = NullIfEmpty(info.McpName)
+ }));
+ }
+
+ private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
+ public void Dispose() => _http?.Dispose();
+}
diff --git a/src/Agent.App/Logging/RollingFileLog.cs b/src/Agent.App/Logging/RollingFileLog.cs
index 801768d..8bdbbb4 100644
--- a/src/Agent.App/Logging/RollingFileLog.cs
+++ b/src/Agent.App/Logging/RollingFileLog.cs
@@ -4,52 +4,43 @@ namespace Agent.App.Logging;
public sealed class RollingFileLog : IDisposable
{
- private readonly object _sync = new();
- private readonly string _path;
- private readonly long _maxBytes;
- private readonly int _retainedFiles;
- private StreamWriter _writer;
+ private readonly RollingFileWriter _writer;
+ private readonly int _minimumLevel;
- public RollingFileLog(string directory, long maxBytes, int retainedFiles)
+ public RollingFileLog(string directory, long maxBytes, int retainedFiles, string minimumLevel = "information")
{
Directory.CreateDirectory(directory);
- _path = Path.Combine(directory, "application.log");
- _maxBytes = maxBytes;
- _retainedFiles = retainedFiles;
- _writer = Open();
+ _writer = new RollingFileWriter(Path.Combine(directory, "application.log"), maxBytes, retainedFiles);
+ _minimumLevel = ParseLevel(minimumLevel);
}
public void Information(string message) => Write("INF", message);
+ public void Operational(string message) => Write("INF", message, force: true);
+ public void Warning(string message) => Write("WRN", message);
public void Error(string message) => Write("ERR", message);
+ public void Fatal(string message) => Write("FTL", message);
- private void Write(string level, string message)
+ private void Write(string level, string message, bool force = false)
{
- lock (_sync)
- {
- if (_writer.BaseStream.Length >= _maxBytes) Rotate();
- string line = string.Create(CultureInfo.InvariantCulture, $"{DateTimeOffset.UtcNow:O} [{level}] {message}");
- Console.Error.WriteLine(line);
- _writer.WriteLine(line);
- _writer.Flush();
- }
+ if (!force && Level(level) < _minimumLevel) return;
+ string line = string.Create(CultureInfo.InvariantCulture, $"{DateTimeOffset.UtcNow:O} [{level}] {message}");
+ Console.Error.WriteLine(line);
+ _writer.WriteLine(line);
}
- private StreamWriter Open() => new(new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read), new System.Text.UTF8Encoding(false));
-
- private void Rotate()
+ private static int ParseLevel(string level) => level.ToLowerInvariant() switch
{
- _writer.Dispose();
- for (int index = _retainedFiles - 1; index >= 1; index--)
- {
- string source = $"{_path}.{index}";
- if (File.Exists(source)) File.Move(source, $"{_path}.{index + 1}", true);
- }
- if (File.Exists(_path)) File.Move(_path, $"{_path}.1", true);
- _writer = Open();
- }
+ "information" or "info" => 0,
+ "warning" or "warn" => 1,
+ "error" => 2,
+ "fatal" => 3,
+ _ => throw new ArgumentException($"Unknown logging level '{level}'.", nameof(level))
+ };
+
+ private static int Level(string level) => level switch { "INF" => 0, "WRN" => 1, "ERR" => 2, "FTL" => 3, _ => 0 };
public void Dispose()
{
- lock (_sync) _writer.Dispose();
+ _writer.Dispose();
}
}
diff --git a/src/Agent.App/Logging/RollingFileWriter.cs b/src/Agent.App/Logging/RollingFileWriter.cs
new file mode 100644
index 0000000..e155858
--- /dev/null
+++ b/src/Agent.App/Logging/RollingFileWriter.cs
@@ -0,0 +1,50 @@
+using System.Text;
+
+namespace Agent.App.Logging;
+
+internal sealed class RollingFileWriter : IDisposable
+{
+ private readonly object _sync = new();
+ private readonly string _path;
+ private readonly long _maxBytes;
+ private readonly int _retainedFiles;
+ private StreamWriter _writer;
+
+ public RollingFileWriter(string path, long maxBytes, int retainedFiles)
+ {
+ _path = path;
+ _maxBytes = maxBytes;
+ _retainedFiles = retainedFiles;
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ _writer = Open();
+ }
+
+ public void WriteLine(string line)
+ {
+ lock (_sync)
+ {
+ if (_writer.BaseStream.Length >= _maxBytes) Rotate();
+ _writer.WriteLine(line);
+ _writer.Flush();
+ }
+ }
+
+ private StreamWriter Open() => new(new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read), new UTF8Encoding(false));
+
+ private void Rotate()
+ {
+ _writer.Dispose();
+ for (int index = _retainedFiles - 1; index >= 1; index--)
+ {
+ string source = $"{_path}.{index}";
+ if (File.Exists(source)) File.Move(source, $"{_path}.{index + 1}", true);
+ }
+ if (File.Exists(_path)) File.Move(_path, $"{_path}.1", true);
+ _writer = Open();
+ }
+
+ public void Dispose()
+ {
+ lock (_sync) _writer.Dispose();
+ }
+}
diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs
index 3fae93f..d9ee17a 100644
--- a/src/Agent.App/Program.cs
+++ b/src/Agent.App/Program.cs
@@ -35,7 +35,7 @@ internal static class Program
string logDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Logging.Directory);
UserOnlyDirectory.Ensure(logDirectory);
- using var log = new RollingFileLog(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles);
+ using var log = new RollingFileLog(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles, configuration.Logging.Level);
if (commandLine.Command == AppCommand.Check)
{
WindowsPlatformCheckResult platform = WindowsPlatformCheck.Run();
@@ -57,8 +57,30 @@ internal static class Program
}
log.Information(locale.Get("app.starting"));
+ WindowsPlatformCheckResult servePlatform = WindowsPlatformCheck.Run();
+ if (!servePlatform.Success)
+ {
+ string failure = locale.Format("app.preflight_failed", new Dictionary<string, object?>
+ {
+ ["operation"] = servePlatform.ErrorOperation ?? "privilege/BFE/WFP probe",
+ ["error_code"] = servePlatform.ErrorCode,
+ ["administrator"] = servePlatform.IsAdministrator.ToString().ToLowerInvariant(),
+ ["bfe"] = servePlatform.BfeEngineOpen.ToString().ToLowerInvariant(),
+ ["transaction"] = servePlatform.WfpWriteTransaction.ToString().ToLowerInvariant(),
+ ["filters"] = servePlatform.WfpFilterAddDelete.ToString().ToLowerInvariant()
+ });
+ log.Fatal(failure);
+ return 2;
+ }
+
InternalToolDispatcher toolDispatcher = CreateToolDispatcher(configuration, baseDirectory, logDirectory);
var runtimeObserver = new JsonLinesRuntimeObserver(logDirectory);
+ string taskStateDirectory = Path.Combine(baseDirectory, "state", "mcp-tasks");
+ UserOnlyDirectory.Ensure(taskStateDirectory);
+ var taskStoreSettings = new McpTaskStoreSettings(taskStateDirectory,
+ TimeSpan.FromHours(configuration.Mcp.Tasks.TimeToLiveHours), configuration.Mcp.Tasks.PollIntervalMs);
+ using var hostObserver = new McpHostLogObserver(log, locale, logDirectory, configuration.Logging.HttpRequests,
+ configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles);
if (configuration.Mcp.Transport == "stdio")
{
string userPrompt = File.ReadAllText(ConfigurationLoader.Resolve(baseDirectory, configuration.Agent.UserSystemPrompt));
@@ -66,7 +88,8 @@ internal static class Program
string[] skills = Directory.EnumerateFiles(skillDirectory, "*.md", SearchOption.TopDirectoryOnly).Select(File.ReadAllText).ToArray();
var runtimeSettings = new AgentRuntimeSettings(userPrompt, language, skills, "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.", configuration.Agent.MaxIterations, configuration.Agent.SameFailureLimit, locale.Get("ollama.vision_unsupported"));
var visionLimits = new VisionLimits(configuration.Mcp.Images.MaxCount, configuration.Mcp.Images.MaxBytesEach, configuration.Mcp.Images.MaxTotalBytes);
- await McpHost.RunStdioAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits, CancellationToken.None, toolDispatcher, runtimeObserver).ConfigureAwait(false);
+ await McpHost.RunStdioAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits, CancellationToken.None,
+ toolDispatcher, runtimeObserver, taskStoreSettings, hostObserver).ConfigureAwait(false);
return 0;
}
if (configuration.Mcp.Transport == "http")
@@ -77,7 +100,8 @@ internal static class Program
var runtimeSettings = new AgentRuntimeSettings(userPrompt, language, skills, "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.", configuration.Agent.MaxIterations, configuration.Agent.SameFailureLimit, locale.Get("ollama.vision_unsupported"));
var visionLimits = new VisionLimits(configuration.Mcp.Images.MaxCount, configuration.Mcp.Images.MaxBytesEach, configuration.Mcp.Images.MaxTotalBytes);
await McpHost.RunHttpAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits,
- configuration.Mcp.Http.Host, configuration.Mcp.Http.Port, CancellationToken.None, toolDispatcher, runtimeObserver).ConfigureAwait(false);
+ configuration.Mcp.Http.Host, configuration.Mcp.Http.Port, CancellationToken.None, toolDispatcher, runtimeObserver,
+ taskStoreSettings, hostObserver).ConfigureAwait(false);
return 0;
}
throw new ConfigurationException("Unsupported MCP transport.");
diff --git a/src/Agent.Core/Tasks/TaskRegistry.cs b/src/Agent.Core/Tasks/TaskRegistry.cs
index b61e525..b7a4afb 100644
--- a/src/Agent.Core/Tasks/TaskRegistry.cs
+++ b/src/Agent.Core/Tasks/TaskRegistry.cs
@@ -14,7 +14,7 @@ public sealed class TaskRegistry
public SessionStore Sessions => _sessions;
- public TaskSnapshot Enqueue(string request, Guid? requestedSessionId, double? temperature, IReadOnlyList<string>? images = null)
+ public TaskSnapshot Enqueue(string request, Guid? requestedSessionId, double? temperature, IReadOnlyList<string>? images = null, Guid? requestedTaskId = null)
{
if (string.IsNullOrWhiteSpace(request)) throw new ArgumentException("Request cannot be empty.", nameof(request));
if (temperature is < 0 or > 2) throw new ArgumentOutOfRangeException(nameof(temperature));
@@ -29,7 +29,7 @@ public sealed class TaskRegistry
sessionId = _sessions.Create().SessionId;
}
- var task = new MutableTask(Guid.NewGuid(), sessionId, request, temperature, images ?? []);
+ var task = new MutableTask(requestedTaskId ?? Guid.NewGuid(), sessionId, request, temperature, images ?? []);
if (!_tasks.TryAdd(task.TaskId, task)) throw new InvalidOperationException("Could not register task.");
if (!_queue.Writer.TryWrite(task.TaskId))
{
@@ -60,6 +60,19 @@ public sealed class TaskRegistry
public bool AddUsage(Guid taskId, long inputTokens, long outputTokens) =>
_tasks.TryGetValue(taskId, out MutableTask? task) && task.AddUsage(inputTokens, outputTokens);
+ public CancellationToken GetCancellationToken(Guid taskId) =>
+ _tasks.TryGetValue(taskId, out MutableTask? task) ? task.CancellationToken : CancellationToken.None;
+
+ public bool Cancel(Guid taskId) =>
+ _tasks.TryGetValue(taskId, out MutableTask? task) && task.Cancel();
+
+ public async Task<TaskSnapshot> WaitForTerminalAsync(Guid taskId, CancellationToken cancellationToken)
+ {
+ if (!_tasks.TryGetValue(taskId, out MutableTask? task)) throw new KeyNotFoundException($"Task '{taskId}' was not found.");
+ await task.Terminal.WaitAsync(cancellationToken).ConfigureAwait(false);
+ return task.Snapshot();
+ }
+
private sealed class MutableTask(Guid taskId, Guid sessionId, string request, double? temperature, IReadOnlyList<string> images)
{
private readonly object _sync = new();
@@ -68,9 +81,13 @@ public sealed class TaskRegistry
private string? _result;
private long _inputTokens;
private long _outputTokens;
+ private readonly CancellationTokenSource _cancellation = new();
+ private readonly TaskCompletionSource _terminal = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Guid TaskId { get; } = taskId;
public Guid SessionId { get; } = sessionId;
+ public CancellationToken CancellationToken => _cancellation.Token;
+ public Task Terminal => _terminal.Task;
public TaskSnapshot Snapshot()
{
@@ -100,6 +117,21 @@ public sealed class TaskRegistry
_state = next;
_currentTask = currentTask;
_result = result;
+ if (next is AgentTaskState.Completed or AgentTaskState.Failed or AgentTaskState.Cancelled) _terminal.TrySetResult();
+ return true;
+ }
+ }
+
+ public bool Cancel()
+ {
+ lock (_sync)
+ {
+ if (_state is AgentTaskState.Completed or AgentTaskState.Failed or AgentTaskState.Cancelled) return false;
+ _cancellation.Cancel();
+ _state = AgentTaskState.Cancelled;
+ _currentTask = null;
+ _result = "cancelled";
+ _terminal.TrySetResult();
return true;
}
}
diff --git a/src/Agent.Mcp/Agent.Mcp.csproj b/src/Agent.Mcp/Agent.Mcp.csproj
index 5b09556..df76e22 100644
--- a/src/Agent.Mcp/Agent.Mcp.csproj
+++ b/src/Agent.Mcp/Agent.Mcp.csproj
@@ -5,6 +5,7 @@
<ItemGroup>
<PackageReference Include="ModelContextProtocol" />
<PackageReference Include="ModelContextProtocol.AspNetCore" />
+ <PackageReference Include="ModelContextProtocol.Extensions.Tasks" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
diff --git a/src/Agent.Mcp/AgentTools.cs b/src/Agent.Mcp/AgentTools.cs
index c44d6ec..6a5759f 100644
--- a/src/Agent.Mcp/AgentTools.cs
+++ b/src/Agent.Mcp/AgentTools.cs
@@ -3,34 +3,47 @@ using Agent.Core.Localization;
using Agent.Core.Tasks;
using ModelContextProtocol.Server;
using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Extensions.Tasks;
+using System.Text.Json;
+using System.Text.Json.Nodes;
namespace Agent.Mcp;
[McpServerToolType]
-public sealed class AgentTools(TaskRegistry registry, LocaleCatalog locale, VisionLimits visionLimits)
+public sealed class AgentTools(TaskRegistry registry, LocaleCatalog locale, VisionLimits visionLimits, PersistentMcpTaskStore taskStore)
{
[McpServerTool(Name = "agent_run", ReadOnly = false, Destructive = true, OpenWorld = false, UseStructuredContent = false)]
[Description("Queue a natural-language request for asynchronous execution.")]
- public string AgentRun(
+ public async Task<string> AgentRun(
RequestContext<CallToolRequestParams> context,
[Description("Non-empty natural-language work request.")] string request,
[Description("Existing session UUID, or omit to create a session.")] Guid? session_id = null,
[Description("Task-scoped temperature from 0.0 through 2.0.")] double? temperature = null,
- [Description("Optional PNG, JPEG, or WebP images.")] IReadOnlyList<McpImageInput>? images = null)
+ [Description("Optional PNG, JPEG, or WebP images.")] IReadOnlyList<McpImageInput>? images = null,
+ CancellationToken cancellationToken = default)
{
RequireOnly(context, "request", "session_id", "temperature", "images");
ValidateImages(images);
- TaskSnapshot task = registry.Enqueue(request, session_id, temperature, images?.Select(static image => image.DataBase64).ToArray());
+ Guid? mcpTaskId = GetRelatedTaskId(context.Params?.Meta) ?? GetRelatedTaskId(context.JsonRpcRequest) ??
+ taskStore.ResolveTaskId(cancellationToken);
+ TaskSnapshot task = registry.Enqueue(request, session_id, temperature, images?.Select(static image => image.DataBase64).ToArray(), mcpTaskId);
string message = locale.Format("mcp.task_queued", new Dictionary<string, object?>
{
["task"] = task.TaskId,
["session"] = task.SessionId
});
- return string.Join('\n',
+ string queued = string.Join('\n',
$"task_id: {task.TaskId}",
$"session_id: {task.SessionId}",
"status: queued",
$"message: {message}");
+ if (mcpTaskId is null) return queued;
+
+ using CancellationTokenRegistration registration = cancellationToken.Register(() => registry.Cancel(task.TaskId));
+ TaskSnapshot terminal = await registry.WaitForTerminalAsync(task.TaskId, cancellationToken).ConfigureAwait(false);
+ if (terminal.State == AgentTaskState.Failed) throw new InvalidOperationException(terminal.Result ?? "Agent task failed.");
+ if (terminal.State == AgentTaskState.Cancelled) throw new OperationCanceledException("Agent task was cancelled.", cancellationToken);
+ return FormatStatus(terminal);
}
private void ValidateImages(IReadOnlyList<McpImageInput>? images)
@@ -66,6 +79,11 @@ public sealed class AgentTools(TaskRegistry registry, LocaleCatalog locale, Visi
$"message: {locale.Format("mcp.task_not_found", new Dictionary<string, object?> { ["task"] = task_id })}");
}
+ return FormatStatus(task);
+ }
+
+ private string FormatStatus(TaskSnapshot task)
+ {
string state = task.State.ToString().ToLowerInvariant();
bool terminal = task.State is AgentTaskState.Completed or AgentTaskState.Failed or AgentTaskState.Cancelled;
return string.Join('\n',
@@ -80,6 +98,43 @@ public sealed class AgentTools(TaskRegistry registry, LocaleCatalog locale, Visi
terminal ? $"result: {task.Result}" : "result:");
}
+ private static Guid? GetRelatedTaskId(object? metadata)
+ {
+ if (metadata is null) return null;
+ JsonElement json = JsonSerializer.SerializeToElement(metadata);
+ return Find(json);
+
+ static Guid? Find(JsonElement json)
+ {
+ if (json.ValueKind == JsonValueKind.Object)
+ {
+ foreach (JsonProperty property in json.EnumerateObject())
+ {
+ if (property.NameEquals(TasksProtocol.MetaRelatedTask)) return Parse(property.Value);
+ Guid? nested = Find(property.Value);
+ if (nested is not null) return nested;
+ }
+ }
+ else if (json.ValueKind == JsonValueKind.Array)
+ {
+ foreach (JsonElement item in json.EnumerateArray())
+ {
+ Guid? nested = Find(item);
+ if (nested is not null) return nested;
+ }
+ }
+ return null;
+ }
+
+ static Guid? Parse(JsonElement related)
+ {
+ if (related.ValueKind == JsonValueKind.String && Guid.TryParse(related.GetString(), out Guid direct)) return direct;
+ if (related.ValueKind == JsonValueKind.Object && related.TryGetProperty("taskId", out JsonElement taskId) &&
+ Guid.TryParse(taskId.GetString(), out Guid nested)) return nested;
+ return null;
+ }
+ }
+
private static void RequireOnly(RequestContext<CallToolRequestParams> context, params string[] allowed)
{
if (context.Params?.Arguments is null) return;
diff --git a/src/Agent.Mcp/McpHost.cs b/src/Agent.Mcp/McpHost.cs
index ff546e0..9a2ba66 100644
--- a/src/Agent.Mcp/McpHost.cs
+++ b/src/Agent.Mcp/McpHost.cs
@@ -15,12 +15,16 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using ModelContextProtocol.Server;
+using ModelContextProtocol.Extensions.Tasks;
+using ModelContextProtocol;
+using ModelContextProtocol.Protocol;
+using System.Diagnostics;
namespace Agent.Mcp;
public static class McpHost
{
- public static async Task RunStdioAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null)
+ public static async Task RunStdioAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, McpTaskStoreSettings? taskStoreSettings = null, IMcpHostObserver? hostObserver = null, IReadOnlyList<McpServerTool>? additionalTools = null, IReadOnlySet<string>? taskCapableTools = null, IReadOnlySet<string>? requiredTaskTools = null, Action<IMcpServerBuilder>? configureBuilder = null)
{
HostApplicationBuilder builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
{
@@ -37,18 +41,24 @@ public static class McpHost
builder.Services.AddSingleton(runtime);
builder.Services.AddHostedService<QueuedTaskWorker>();
- McpServerTool[] tools = CreateTools(registry, locale, visionLimits);
+ var taskStore = new PersistentMcpTaskStore(taskStoreSettings ?? DefaultTaskStoreSettings());
+ McpServerTool[] tools = CreateTools(registry, locale, visionLimits, taskStore).Concat(additionalTools ?? []).ToArray();
- builder.Services
+ IMcpServerBuilder mcpBuilder = builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithTools(tools);
+ ConfigureAgentTaskFilter(mcpBuilder);
+ configureBuilder?.Invoke(mcpBuilder);
+ mcpBuilder.WithTasks(taskStore, options => ConfigureTasks(options, taskCapableTools, requiredTaskTools));
using IHost host = builder.Build();
- await host.RunAsync(cancellationToken).ConfigureAwait(false);
+ await host.StartAsync(cancellationToken).ConfigureAwait(false);
+ hostObserver?.Ready(new McpReadyInfo("stdio", null, Environment.ProcessId, "2026-07-28", false, true));
+ await host.WaitForShutdownAsync(cancellationToken).ConfigureAwait(false);
}
- public static async Task RunHttpAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, string hostName, int port, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null)
+ public static async Task RunHttpAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, string hostName, int port, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, McpTaskStoreSettings? taskStoreSettings = null, IMcpHostObserver? hostObserver = null, IReadOnlyList<McpServerTool>? additionalTools = null, IReadOnlySet<string>? taskCapableTools = null, IReadOnlySet<string>? requiredTaskTools = null, Action<IMcpServerBuilder>? configureBuilder = null)
{
if (!System.Net.IPAddress.TryParse(hostName, out System.Net.IPAddress? address) || !System.Net.IPAddress.IsLoopback(address))
throw new ArgumentException("HTTP transport host must be a loopback IP literal.", nameof(hostName));
@@ -63,29 +73,76 @@ public static class McpHost
builder.Services.AddSingleton(registry);
builder.Services.AddSingleton(runtime);
builder.Services.AddHostedService<QueuedTaskWorker>();
- McpServerTool[] tools = CreateTools(registry, locale, visionLimits);
- builder.Services.AddMcpServer().WithHttpTransport().WithTools(tools);
+ var taskStore = new PersistentMcpTaskStore(taskStoreSettings ?? DefaultTaskStoreSettings());
+ McpServerTool[] tools = CreateTools(registry, locale, visionLimits, taskStore).Concat(additionalTools ?? []).ToArray();
+ IMcpServerBuilder mcpBuilder = builder.Services.AddMcpServer()
+ .WithHttpTransport(options => options.Stateless = true)
+ .WithTools(tools);
+ ConfigureAgentTaskFilter(mcpBuilder);
+ configureBuilder?.Invoke(mcpBuilder);
+ mcpBuilder.WithTasks(taskStore, options => ConfigureTasks(options, taskCapableTools, requiredTaskTools));
await using WebApplication app = builder.Build();
app.Use(async (context, next) =>
{
- if (context.Request.Headers.TryGetValue("Origin", out Microsoft.Extensions.Primitives.StringValues origins) &&
- origins.Any(origin => !IsLoopbackOrigin(origin)))
+ var stopwatch = Stopwatch.StartNew();
+ DateTimeOffset timestamp = DateTimeOffset.UtcNow;
+ hostObserver?.HttpRequestStarted(new McpHttpRequestStartInfo(context.Connection.Id, context.TraceIdentifier,
+ context.Connection.RemoteIpAddress?.ToString(), context.Connection.RemotePort,
+ context.Request.Method, context.Request.Path.Value ?? string.Empty));
+ try
{
- context.Response.StatusCode = StatusCodes.Status403Forbidden;
- return;
+ if (context.Request.Headers.TryGetValue("Origin", out Microsoft.Extensions.Primitives.StringValues origins) &&
+ origins.Any(origin => !IsLoopbackOrigin(origin)))
+ {
+ context.Response.StatusCode = StatusCodes.Status403Forbidden;
+ }
+ else await next(context).ConfigureAwait(false);
}
- if (context.Request.Path == "/mcp" && HttpMethods.IsGet(context.Request.Method))
+ finally
{
- context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
- return;
+ stopwatch.Stop();
+ hostObserver?.HttpRequest(new McpHttpRequestInfo(timestamp, context.Connection.Id, context.TraceIdentifier,
+ context.Connection.RemoteIpAddress?.ToString(), context.Connection.RemotePort,
+ context.Connection.LocalIpAddress?.ToString(), context.Connection.LocalPort,
+ context.Request.Method, context.Request.Path.Value ?? string.Empty, context.Response.StatusCode,
+ stopwatch.Elapsed.TotalMilliseconds, context.Request.ContentType, context.Request.ContentLength,
+ context.Request.Headers.UserAgent.ToString(), context.Request.Headers["MCP-Protocol-Version"].ToString(),
+ context.Request.Headers["MCP-Method"].ToString(), context.Request.Headers["MCP-Name"].ToString()));
}
- await next(context).ConfigureAwait(false);
});
app.MapMcp("/mcp");
- try { await app.RunAsync(cancellationToken).ConfigureAwait(false); }
+ try
+ {
+ await app.StartAsync(cancellationToken).ConfigureAwait(false);
+ hostObserver?.Ready(new McpReadyInfo("http", $"http://{listenHost}:{port}/mcp", Environment.ProcessId, "2026-07-28", true, true));
+ await app.WaitForShutdownAsync(cancellationToken).ConfigureAwait(false);
+ }
finally { httpClient.Dispose(); }
}
+ private static McpTaskStoreSettings DefaultTaskStoreSettings() =>
+ new(Path.Combine(Path.GetTempPath(), "agent-mcp", "mcp-tasks"), TimeSpan.FromHours(24), 1000);
+
+ private static void ConfigureTasks(McpTasksOptions options, IReadOnlySet<string>? taskCapableTools, IReadOnlySet<string>? requiredTaskTools)
+ {
+ IReadOnlySet<string> names = taskCapableTools ?? new HashSet<string>(["agent_run"], StringComparer.Ordinal);
+ options.ExecutionModeSelector = context =>
+ context.MatchedPrimitive is not McpServerTool tool ? McpTaskExecutionMode.Synchronous :
+ requiredTaskTools?.Contains(tool.ProtocolTool.Name) == true ? McpTaskExecutionMode.Required :
+ names.Contains(tool.ProtocolTool.Name) ? McpTaskExecutionMode.Optional : McpTaskExecutionMode.Synchronous;
+ }
+
+ private static void ConfigureAgentTaskFilter(IMcpServerBuilder builder)
+ {
+ builder.WithRequestFilters(filters => filters.AddCallToolFilter(next => async (context, cancellationToken) =>
+ {
+ CallToolResult result = await next(context, cancellationToken).ConfigureAwait(false);
+ if (context.Params?.Name == "agent_run" && result.IsError == true)
+ throw new McpProtocolException("The Agent task failed.", McpErrorCode.InternalError);
+ return result;
+ }));
+ }
+
private static bool IsLoopbackOrigin(string? origin)
{
return Uri.TryCreate(origin, UriKind.Absolute, out Uri? uri) &&
@@ -94,7 +151,7 @@ public static class McpHost
(System.Net.IPAddress.TryParse(uri.Host, out System.Net.IPAddress? address) && System.Net.IPAddress.IsLoopback(address)));
}
- private static McpServerTool[] CreateTools(TaskRegistry registry, LocaleCatalog locale, VisionLimits visionLimits)
+ private static McpServerTool[] CreateTools(TaskRegistry registry, LocaleCatalog locale, VisionLimits visionLimits, PersistentMcpTaskStore taskStore)
{
var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
@@ -102,7 +159,7 @@ public static class McpHost
TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow
};
- var target = new AgentTools(registry, locale, visionLimits);
+ var target = new AgentTools(registry, locale, visionLimits, taskStore);
McpServerTool[] tools = typeof(AgentTools)
.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)
.Where(static method => method.GetCustomAttribute<McpServerToolAttribute>() is not null)
diff --git a/src/Agent.Mcp/McpHostObservability.cs b/src/Agent.Mcp/McpHostObservability.cs
new file mode 100644
index 0000000..17beb07
--- /dev/null
+++ b/src/Agent.Mcp/McpHostObservability.cs
@@ -0,0 +1,37 @@
+namespace Agent.Mcp;
+
+public sealed record McpReadyInfo(string Transport, string? Endpoint, int ProcessId, string ProtocolVersion, bool Stateless, bool LegacyFallback);
+
+public sealed record McpHttpRequestInfo(
+ DateTimeOffset Timestamp,
+ string ConnectionId,
+ string TraceId,
+ string? RemoteIp,
+ int? RemotePort,
+ string? LocalIp,
+ int? LocalPort,
+ string Method,
+ string Path,
+ int Status,
+ double DurationMs,
+ string? ContentType,
+ long? ContentLength,
+ string? UserAgent,
+ string? ProtocolVersion,
+ string? McpMethod,
+ string? McpName);
+
+public sealed record McpHttpRequestStartInfo(
+ string ConnectionId,
+ string TraceId,
+ string? RemoteIp,
+ int RemotePort,
+ string Method,
+ string Path);
+
+public interface IMcpHostObserver
+{
+ void Ready(McpReadyInfo info);
+ void HttpRequestStarted(McpHttpRequestStartInfo info);
+ void HttpRequest(McpHttpRequestInfo info);
+}
diff --git a/src/Agent.Mcp/PersistentMcpTaskStore.cs b/src/Agent.Mcp/PersistentMcpTaskStore.cs
new file mode 100644
index 0000000..73608d4
--- /dev/null
+++ b/src/Agent.Mcp/PersistentMcpTaskStore.cs
@@ -0,0 +1,190 @@
+using System.Collections.Concurrent;
+using System.Text.Json;
+using ModelContextProtocol.Extensions.Tasks;
+using ModelContextProtocol.Protocol;
+
+namespace Agent.Mcp;
+
+public sealed record McpTaskStoreSettings(string Directory, TimeSpan TimeToLive, long PollIntervalMs);
+
+public sealed class PersistentMcpTaskStore : IMcpTaskStore
+{
+ private readonly ConcurrentDictionary<CancellationToken, Guid> _taskIdsByCancellation = new();
+ private readonly ConcurrentDictionary<string, StoredTask> _tasks = new(StringComparer.Ordinal);
+ private readonly McpTaskStoreSettings _settings;
+ private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web) { WriteIndented = true };
+
+ public PersistentMcpTaskStore(McpTaskStoreSettings settings)
+ {
+ _settings = settings;
+ System.IO.Directory.CreateDirectory(settings.Directory);
+ Recover();
+ }
+
+ public event Action<InputResponseReceivedEventArgs>? InputResponseReceived;
+
+ public Guid? ResolveTaskId(CancellationToken cancellationToken)
+ {
+ if (!_taskIdsByCancellation.TryRemove(cancellationToken, out Guid taskId)) return null;
+ return taskId;
+ }
+
+ public async Task<McpTaskInfo> CreateTaskAsync(CancellationToken cancellationToken)
+ {
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ var task = new StoredTask(Guid.NewGuid().ToString("D"), McpTaskStatus.Working, now, now,
+ _settings.TimeToLive, _settings.PollIntervalMs, "working", null, null, null);
+ if (!_tasks.TryAdd(task.TaskId, task)) throw new InvalidOperationException("Could not allocate an MCP task ID.");
+ await SaveAsync(task, cancellationToken).ConfigureAwait(false);
+ Guid createdId = Guid.Parse(task.TaskId);
+ _taskIdsByCancellation[cancellationToken] = createdId;
+ return ToInfo(task);
+ }
+
+ public Task<McpTaskInfo?> GetTaskAsync(string taskId, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!_tasks.TryGetValue(taskId, out StoredTask? task)) return Task.FromResult<McpTaskInfo?>(null);
+ if (IsExpired(task))
+ {
+ _tasks.TryRemove(taskId, out _);
+ Delete(taskId);
+ return Task.FromResult<McpTaskInfo?>(null);
+ }
+ return Task.FromResult<McpTaskInfo?>(ToInfo(task));
+ }
+
+ public Task SetCompletedAsync(string taskId, JsonElement result, CancellationToken cancellationToken) =>
+ UpdateAsync(taskId, McpTaskStatus.Completed, "completed", result, null, null, cancellationToken);
+
+ public Task SetFailedAsync(string taskId, JsonElement error, CancellationToken cancellationToken) =>
+ UpdateAsync(taskId, McpTaskStatus.Failed, "failed", null, error, null, cancellationToken);
+
+ public async Task<bool> SetCancelledAsync(string taskId, CancellationToken cancellationToken)
+ {
+ if (!_tasks.ContainsKey(taskId)) return false;
+ await UpdateAsync(taskId, McpTaskStatus.Cancelled, "cancelled", null, null, null, cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+
+ public async Task ResolveInputRequestsAsync(string taskId, IDictionary<string, InputResponse> responses, CancellationToken cancellationToken)
+ {
+ if (!_tasks.TryGetValue(taskId, out StoredTask? task)) return;
+ var pending = task.InputRequests is null
+ ? new Dictionary<string, InputRequest>(StringComparer.Ordinal)
+ : new Dictionary<string, InputRequest>(task.InputRequests, StringComparer.Ordinal);
+ var accepted = new List<KeyValuePair<string, InputResponse>>();
+ foreach ((string requestId, InputResponse response) in responses)
+ {
+ if (!pending.Remove(requestId)) continue;
+ accepted.Add(new KeyValuePair<string, InputResponse>(requestId, response));
+ }
+ await UpdateAsync(taskId, pending.Count == 0 ? McpTaskStatus.Working : McpTaskStatus.InputRequired,
+ pending.Count == 0 ? "working" : "input required", task.Result, task.Error,
+ pending.Count == 0 ? null : pending, cancellationToken).ConfigureAwait(false);
+ foreach ((string requestId, InputResponse response) in accepted)
+ InputResponseReceived?.Invoke(new InputResponseReceivedEventArgs { TaskId = taskId, RequestId = requestId, Response = response });
+ }
+
+ public Task SetInputRequestsAsync(string taskId, IDictionary<string, InputRequest> requests, CancellationToken cancellationToken) =>
+ UpdateAsync(taskId, McpTaskStatus.InputRequired, "input required", null, null,
+ new Dictionary<string, InputRequest>(requests, StringComparer.Ordinal), cancellationToken);
+
+ private async Task UpdateAsync(string taskId, McpTaskStatus status, string message, JsonElement? result,
+ JsonElement? error, IReadOnlyDictionary<string, InputRequest>? inputRequests, CancellationToken cancellationToken)
+ {
+ if (!_tasks.TryGetValue(taskId, out StoredTask? current)) throw new KeyNotFoundException($"MCP task '{taskId}' was not found.");
+ var updated = current with
+ {
+ Status = status,
+ LastUpdatedAt = DateTimeOffset.UtcNow,
+ StatusMessage = message,
+ Result = result,
+ Error = error,
+ InputRequests = inputRequests
+ };
+ _tasks[taskId] = updated;
+ await SaveAsync(updated, cancellationToken).ConfigureAwait(false);
+ }
+
+ private void Recover()
+ {
+ foreach (string path in System.IO.Directory.EnumerateFiles(_settings.Directory, "*.json", SearchOption.TopDirectoryOnly))
+ {
+ try
+ {
+ StoredTask? task = JsonSerializer.Deserialize<StoredTask>(File.ReadAllText(path), _json);
+ if (task is null || IsExpired(task)) { File.Delete(path); continue; }
+ if (task.Status is McpTaskStatus.Working or McpTaskStatus.InputRequired)
+ {
+ JsonElement error = JsonSerializer.SerializeToElement(new { code = "server_restarted", message = "The server restarted before the task completed." });
+ task = task with { Status = McpTaskStatus.Failed, StatusMessage = "server_restarted", Error = error, LastUpdatedAt = DateTimeOffset.UtcNow, InputRequests = null };
+ SaveSynchronously(task);
+ }
+ _tasks[task.TaskId] = task;
+ }
+ catch (JsonException)
+ {
+ File.Move(path, path + ".invalid", true);
+ }
+ }
+ }
+
+ private bool IsExpired(StoredTask task) => task.TimeToLive is { } ttl && DateTimeOffset.UtcNow - task.CreatedAt >= ttl;
+
+ private async ValueTask SaveAsync(StoredTask task, CancellationToken cancellationToken)
+ {
+ string path = PathFor(task.TaskId);
+ string temp = path + ".tmp-" + Guid.NewGuid().ToString("N");
+ try
+ {
+ await using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096,
+ FileOptions.Asynchronous | FileOptions.WriteThrough))
+ {
+ await JsonSerializer.SerializeAsync(stream, task, _json, cancellationToken).ConfigureAwait(false);
+ await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
+ }
+ File.Move(temp, path, true);
+ }
+ finally { if (File.Exists(temp)) File.Delete(temp); }
+ }
+
+ private void SaveSynchronously(StoredTask task)
+ {
+ string path = PathFor(task.TaskId);
+ string temp = path + ".tmp-" + Guid.NewGuid().ToString("N");
+ try
+ {
+ using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
+ {
+ JsonSerializer.Serialize(stream, task, _json);
+ stream.Flush(true);
+ }
+ File.Move(temp, path, true);
+ }
+ finally { if (File.Exists(temp)) File.Delete(temp); }
+ }
+
+ private void Delete(string taskId)
+ {
+ string path = PathFor(taskId);
+ if (File.Exists(path)) File.Delete(path);
+ }
+
+ private string PathFor(string taskId) => Path.Combine(_settings.Directory, taskId + ".json");
+
+ private static McpTaskInfo ToInfo(StoredTask task) => new(task.TaskId, task.Status, task.CreatedAt, task.LastUpdatedAt,
+ task.TimeToLive, task.PollIntervalMs, task.StatusMessage, task.Result, task.Error, task.InputRequests);
+
+ private sealed record StoredTask(
+ string TaskId,
+ McpTaskStatus Status,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset LastUpdatedAt,
+ TimeSpan? TimeToLive,
+ long? PollIntervalMs,
+ string StatusMessage,
+ JsonElement? Result,
+ JsonElement? Error,
+ IReadOnlyDictionary<string, InputRequest>? InputRequests);
+}
diff --git a/src/Agent.Mcp/QueuedTaskWorker.cs b/src/Agent.Mcp/QueuedTaskWorker.cs
index 23404e3..edea1ad 100644
--- a/src/Agent.Mcp/QueuedTaskWorker.cs
+++ b/src/Agent.Mcp/QueuedTaskWorker.cs
@@ -12,7 +12,15 @@ public sealed class QueuedTaskWorker(TaskRegistry registry, AgentRuntime runtime
{
if (!registry.Transition(taskId, AgentTaskState.Planning, "planning")) continue;
registry.Transition(taskId, AgentTaskState.Running, "ollama chat");
- await runtime.ExecuteAsync(taskId, stoppingToken).ConfigureAwait(false);
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, registry.GetCancellationToken(taskId));
+ try
+ {
+ await runtime.ExecuteAsync(taskId, linked.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (linked.IsCancellationRequested)
+ {
+ registry.Cancel(taskId);
+ }
}
}
}
diff --git a/tests/Agent.Core.Tests/Agent.Core.Tests.csproj b/tests/Agent.Core.Tests/Agent.Core.Tests.csproj
index 1326733..337bde9 100644
--- a/tests/Agent.Core.Tests/Agent.Core.Tests.csproj
+++ b/tests/Agent.Core.Tests/Agent.Core.Tests.csproj
@@ -8,6 +8,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Agent.Core\Agent.Core.csproj" />
<ProjectReference Include="..\..\src\Agent.App\Agent.App.csproj" />
+ <ProjectReference Include="..\Agent.Mcp.TestHost\Agent.Mcp.TestHost.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
diff --git a/tests/Agent.Core.Tests/McpHttpTests.cs b/tests/Agent.Core.Tests/McpHttpTests.cs
index 7e3f1cf..9edb133 100644
--- a/tests/Agent.Core.Tests/McpHttpTests.cs
+++ b/tests/Agent.Core.Tests/McpHttpTests.cs
@@ -6,6 +6,9 @@ using Agent.Core.Runtime;
using Agent.Mcp;
using ModelContextProtocol;
using ModelContextProtocol.Client;
+using ModelContextProtocol.Extensions.Tasks;
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
namespace Agent.Core.Tests;
@@ -15,9 +18,10 @@ public sealed class McpHttpTests
public async Task Loopback_streamable_http_works_and_get_sse_legacy_sse_are_rejected()
{
int port = FreePort();
+ await using var ollamaServer = new SharedFakeOllamaServer();
using var stop = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
LocaleCatalog locale = LocaleCatalog.Load(Path.GetFullPath("config/locales"), "en");
- var settings = new OllamaSettings(new Uri("http://127.0.0.1:1"), "unused", false, "0", new Dictionary<string, System.Text.Json.JsonElement>());
+ var settings = new OllamaSettings(ollamaServer.Endpoint, "unused", false, "0", new Dictionary<string, System.Text.Json.JsonElement>());
Task server = McpHost.RunHttpAsync(locale, settings, new AgentRuntimeSettings("", "en", [], "sandbox"), new VisionLimits(), "127.0.0.1", port, stop.Token);
using var http = new HttpClient();
Uri endpoint = new($"http://127.0.0.1:{port}/mcp");
@@ -33,10 +37,32 @@ public sealed class McpHttpTests
{
IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(["agent_run", "agent_status"], tools.Select(static tool => tool.Name).Order(StringComparer.Ordinal));
+
+ ResultOrCreatedTask<CallToolResult> taskCall = await client.CallToolAsTaskAsync(new CallToolRequestParams
+ {
+ Name = "agent_run",
+ Arguments = new Dictionary<string, JsonElement> { ["request"] = JsonSerializer.SerializeToElement("task extension test") }
+ }, TestContext.Current.CancellationToken);
+ Assert.True(taskCall.IsTask);
+ CreateTaskResult created = taskCall.TaskCreated!;
+ Assert.True(Guid.TryParse(created.TaskId, out _));
+ GetTaskResult? task = null;
+ for (int attempt = 0; attempt < 400; attempt++)
+ {
+ task = await client.GetTaskAsync(created.TaskId, TestContext.Current.CancellationToken);
+ if (task.Status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled) break;
+ await Task.Delay(25, TestContext.Current.CancellationToken);
+ }
+ Assert.NotNull(task);
+ Assert.Equal(McpTaskStatus.Completed, task.Status);
+ Assert.Contains(created.TaskId, JsonSerializer.Serialize(task), StringComparison.OrdinalIgnoreCase);
}
using HttpResponseMessage get = await http.GetAsync(endpoint, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.MethodNotAllowed, get.StatusCode);
+ Assert.False(get.Headers.Contains("Mcp-Session-Id"));
+ using HttpResponseMessage delete = await http.DeleteAsync(endpoint, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.MethodNotAllowed, delete.StatusCode);
using var hostileOrigin = new HttpRequestMessage(HttpMethod.Post, endpoint);
hostileOrigin.Headers.Add("Origin", "https://example.invalid");
using HttpResponseMessage forbidden = await http.SendAsync(hostileOrigin, TestContext.Current.CancellationToken);
diff --git a/tests/Agent.Core.Tests/McpStdioTests.cs b/tests/Agent.Core.Tests/McpStdioTests.cs
index 66f4851..fcef80e 100644
--- a/tests/Agent.Core.Tests/McpStdioTests.cs
+++ b/tests/Agent.Core.Tests/McpStdioTests.cs
@@ -1,4 +1,4 @@
-using Agent.App.Configuration;
+using Agent.Mcp.TestHost;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
@@ -15,17 +15,18 @@ public sealed class McpStdioTests
{
using var directory = new TestDirectory();
await using var ollama = new FakeOllamaServer();
- string configPath = CreateBundleConfiguration(directory, ollama.Endpoint);
- string application = typeof(ConfigurationLoader).Assembly.Location;
+ string localeDirectory = CreateLocale(directory);
+ string application = typeof(Marker).Assembly.Location;
var transport = new StdioClientTransport(new StdioClientTransportOptions
{
Name = "agent-mcp-test",
Command = "dotnet",
- Arguments = [application, "serve", "--config", configPath, "--lang", "en"],
+ Arguments = [application, localeDirectory, System.IO.Path.Combine(directory.Path, "mcp-tasks")],
WorkingDirectory = directory.Path
});
- await using McpClient client = await McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken);
+ await using McpClient client = await McpClient.CreateAsync(transport,
+ new McpClientOptions { ProtocolVersion = "2025-11-25" }, cancellationToken: TestContext.Current.CancellationToken);
IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(["agent_run", "agent_status"], tools.Select(static tool => tool.Name).Order(StringComparer.Ordinal));
foreach (McpClientTool tool in tools)
@@ -71,30 +72,10 @@ public sealed class McpStdioTests
return Guid.Parse(line[(key.Length + 1)..].Trim());
}
- private static string CreateBundleConfiguration(TestDirectory directory, Uri endpoint)
+ private static string CreateLocale(TestDirectory directory)
{
- directory.File("prompts/user-system.md", string.Empty);
directory.File("config/locales/en.json", """{"app.starting":"Starting","check.success":"OK","check.failure":"Failed {message}","check.wfp_admin_required":"Administrator required","config.invalid":"Invalid {message}","locale.invalid":"Locale {message}","mcp.task_queued":"Task {task} queued in {session}","mcp.task_not_found":"Task {task} not found","sandbox.access_denied":"Denied {path}","ollama.vision_unsupported":"No vision"}""");
- Directory.CreateDirectory(System.IO.Path.Combine(directory.Path, "skills"));
- return directory.File("agent.yaml", $$"""
- schema_version: 1
- application:
- language: en
- mcp:
- transport: stdio
- agent:
- user_system_prompt: prompts/user-system.md
- ollama:
- endpoint: {{endpoint}}
- model: model
- localization:
- directory: config/locales
- fallback: en
- skills:
- directory: skills
- logging:
- directory: logs
- """);
+ return System.IO.Path.Combine(directory.Path, "config", "locales");
}
private sealed class FakeOllamaServer : IAsyncDisposable
diff --git a/tests/Agent.Core.Tests/ObservabilityTests.cs b/tests/Agent.Core.Tests/ObservabilityTests.cs
new file mode 100644
index 0000000..cd86bc2
--- /dev/null
+++ b/tests/Agent.Core.Tests/ObservabilityTests.cs
@@ -0,0 +1,46 @@
+using Agent.App.Logging;
+using Agent.Core.Localization;
+using Agent.Mcp;
+
+namespace Agent.Core.Tests;
+
+public sealed class ObservabilityTests
+{
+ [Fact]
+ public void Ready_is_always_visible_even_when_log_threshold_is_fatal()
+ {
+ using var directory = new TestDirectory();
+ LocaleCatalog locale = LocaleCatalog.Load(Path.GetFullPath("config/locales"), "en");
+ using (var application = new RollingFileLog(directory.Path, 1024 * 1024, 2, "fatal"))
+ using (var observer = new McpHostLogObserver(application, locale, directory.Path, false, 1024 * 1024, 2))
+ observer.Ready(new McpReadyInfo("http", "http://127.0.0.1:8080/mcp", 42, "2026-07-28", true, true));
+
+ Assert.Contains("Agent is ready", File.ReadAllText(Path.Combine(directory.Path, "application.log")), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Ready_and_http_request_are_logged_without_sensitive_payloads()
+ {
+ using var directory = new TestDirectory();
+ LocaleCatalog locale = LocaleCatalog.Load(Path.GetFullPath("config/locales"), "en");
+ using (var application = new RollingFileLog(directory.Path, 1024 * 1024, 2))
+ {
+ using var observer = new McpHostLogObserver(application, locale, directory.Path, true, 1024 * 1024, 2);
+ observer.Ready(new McpReadyInfo("http", "http://127.0.0.1:8080/mcp", 42, "2026-07-28", true, true));
+ observer.HttpRequestStarted(new McpHttpRequestStartInfo("connection", "trace", "127.0.0.1", 50123, "POST", "/mcp"));
+ observer.HttpRequest(new McpHttpRequestInfo(DateTimeOffset.UtcNow, "connection", "trace", "127.0.0.1", 50123,
+ "127.0.0.1", 8080, "POST", "/mcp", 200, 12.5, "application/json", 100, "test-client",
+ "2026-07-28", "tools/list", "agent_run"));
+ }
+
+ string applicationText = File.ReadAllText(Path.Combine(directory.Path, "application.log"));
+ Assert.Contains("Agent is ready", applicationText, StringComparison.Ordinal);
+ Assert.Contains("remote=127.0.0.1:50123", applicationText, StringComparison.Ordinal);
+ string httpText = File.ReadAllText(Path.Combine(directory.Path, "http.jsonl"));
+ Assert.Contains("\"method\":\"POST\"", httpText, StringComparison.Ordinal);
+ Assert.Contains("\"remote_ip\":\"127.0.0.1\"", httpText, StringComparison.Ordinal);
+ Assert.DoesNotContain("Authorization", httpText, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("Cookie", httpText, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("prompt", httpText, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/tests/Agent.Core.Tests/PersistentMcpTaskStoreTests.cs b/tests/Agent.Core.Tests/PersistentMcpTaskStoreTests.cs
new file mode 100644
index 0000000..2b43459
--- /dev/null
+++ b/tests/Agent.Core.Tests/PersistentMcpTaskStoreTests.cs
@@ -0,0 +1,36 @@
+using System.Text.Json;
+using Agent.Mcp;
+using ModelContextProtocol.Extensions.Tasks;
+
+namespace Agent.Core.Tests;
+
+public sealed class PersistentMcpTaskStoreTests
+{
+ [Fact]
+ public async Task Persists_terminal_task_and_marks_incomplete_task_failed_after_restart()
+ {
+ using var directory = new TestDirectory();
+ var settings = new McpTaskStoreSettings(directory.Path, TimeSpan.FromHours(24), 1000);
+ var first = new PersistentMcpTaskStore(settings);
+ McpTaskInfo completed = await first.CreateTaskAsync(TestContext.Current.CancellationToken);
+ await first.SetCompletedAsync(completed.TaskId, JsonSerializer.SerializeToElement(new { ok = true }), TestContext.Current.CancellationToken);
+ McpTaskInfo working = await first.CreateTaskAsync(TestContext.Current.CancellationToken);
+
+ var recovered = new PersistentMcpTaskStore(settings);
+ Assert.Equal(McpTaskStatus.Completed, (await recovered.GetTaskAsync(completed.TaskId, TestContext.Current.CancellationToken))!.Status);
+ McpTaskInfo failed = (await recovered.GetTaskAsync(working.TaskId, TestContext.Current.CancellationToken))!;
+ Assert.Equal(McpTaskStatus.Failed, failed.Status);
+ Assert.Equal("server_restarted", failed.StatusMessage);
+ }
+
+ [Fact]
+ public async Task Expired_task_is_removed()
+ {
+ using var directory = new TestDirectory();
+ var store = new PersistentMcpTaskStore(new McpTaskStoreSettings(directory.Path, TimeSpan.FromMilliseconds(1), 1000));
+ McpTaskInfo task = await store.CreateTaskAsync(TestContext.Current.CancellationToken);
+ await Task.Delay(20, TestContext.Current.CancellationToken);
+ Assert.Null(await store.GetTaskAsync(task.TaskId, TestContext.Current.CancellationToken));
+ Assert.Empty(Directory.EnumerateFiles(directory.Path, "*.json"));
+ }
+}
diff --git a/tests/Agent.Core.Tests/SharedFakeOllamaServer.cs b/tests/Agent.Core.Tests/SharedFakeOllamaServer.cs
new file mode 100644
index 0000000..3264c77
--- /dev/null
+++ b/tests/Agent.Core.Tests/SharedFakeOllamaServer.cs
@@ -0,0 +1,55 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+
+namespace Agent.Core.Tests;
+
+internal sealed class SharedFakeOllamaServer : IAsyncDisposable
+{
+ private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
+ private readonly CancellationTokenSource _stop = new();
+ private readonly Task _loop;
+
+ public SharedFakeOllamaServer()
+ {
+ _listener.Start();
+ Endpoint = new Uri($"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/");
+ _loop = RunAsync();
+ }
+
+ public Uri Endpoint { get; }
+
+ private async Task RunAsync()
+ {
+ try
+ {
+ while (!_stop.IsCancellationRequested)
+ {
+ using TcpClient client = await _listener.AcceptTcpClientAsync(_stop.Token).ConfigureAwait(false);
+ NetworkStream stream = client.GetStream();
+ using var reader = new StreamReader(stream, Encoding.ASCII, false, 1024, true);
+ int contentLength = 0;
+ while (await reader.ReadLineAsync(_stop.Token).ConfigureAwait(false) is { } line && line.Length > 0)
+ if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) contentLength = int.Parse(line[15..].Trim(), System.Globalization.CultureInfo.InvariantCulture);
+ if (contentLength > 0)
+ {
+ char[] body = new char[contentLength];
+ await reader.ReadBlockAsync(body.AsMemory(), _stop.Token).ConfigureAwait(false);
+ }
+ byte[] payload = Encoding.UTF8.GetBytes("""{"message":{"role":"assistant","content":"done"},"prompt_eval_count":3,"eval_count":2}""");
+ byte[] header = Encoding.ASCII.GetBytes($"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {payload.Length}\r\nConnection: close\r\n\r\n");
+ await stream.WriteAsync(header, _stop.Token).ConfigureAwait(false);
+ await stream.WriteAsync(payload, _stop.Token).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException) when (_stop.IsCancellationRequested) { }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _stop.CancelAsync().ConfigureAwait(false);
+ _listener.Stop();
+ await _loop.ConfigureAwait(false);
+ _stop.Dispose();
+ }
+}
diff --git a/tests/Agent.Core.Tests/TaskRegistryTests.cs b/tests/Agent.Core.Tests/TaskRegistryTests.cs
index 33e16ab..28a2844 100644
--- a/tests/Agent.Core.Tests/TaskRegistryTests.cs
+++ b/tests/Agent.Core.Tests/TaskRegistryTests.cs
@@ -5,6 +5,19 @@ namespace Agent.Core.Tests;
public sealed class TaskRegistryTests
{
[Fact]
+ public async Task Explicit_task_id_can_be_cancelled_and_observed_as_terminal()
+ {
+ var registry = new TaskRegistry();
+ Guid taskId = Guid.NewGuid();
+ TaskSnapshot queued = registry.Enqueue("work", null, null, requestedTaskId: taskId);
+ Assert.Equal(taskId, queued.TaskId);
+ Assert.True(registry.Cancel(taskId));
+ TaskSnapshot terminal = await registry.WaitForTerminalAsync(taskId, TestContext.Current.CancellationToken);
+ Assert.Equal(AgentTaskState.Cancelled, terminal.State);
+ Assert.True(registry.GetCancellationToken(taskId).IsCancellationRequested);
+ }
+
+ [Fact]
public void NewTaskGetsUniqueTaskAndSessionIds()
{
var registry = new TaskRegistry();
diff --git a/tests/Agent.Mcp.TestHost/Agent.Mcp.TestHost.csproj b/tests/Agent.Mcp.TestHost/Agent.Mcp.TestHost.csproj
new file mode 100644
index 0000000..ff1c533
--- /dev/null
+++ b/tests/Agent.Mcp.TestHost/Agent.Mcp.TestHost.csproj
@@ -0,0 +1,8 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <OutputType>Exe</OutputType>
+ </PropertyGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\..\src\Agent.Mcp\Agent.Mcp.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/tests/Agent.Mcp.TestHost/ConformanceTools.cs b/tests/Agent.Mcp.TestHost/ConformanceTools.cs
new file mode 100644
index 0000000..3d8879d
--- /dev/null
+++ b/tests/Agent.Mcp.TestHost/ConformanceTools.cs
@@ -0,0 +1,92 @@
+using System.ComponentModel;
+using ModelContextProtocol;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+#pragma warning disable MCP9005 // The frozen alpha conformance fixture deliberately probes the legacy sampling capability.
+
+namespace Agent.Mcp.TestHost;
+
+[McpServerToolType]
+public sealed class ConformanceTools
+{
+ [McpServerTool(Name = "test_missing_capability")]
+ [Description("Conformance-only sampling capability probe.")]
+ public async Task<string> TestMissingCapability(McpServer server, CancellationToken cancellationToken)
+ {
+ await server.SampleAsync(new CreateMessageRequestParams
+ {
+ MaxTokens = 1,
+ Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "probe" }] }]
+ }, cancellationToken).ConfigureAwait(false);
+ return "sampled";
+ }
+
+ [McpServerTool(Name = "test_streaming_elicitation")]
+ [Description("Conformance-only response-stream framing probe.")]
+ public string TestStreamingElicitation() => "stream complete";
+
+ [McpServerTool(Name = "test_logging_tool")]
+ [Description("Conformance-only request log-level probe.")]
+ public string TestLoggingTool() => "no request-scoped log emitted";
+
+ [McpServerTool(Name = "confirm_delete")]
+ [Description("Conformance-only elicitation task fixture.")]
+ public string ConfirmDelete(RequestContext<CallToolRequestParams> context, string filename)
+ {
+ if (context.Params?.InputResponses?.Count > 0) return $"delete {filename}: accepted";
+ throw new InputRequiredException(new Dictionary<string, InputRequest>
+ {
+ ["confirm-delete"] = Elicitation($"Confirm deletion of {filename}", "confirm", new ElicitRequestParams.BooleanSchema())
+ }, "confirm-delete");
+ }
+
+ [McpServerTool(Name = "multi_input")]
+ [Description("Conformance-only multiple elicitation task fixture.")]
+ public string MultiInput(RequestContext<CallToolRequestParams> context)
+ {
+ if (context.Params?.InputResponses?.Count >= 2) return "both inputs received";
+ throw new InputRequiredException(new Dictionary<string, InputRequest>
+ {
+ ["first"] = Elicitation("First confirmation", "confirm", new ElicitRequestParams.BooleanSchema()),
+ ["second"] = Elicitation("Second confirmation", "confirm", new ElicitRequestParams.BooleanSchema())
+ }, "multi-input");
+ }
+
+ [McpServerTool(Name = "test_tool_with_task")]
+ [Description("Conformance-only MRTR and Tasks composition fixture.")]
+ public string TestToolWithTask(RequestContext<CallToolRequestParams> context)
+ {
+ if (context.Params?.InputResponses is { Count: > 0 } responses)
+ {
+ string response = System.Text.Json.JsonSerializer.Serialize(responses);
+ return response.Contains("Alice", StringComparison.Ordinal) ? "Hello, Alice" : "Hello, user";
+ }
+ throw new InputRequiredException(new Dictionary<string, InputRequest>
+ {
+ ["user-name"] = Elicitation("Provide a user name", "name", new ElicitRequestParams.StringSchema())
+ }, "user-name");
+ }
+
+ private static InputRequest Elicitation(string message, string propertyName, ElicitRequestParams.PrimitiveSchemaDefinition schema) =>
+ InputRequest.ForElicitation(new ElicitRequestParams
+ {
+ Mode = "form",
+ Message = message,
+ RequestedSchema = new ElicitRequestParams.RequestSchema
+ {
+ Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition> { [propertyName] = schema },
+ Required = [propertyName]
+ }
+ });
+
+ public sealed class Confirmation
+ {
+ public bool Confirm { get; set; }
+ }
+
+ public sealed class UserName
+ {
+ public string? Name { get; set; }
+ }
+}
diff --git a/tests/Agent.Mcp.TestHost/Program.cs b/tests/Agent.Mcp.TestHost/Program.cs
new file mode 100644
index 0000000..3447beb
--- /dev/null
+++ b/tests/Agent.Mcp.TestHost/Program.cs
@@ -0,0 +1,72 @@
+using Agent.Core.Localization;
+using Agent.Core.Ollama;
+using Agent.Core.Runtime;
+using Agent.Mcp;
+using ModelContextProtocol.Server;
+using ModelContextProtocol.Protocol;
+using System.Reflection;
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol;
+
+namespace Agent.Mcp.TestHost;
+
+public static class Marker;
+
+internal static class Program
+{
+ public static async Task Main(string[] args)
+ {
+ if (args.Length is not (2 or 4)) throw new ArgumentException("Expected <locale> <task-state> or http <port> <locale> <task-state>.");
+ bool http = args.Length == 4;
+ int offset = http ? 2 : 0;
+ LocaleCatalog locale = LocaleCatalog.Load(args[offset], "en");
+ var ollama = new OllamaSettings(new Uri("http://127.0.0.1:1"), "unused", false, "0", new Dictionary<string, System.Text.Json.JsonElement>());
+ var store = new McpTaskStoreSettings(args[offset + 1], TimeSpan.FromHours(24), 1000);
+ var runtime = new AgentRuntimeSettings("", "en", [], "sandbox");
+ McpServerTool[] fixtures =
+ [
+ McpServerTool.Create((string name) => $"Hello, {name}!", new McpServerToolCreateOptions { Name = "greet" }),
+ McpServerTool.Create(async (int seconds, CancellationToken cancellationToken) =>
+ {
+ await Task.Delay(TimeSpan.FromSeconds(seconds), cancellationToken).ConfigureAwait(false);
+ return $"Computed after {seconds} seconds";
+ }, new McpServerToolCreateOptions { Name = "slow_compute" }),
+ McpServerTool.Create(async () =>
+ {
+ await Task.Delay(1000).ConfigureAwait(false);
+ return new CallToolResult { IsError = true, Content = [new TextContentBlock { Text = "intentional tool error" }] };
+ }, new McpServerToolCreateOptions { Name = "failing_job" }),
+ McpServerTool.Create((Func<string>)(() => throw new InvalidOperationException("intentional protocol error")),
+ new McpServerToolCreateOptions { Name = "protocol_error_job" }),
+ .. typeof(ConformanceTools).GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
+ .Where(static method => method.GetCustomAttribute<McpServerToolAttribute>() is not null)
+ .Select(method => McpServerTool.Create(method, new ConformanceTools()))
+ ];
+ var taskTools = new HashSet<string>(["agent_run", "slow_compute", "confirm_delete", "multi_input", "protocol_error_job"], StringComparer.Ordinal);
+ var requiredTaskTools = new HashSet<string>(["failing_job", "test_tool_with_task"], StringComparer.Ordinal);
+ if (http)
+ await McpHost.RunHttpAsync(locale, ollama, runtime, new VisionLimits(), "127.0.0.1", int.Parse(args[1], System.Globalization.CultureInfo.InvariantCulture), CancellationToken.None,
+ taskStoreSettings: store, additionalTools: fixtures, taskCapableTools: taskTools, requiredTaskTools: requiredTaskTools,
+ configureBuilder: ConfigureConformanceFilters).ConfigureAwait(false);
+ else
+ await McpHost.RunStdioAsync(locale, ollama, runtime, new VisionLimits(), CancellationToken.None, taskStoreSettings: store).ConfigureAwait(false);
+ }
+
+ private static void ConfigureConformanceFilters(IMcpServerBuilder builder)
+ {
+ builder.WithRequestFilters(filters => filters.AddCallToolFilter(next => async (context, cancellationToken) =>
+ {
+ if (context.Params?.Name == "test_missing_capability")
+ {
+#pragma warning disable MCP9005 // Frozen alpha conformance probes the legacy sampling capability shape.
+ throw new MissingRequiredClientCapabilityException(
+ new ClientCapabilities { Sampling = new SamplingCapability() },
+ "The conformance diagnostic requires the sampling capability.");
+#pragma warning restore MCP9005
+ }
+ if (context.Params?.Name == "protocol_error_job")
+ throw new McpProtocolException("intentional protocol error", McpErrorCode.InternalError);
+ return await next(context, cancellationToken).ConfigureAwait(false);
+ }));
+ }
+}