diff options
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> @@ -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); + })); + } +} |
