summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-09 23:19:24 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-09 23:19:24 +0900
commit788df5e1d86c1bd91571d2b9cd1559599b65c9d6 (patch)
tree3d1d9bec9e8318bfc6749f9f8f1727436e3d6af1
parentb07b41cd435991cd254957af79055080c85b346e (diff)
feat(mcp): migrate server to MCP 2026-07-28
Replace the MCP 1.4.1 stateful HTTP design with the official C# SDK 2.0.0 stateless transport. The SDK now owns protocol negotiation, server/discover, standard MCP headers, GET and DELETE handling, error serialization, caching hints, and legacy initialize compatibility; production code no longer maintains a custom wire-protocol workaround for MCP Inspector. Separate transport state from application conversation state. HTTP does not issue Mcp-Session-Id, while the existing session_id remains an Agent conversation identifier. Preserve agent_run and agent_status for clients without Tasks support and add the official ModelContextProtocol.Extensions.Tasks 2.0.0 path for capable clients. Add persistent MCP Task storage with atomic JSON replacement, user-only state location, configurable TTL and poll interval, restart recovery, cancellation correlation, and public Agent-to-MCP state mapping. Keep conformance-only tools and protocol exception fixtures in a test host so they cannot enter the Release Bundle. Run administrator, BFE, WFP transaction, and filter add/delete preflight before binding. Fail fast with exit code 2 and actionable localized diagnostics when sandbox enforcement is unavailable. Emit ready only after the listener starts and keep stdio stdout protocol-pure. Add shared rotating application and HTTP request logs. Record real socket endpoints, connection and trace identifiers, method, path, result, timing, media metadata, User-Agent, and MCP metadata. Do not trust X-Forwarded-For and do not log authorization, cookies, query data, request or response bodies, prompts, images, or Base64 payloads. Add typed logging and Tasks configuration, Japanese and English messages, protocol and operations documentation, design/specification deltas, traceability, and exact conformance acquisition/offline runner scripts for @modelcontextprotocol/conformance 0.2.0-alpha.11. Node remains test-only and is not a runtime dependency. Conformance evidence: server-stateless passed 28 of 28 checks with zero unexpected failures; DNS rebinding and multiple POST stream scenarios passed. The alpha Tasks lifecycle/cancel/error/required-task checks passed, while its wire-schema validator misclassifies CreateTaskResult and its MRTR composition remains incompatible with SDK 2.0.0. The aggregate conformance Gate is therefore recorded as PARTIAL, not PASS. Test-host fixture gaps for tools-list descriptions, JSON Schema diagnostics, caching fixtures, and a header-validation/required-Task capability conflict are also recorded without expected-failure masking. Verification: Release build passed with 0 warnings and 0 errors; Planner tests 10/10 and Core tests 47/47 passed with skip 0. The last Windows suite run passed 19/25; five WFP cases require an elevated token and one symlink case requires unavailable host privilege, so the elevated security Gate remains open. A win-x64 self-contained 1.0.0 Bundle and ZIP were regenerated under build/Release/publish; runtime check was intentionally skipped in this non-elevated session. Evidence remains under ignored artifacts/evidence/mcp-2026-07-28. Harden release regeneration when another shell holds the previous Bundle directory as its current directory: validate the target and remove only its children instead of deleting the directory itself. build/ and artifacts/ remain excluded from Git.
-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);
+ }));
+ }
+}