diff --git a/CLAUDE.md b/CLAUDE.md index 9e3118b..4cf4cda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,7 +116,7 @@ Use `pwsh.exe` (PowerShell 7+) for all scripts. Do not use `powershell.exe`. ### Session Start (Run Once Per Session) 1. **Read `AGENTS-README-FIRST.yaml`** in the repo root for the current API key, endpoints, and base URL -2. **Verify marker signature** using HMAC-SHA256 with the workspace API key before contacting the server +2. **Verify marker signature** using `Initialize-McpSession` from `McpSession.psm1` (preferred), or by manually recomputing HMAC-SHA256 over the exact fields listed in `signature.fields` of the marker file in that order, each as `key=value\n`, trailing LF on the final line, UTF-8 encoded. Do not infer the payload shape from the YAML structure — use only the `signature.fields` list. 3. **GET `/health`** with a random nonce — confirm the response echoes that exact nonce 4. **Review recent session history** and current TODOs only after verification succeeds 5. **POST an initial session log turn** for the session diff --git a/Directory.Packages.props b/Directory.Packages.props index b20a6d2..b930ab7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,37 +5,40 @@ - - - - - - - + + + + + + + - - - + + + - - - - - - - - + + + + + + + + - - + + + + + @@ -50,8 +53,8 @@ - - + + @@ -68,18 +71,18 @@ - - + + - + - - + + diff --git a/GitVersion.yml b/GitVersion.yml index a6b29b0..5ec48e3 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -1,5 +1,5 @@ mode: ContinuousDelivery -next-version: 0.2.91 +next-version: 0.2.92 assembly-versioning-scheme: MajorMinorPatch assembly-informational-format: '{SemVer}+Branch.{BranchName}.Sha.{ShortSha}' branches: diff --git a/build/_build.csproj b/build/_build.csproj index 845f911..c8fa0f6 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -16,6 +16,10 @@ + + + + diff --git a/docs/Project/Functional-Requirements.md b/docs/Project/Functional-Requirements.md index 84bcc97..d5bb3d1 100644 --- a/docs/Project/Functional-Requirements.md +++ b/docs/Project/Functional-Requirements.md @@ -606,3 +606,9 @@ The server shall provide full CRUD operations for explicit graph entity nodes an The server shall provide endpoints to list indexed documents with chunk counts and token totals, retrieve chunks for a specific document ordered by chunk index, and delete a document with cascade removal of its chunks and corresponding vector index entries. All operations shall be workspace-scoped and available via REST endpoints, MCP tools, and REPL commands. +## FR-MCP-081 Self-Describing Marker Signature Canonicalization + +The generated `AGENTS-README-FIRST.yaml` marker file shall embed the complete `marker-v1` canonical field list and encoding contract inside the `signature` block so that any agent can reconstruct and verify the HMAC-SHA256 signature without consulting server source code or helper modules. + +**Covered by:** `MarkerFileService`, `MarkerSignature` + diff --git a/docs/Project/Technical-Requirements.md b/docs/Project/Technical-Requirements.md index 691cbfa..dc187b5 100644 --- a/docs/Project/Technical-Requirements.md +++ b/docs/Project/Technical-Requirements.md @@ -673,6 +673,13 @@ When a session is completed, the module SHALL remove both the legacy wrapper cac **Covered by:** `tools/powershell/McpSession.psm1`, `tools/powershell/McpTodo.psm1`, `tools/powershell/McpContext.psm1`, `docs/context/module-bootstrap.md`, `docs/USER-GUIDE.md` +## TR-MCP-SEC-005 + +**Self-Describing Marker Signature Payload** — The `MarkerSignature` model SHALL include an ordered `fields` array listing the 28 canonical field names used to construct the `marker-v1` HMAC-SHA256 payload, and a `format` string describing the `key=value\n` per-field encoding, trailing LF on the final line, and UTF-8 encoding. The `fields` array SHALL be the authoritative source of truth for field order; `BuildSignaturePayload` SHALL derive its output order from the same array so the two cannot diverge. +**Status:** ✅ Complete + +**Covered by:** `MarkerFileService`, `MarkerSignature`, `MarkerFileServiceTests` + ## TR-MCP-SEC-004 **Provider-Native At-Rest Encryption with No-Loss Transition Procedures** — The storage layer SHALL support optional at-rest encryption using only provider-native or provider-extension facilities: SQLite SEE, PostgreSQL `pg_tde` on Percona Server for PostgreSQL, and native SQL Server TDE. The implementation SHALL detect desired-versus-actual encryption state at startup, SHALL refuse to silently continue when the configured state and live state differ, and SHALL require explicit no-data-loss enable/disable/rotation procedures that preserve existing data when configuration changes. SQL Server LocalDB may be used for provider and migration coverage, but SQL Server TDE validation requires a non-LocalDB SQL Server target. diff --git a/src/McpServer.Services/McpServer.Services.csproj b/src/McpServer.Services/McpServer.Services.csproj index 90f5ff0..b956fa2 100644 --- a/src/McpServer.Services/McpServer.Services.csproj +++ b/src/McpServer.Services/McpServer.Services.csproj @@ -18,7 +18,6 @@ - diff --git a/src/McpServer.Services/Services/MarkerFileService.cs b/src/McpServer.Services/Services/MarkerFileService.cs index dd4384e..9c56ed2 100644 --- a/src/McpServer.Services/Services/MarkerFileService.cs +++ b/src/McpServer.Services/Services/MarkerFileService.cs @@ -22,6 +22,23 @@ public static class MarkerFileService public const string MarkerFileName = "AGENTS-README-FIRST.yaml"; internal const string MarkerSignatureCanonicalization = "marker-v1"; internal const string MarkerSignatureVerifier = "workspace_api_key"; + internal const string MarkerSignatureFormat = @"key=value\n per field in fields order; trailing LF on final line; UTF-8 encoded"; + + /// + /// TR-MCP-SEC-005: The authoritative ordered field list for the marker-v1 HMAC-SHA256 payload. + /// derives its field order from this array. + /// + /// 27 fields total: 10 top-level + 17 endpoint fields. + internal static readonly string[] SignaturePayloadFields = + [ + "canonicalization", "port", "baseUrl", "apiKey", "workspace", "workspacePath", + "pid", "startedAt", "markerWrittenAtUtc", "serverStartedAtUtc", + "endpoints.health", "endpoints.swagger", "endpoints.swaggerUi", "endpoints.mcpTransport", + "endpoints.sessionLog", "endpoints.sessionLogDialog", "endpoints.contextSearch", + "endpoints.contextPack", "endpoints.contextSources", "endpoints.todo", + "endpoints.repo", "endpoints.desktop", "endpoints.gitHub", "endpoints.tools", + "endpoints.workspace", "endpoints.serverStartupUtc", "endpoints.markerFileTimestamp", + ]; private const string WorkspaceStateDirectoryGitIgnoreEntry = ".mcpServer/"; private static readonly ISerializer s_yamlSerializer = new SerializerBuilder() @@ -110,6 +127,8 @@ public static async Task WriteMarkerAsync( Algorithm = "HMAC-SHA256", Canonicalization = MarkerSignatureCanonicalization, Verifier = MarkerSignatureVerifier, + Fields = SignaturePayloadFields, + Format = MarkerSignatureFormat, }, TrustBootstrap = new MarkerTrustBootstrap { @@ -289,34 +308,41 @@ internal static string BuildSignaturePayload(MarkerFile marker) { ArgumentNullException.ThrowIfNull(marker); + // Field values are resolved in the same order as SignaturePayloadFields (TR-MCP-SEC-005). + var fieldValues = new Dictionary(StringComparer.Ordinal) + { + ["canonicalization"] = marker.Signature.Canonicalization, + ["port"] = marker.Port.ToString(CultureInfo.InvariantCulture), + ["baseUrl"] = marker.BaseUrl, + ["apiKey"] = marker.ApiKey, + ["workspace"] = marker.Workspace, + ["workspacePath"] = marker.WorkspacePath, + ["pid"] = marker.Pid.ToString(CultureInfo.InvariantCulture), + ["startedAt"] = marker.StartedAt, + ["markerWrittenAtUtc"] = marker.MarkerWrittenAtUtc, + ["serverStartedAtUtc"] = marker.ServerStartedAtUtc, + ["endpoints.health"] = marker.Endpoints.Health, + ["endpoints.swagger"] = marker.Endpoints.Swagger, + ["endpoints.swaggerUi"] = marker.Endpoints.SwaggerUi, + ["endpoints.mcpTransport"] = marker.Endpoints.McpTransport, + ["endpoints.sessionLog"] = marker.Endpoints.SessionLog, + ["endpoints.sessionLogDialog"] = marker.Endpoints.SessionLogDialog, + ["endpoints.contextSearch"] = marker.Endpoints.ContextSearch, + ["endpoints.contextPack"] = marker.Endpoints.ContextPack, + ["endpoints.contextSources"] = marker.Endpoints.ContextSources, + ["endpoints.todo"] = marker.Endpoints.Todo, + ["endpoints.repo"] = marker.Endpoints.Repo, + ["endpoints.desktop"] = marker.Endpoints.Desktop, + ["endpoints.gitHub"] = marker.Endpoints.GitHub, + ["endpoints.tools"] = marker.Endpoints.Tools, + ["endpoints.workspace"] = marker.Endpoints.Workspace, + ["endpoints.serverStartupUtc"] = marker.Endpoints.ServerStartupUtc, + ["endpoints.markerFileTimestamp"] = marker.Endpoints.MarkerFileTimestamp, + }; + var builder = new StringBuilder(); - AppendPayloadLine(builder, "canonicalization", marker.Signature.Canonicalization); - AppendPayloadLine(builder, "port", marker.Port.ToString(CultureInfo.InvariantCulture)); - AppendPayloadLine(builder, "baseUrl", marker.BaseUrl); - AppendPayloadLine(builder, "apiKey", marker.ApiKey); - AppendPayloadLine(builder, "workspace", marker.Workspace); - AppendPayloadLine(builder, "workspacePath", marker.WorkspacePath); - AppendPayloadLine(builder, "pid", marker.Pid.ToString(CultureInfo.InvariantCulture)); - AppendPayloadLine(builder, "startedAt", marker.StartedAt); - AppendPayloadLine(builder, "markerWrittenAtUtc", marker.MarkerWrittenAtUtc); - AppendPayloadLine(builder, "serverStartedAtUtc", marker.ServerStartedAtUtc); - AppendPayloadLine(builder, "endpoints.health", marker.Endpoints.Health); - AppendPayloadLine(builder, "endpoints.swagger", marker.Endpoints.Swagger); - AppendPayloadLine(builder, "endpoints.swaggerUi", marker.Endpoints.SwaggerUi); - AppendPayloadLine(builder, "endpoints.mcpTransport", marker.Endpoints.McpTransport); - AppendPayloadLine(builder, "endpoints.sessionLog", marker.Endpoints.SessionLog); - AppendPayloadLine(builder, "endpoints.sessionLogDialog", marker.Endpoints.SessionLogDialog); - AppendPayloadLine(builder, "endpoints.contextSearch", marker.Endpoints.ContextSearch); - AppendPayloadLine(builder, "endpoints.contextPack", marker.Endpoints.ContextPack); - AppendPayloadLine(builder, "endpoints.contextSources", marker.Endpoints.ContextSources); - AppendPayloadLine(builder, "endpoints.todo", marker.Endpoints.Todo); - AppendPayloadLine(builder, "endpoints.repo", marker.Endpoints.Repo); - AppendPayloadLine(builder, "endpoints.desktop", marker.Endpoints.Desktop); - AppendPayloadLine(builder, "endpoints.gitHub", marker.Endpoints.GitHub); - AppendPayloadLine(builder, "endpoints.tools", marker.Endpoints.Tools); - AppendPayloadLine(builder, "endpoints.workspace", marker.Endpoints.Workspace); - AppendPayloadLine(builder, "endpoints.serverStartupUtc", marker.Endpoints.ServerStartupUtc); - AppendPayloadLine(builder, "endpoints.markerFileTimestamp", marker.Endpoints.MarkerFileTimestamp); + foreach (var field in SignaturePayloadFields) + AppendPayloadLine(builder, field, fieldValues[field]); return builder.ToString(); } @@ -360,6 +386,10 @@ internal sealed class MarkerSignature public string Algorithm { get; set; } = string.Empty; public string Canonicalization { get; set; } = string.Empty; public string Verifier { get; set; } = string.Empty; + /// TR-MCP-SEC-005: Ordered field names for the marker-v1 canonical payload. + public string[] Fields { get; set; } = []; + /// TR-MCP-SEC-005: Encoding contract for each payload line. + public string Format { get; set; } = string.Empty; public string Value { get; set; } = string.Empty; } diff --git a/tests/McpServer.Support.Mcp.Tests/Services/MarkerFileServiceTests.cs b/tests/McpServer.Support.Mcp.Tests/Services/MarkerFileServiceTests.cs index 22dd68e..7743154 100644 --- a/tests/McpServer.Support.Mcp.Tests/Services/MarkerFileServiceTests.cs +++ b/tests/McpServer.Support.Mcp.Tests/Services/MarkerFileServiceTests.cs @@ -258,8 +258,8 @@ public void ResolvePrompt_HandlebarsRendersWorkspaceProperties() /// This data is used to assert deterministic marker-file metadata and endpoint wiring in generated output. /// [Fact] - public async Task WriteMarkerAsync_EmitsUtcTimestampsAndDiagnosticsEndpoints() - { + public async Task WriteMarkerAsync_EmitsUtcTimestampsAndDiagnosticsEndpoints() + { var tempDir = Path.Combine(Path.GetTempPath(), "mcp-marker-test-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDir); @@ -281,16 +281,16 @@ await MarkerFileService.WriteMarkerAsync( Assert.Contains(globalPrompt, yaml); Assert.Contains("markerWrittenAtUtc:", yaml); Assert.Contains($"serverStartedAtUtc: {serverStartedAtUtc:o}", yaml); - Assert.Contains("serverStartupUtc: /server-startup-utc", yaml); - Assert.Contains("markerFileTimestamp: /marker-file-timestamp?repoPath={workspacePath}", yaml); - Assert.Contains("desktop: /mcpserver/desktop", yaml); - Assert.Contains("signature:", yaml); - Assert.Contains("trust_bootstrap:", yaml); - Assert.Contains("verifier: workspace_api_key", yaml); - Assert.Contains("health_nonce_parameter: nonce", yaml); - } - finally - { + Assert.Contains("serverStartupUtc: /server-startup-utc", yaml); + Assert.Contains("markerFileTimestamp: /marker-file-timestamp?repoPath={workspacePath}", yaml); + Assert.Contains("desktop: /mcpserver/desktop", yaml); + Assert.Contains("signature:", yaml); + Assert.Contains("trust_bootstrap:", yaml); + Assert.Contains("verifier: workspace_api_key", yaml); + Assert.Contains("health_nonce_parameter: nonce", yaml); + } + finally + { try { if (Directory.Exists(tempDir)) @@ -316,7 +316,7 @@ await MarkerFileService.WriteMarkerAsync( /// marker writing during workspace startup. /// [Fact] - public async Task WriteMarkerAsync_AddsMarkerAndMcpServerEntriesToGitIgnoreWithoutDuplicates() + public async Task WriteMarkerAsync_AddsMarkerAndMcpServerEntriesToGitIgnoreWithoutDuplicates() { var tempDir = Path.Combine(Path.GetTempPath(), "mcp-marker-gitignore-test-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDir); @@ -355,67 +355,206 @@ await MarkerFileService.WriteMarkerAsync( { // Best-effort cleanup for temp test directory. } - } - } - - /// - /// Verifies that marker-signature payload generation is deterministic for the same marker content. - /// - /// - /// Requirement coverage: FR-MCP-076, TR-MCP-SEC-003. - /// Test data: two in-memory marker objects with identical values. - /// This data is used to prove that signature verification can be reproduced by bootstrap clients. - /// - [Fact] - public void BuildSignaturePayload_WithSameMarkerValues_IsDeterministic() - { - var marker = new MarkerFile - { - Port = 7147, - BaseUrl = BaseUrl, - ApiKey = "marker-key", - Endpoints = new MarkerEndpoints - { - Health = "/health", - Swagger = "/swagger/v1/swagger.json", - SwaggerUi = "/swagger", - McpTransport = "/mcp-transport", - SessionLog = "/mcpserver/sessionlog", - SessionLogDialog = "/mcpserver/sessionlog/{agent}/{sessionId}/{requestId}/dialog", - ContextSearch = "/mcpserver/context/search", - ContextPack = "/mcpserver/context/pack", - ContextSources = "/mcpserver/context/sources", - Todo = "/mcpserver/todo", - Repo = "/mcpserver/repo", - Desktop = "/mcpserver/desktop", - GitHub = "/mcpserver/gh", - Tools = "/mcpserver/tools", - Workspace = "/mcpserver/workspace", - ServerStartupUtc = "/server-startup-utc", - MarkerFileTimestamp = "/marker-file-timestamp?repoPath={workspacePath}", - }, - Workspace = "test", - WorkspacePath = @"C:\test", - Pid = 123, - StartedAt = "2026-03-28T16:00:00.0000000Z", - MarkerWrittenAtUtc = "2026-03-28T16:00:00.0000000Z", - ServerStartedAtUtc = "2026-03-28T15:59:00.0000000Z", - Signature = new MarkerSignature - { - Algorithm = "HMAC-SHA256", - Canonicalization = MarkerFileService.MarkerSignatureCanonicalization, - Verifier = MarkerFileService.MarkerSignatureVerifier, - }, - TrustBootstrap = new MarkerTrustBootstrap(), - Prompt = "Prompt", - }; - - var payloadA = MarkerFileService.BuildSignaturePayload(marker); - var payloadB = MarkerFileService.BuildSignaturePayload(marker); - var signatureA = MarkerFileService.ComputeMarkerSignature(marker); - var signatureB = MarkerFileService.ComputeMarkerSignature(marker); - - Assert.Equal(payloadA, payloadB); - Assert.Equal(signatureA, signatureB); - } -} + } + } + + /// + /// Verifies that marker-signature payload generation is deterministic for the same marker content. + /// + /// + /// Requirement coverage: FR-MCP-076, TR-MCP-SEC-003. + /// Test data: two in-memory marker objects with identical values. + /// This data is used to prove that signature verification can be reproduced by bootstrap clients. + /// + [Fact] + public void BuildSignaturePayload_WithSameMarkerValues_IsDeterministic() + { + var marker = new MarkerFile + { + Port = 7147, + BaseUrl = BaseUrl, + ApiKey = "marker-key", + Endpoints = new MarkerEndpoints + { + Health = "/health", + Swagger = "/swagger/v1/swagger.json", + SwaggerUi = "/swagger", + McpTransport = "/mcp-transport", + SessionLog = "/mcpserver/sessionlog", + SessionLogDialog = "/mcpserver/sessionlog/{agent}/{sessionId}/{requestId}/dialog", + ContextSearch = "/mcpserver/context/search", + ContextPack = "/mcpserver/context/pack", + ContextSources = "/mcpserver/context/sources", + Todo = "/mcpserver/todo", + Repo = "/mcpserver/repo", + Desktop = "/mcpserver/desktop", + GitHub = "/mcpserver/gh", + Tools = "/mcpserver/tools", + Workspace = "/mcpserver/workspace", + ServerStartupUtc = "/server-startup-utc", + MarkerFileTimestamp = "/marker-file-timestamp?repoPath={workspacePath}", + }, + Workspace = "test", + WorkspacePath = @"C:\test", + Pid = 123, + StartedAt = "2026-03-28T16:00:00.0000000Z", + MarkerWrittenAtUtc = "2026-03-28T16:00:00.0000000Z", + ServerStartedAtUtc = "2026-03-28T15:59:00.0000000Z", + Signature = new MarkerSignature + { + Algorithm = "HMAC-SHA256", + Canonicalization = MarkerFileService.MarkerSignatureCanonicalization, + Verifier = MarkerFileService.MarkerSignatureVerifier, + }, + TrustBootstrap = new MarkerTrustBootstrap(), + Prompt = "Prompt", + }; + + var payloadA = MarkerFileService.BuildSignaturePayload(marker); + var payloadB = MarkerFileService.BuildSignaturePayload(marker); + var signatureA = MarkerFileService.ComputeMarkerSignature(marker); + var signatureB = MarkerFileService.ComputeMarkerSignature(marker); + + Assert.Equal(payloadA, payloadB); + Assert.Equal(signatureA, signatureB); + } + + /// + /// Verifies that contains exactly 28 entries + /// and that each payload line produced by starts + /// with the corresponding field name in declaration order. + /// + /// + /// Requirement coverage: FR-MCP-081, TR-MCP-SEC-005. + /// Test data: a fully populated in-memory ; no external dependencies. + /// This data is used to ensure the public fields array and the payload builder remain in sync + /// so the marker is self-describing for any agent performing manual verification. + /// + [Fact] + public void SignaturePayloadFields_MatchesBuildSignaturePayloadFieldOrder() + { + var marker = new MarkerFile + { + Port = 7147, + BaseUrl = BaseUrl, + ApiKey = "test-key", + Endpoints = new MarkerEndpoints + { + Health = "/health", + Swagger = "/swagger/v1/swagger.json", + SwaggerUi = "/swagger", + McpTransport = "/mcp-transport", + SessionLog = "/mcpserver/sessionlog", + SessionLogDialog = "/mcpserver/sessionlog/{agent}/{sessionId}/{requestId}/dialog", + ContextSearch = "/mcpserver/context/search", + ContextPack = "/mcpserver/context/pack", + ContextSources = "/mcpserver/context/sources", + Todo = "/mcpserver/todo", + Repo = "/mcpserver/repo", + Desktop = "/mcpserver/desktop", + GitHub = "/mcpserver/gh", + Tools = "/mcpserver/tools", + Workspace = "/mcpserver/workspace", + ServerStartupUtc = "/server-startup-utc", + MarkerFileTimestamp = "/marker-file-timestamp?repoPath={workspacePath}", + }, + Workspace = "test", + WorkspacePath = @"C:\test", + Pid = 1, + StartedAt = "2026-01-01T00:00:00.0000000Z", + MarkerWrittenAtUtc = "2026-01-01T00:00:00.0000000Z", + ServerStartedAtUtc = "2026-01-01T00:00:00.0000000Z", + Signature = new MarkerSignature + { + Algorithm = "HMAC-SHA256", + Canonicalization = MarkerFileService.MarkerSignatureCanonicalization, + Verifier = MarkerFileService.MarkerSignatureVerifier, + }, + TrustBootstrap = new MarkerTrustBootstrap(), + Prompt = string.Empty, + }; + + var fields = MarkerFileService.SignaturePayloadFields; + var payload = MarkerFileService.BuildSignaturePayload(marker); + var lines = payload.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + Assert.Equal(27, fields.Length); + Assert.Equal(fields.Length, lines.Length); + for (var i = 0; i < fields.Length; i++) + { + Assert.StartsWith($"{fields[i]}=", lines[i]); + } + } + + /// + /// Verifies that the written marker YAML contains a fields list and a format entry + /// inside the signature block so that any agent can reconstruct the payload without + /// consulting server source code or helper modules. + /// + /// + /// Requirement coverage: FR-MCP-081, TR-MCP-SEC-005. + /// Test data: temp workspace directory and minimal global prompt string. + /// This data is used to assert the marker YAML is self-describing for trust bootstrap. + /// + [Fact] + public async Task WriteMarkerAsync_SignatureBlock_ContainsFieldsListAndFormat() + { + var tempDir = Path.Combine(Path.GetTempPath(), "mcp-marker-fields-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + + try + { + await MarkerFileService.WriteMarkerAsync( + workspacePath: tempDir, + port: 7147, + workspaceName: "test", + globalPromptTemplate: "Test"); + + var yaml = await File.ReadAllTextAsync(Path.Combine(tempDir, MarkerFileService.MarkerFileName)); + + Assert.Contains("fields:", yaml); + Assert.Contains("- canonicalization", yaml); + Assert.Contains("- endpoints.gitHub", yaml); + Assert.Contains("- endpoints.markerFileTimestamp", yaml); + Assert.Contains("format:", yaml); + } + finally + { + try { Directory.Delete(tempDir, recursive: true); } catch { } + } + } + + /// + /// Verifies that the format field in the written marker signature block describes + /// LF line endings and UTF-8 encoding so agents have the encoding contract in the file. + /// + /// + /// Requirement coverage: FR-MCP-081, TR-MCP-SEC-005. + /// Test data: temp workspace directory and minimal global prompt string. + /// This data is used to confirm the marker encoding contract is readable without external references. + /// + [Fact] + public async Task WriteMarkerAsync_SignatureBlock_FormatDescribesEncodingContract() + { + var tempDir = Path.Combine(Path.GetTempPath(), "mcp-marker-format-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + + try + { + await MarkerFileService.WriteMarkerAsync( + workspacePath: tempDir, + port: 7147, + workspaceName: "test", + globalPromptTemplate: "Test"); + + var yaml = await File.ReadAllTextAsync(Path.Combine(tempDir, MarkerFileService.MarkerFileName)); + + Assert.Contains("UTF-8", yaml); + Assert.Contains(@"\n", yaml); + } + finally + { + try { Directory.Delete(tempDir, recursive: true); } catch { } + } + } +}