Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/SalmonEgg.Acp/Mcp/McpServerConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ public enum McpServerTransport
/// Configuration for a stdio MCP server.
/// Communicates with the server over standard input/output.
/// </summary>
// The base [JsonConverter] only applies when a contract is *declared* as McpServer, so a derived
// declared root (AcpJsonContext lists one per transport) would otherwise serialize raw properties
// with no "type" discriminator and no v1/v2 gate. Repeating the converter on each derived record
// closes that bypass: every root routes through the same polymorphic writer.
[JsonConverter(typeof(McpServerJsonConverter))]
public sealed record StdioMcpServer : McpServer
{
/// <summary>
Expand Down Expand Up @@ -85,6 +90,11 @@ public StdioMcpServer(
/// Configuration for an HTTP MCP server.
/// Communicates with the server over HTTP requests.
/// </summary>
// The base [JsonConverter] only applies when a contract is *declared* as McpServer, so a derived
// declared root (AcpJsonContext lists one per transport) would otherwise serialize raw properties
// with no "type" discriminator and no v1/v2 gate. Repeating the converter on each derived record
// closes that bypass: every root routes through the same polymorphic writer.
[JsonConverter(typeof(McpServerJsonConverter))]
public sealed record HttpMcpServer : McpServer
{
/// <summary>
Expand Down Expand Up @@ -124,6 +134,11 @@ public HttpMcpServer(string name, string url, List<McpHttpHeader>? headers = nul
/// Configuration for an SSE (Server-Sent Events) MCP server.
/// Communicates with the server over an SSE stream.
/// </summary>
// The base [JsonConverter] only applies when a contract is *declared* as McpServer, so a derived
// declared root (AcpJsonContext lists one per transport) would otherwise serialize raw properties
// with no "type" discriminator and no v1/v2 gate. Repeating the converter on each derived record
// closes that bypass: every root routes through the same polymorphic writer.
[JsonConverter(typeof(McpServerJsonConverter))]
public sealed record SseMcpServer : McpServer
{
/// <summary>
Expand Down Expand Up @@ -165,6 +180,11 @@ public SseMcpServer(string name, string url, List<McpHttpHeader>? headers = null
/// stdio/http/sse): the spec requires a receiver to "preserve the raw payload" for a transport it does not
/// recognize, leaving it to the Agent rather than the client to accept or reject it.
/// </summary>
// The base [JsonConverter] only applies when a contract is *declared* as McpServer, so a derived
// declared root (AcpJsonContext lists one per transport) would otherwise serialize raw properties
// with no "type" discriminator and no v1/v2 gate. Repeating the converter on each derived record
// closes that bypass: every root routes through the same polymorphic writer.
[JsonConverter(typeof(McpServerJsonConverter))]
public sealed record CustomMcpServer : McpServer
{
/// <summary>
Expand Down Expand Up @@ -384,6 +404,11 @@ internal sealed class McpServerJsonConverter : JsonConverter<McpServer>
internal const string SseV1OnlyMessage =
"ACP MCP server transport sse is only available in protocolVersion 1.";

// The generated context validates a converter against its declared root with an exact-type
// CanConvert check; derived records reuse this converter for their own roots, so any type in
// the McpServer family is accepted and dispatch still happens on the runtime value in Write.
public override bool CanConvert(Type typeToConvert) => typeof(McpServer).IsAssignableFrom(typeToConvert);

public override McpServer? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var document = JsonDocument.ParseValue(ref reader);
Expand Down
5 changes: 5 additions & 0 deletions src/SalmonEgg.Acp/Serialization/AcpJsonContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ namespace SalmonEgg.Acp.Serialization;
[JsonSerializable(typeof(CustomMcpServer))]
[JsonSerializable(typeof(McpHttpHeader))]
[JsonSerializable(typeof(McpEnvVariable))]
// The derived McpServer records carry their own converter, so the generator no longer walks their
// properties and these two List<> infos must be declared explicitly: they are public members of the
// shipped context (AcpJsonContext.ListMcpHttpHeader / ListMcpEnvVariable).
[JsonSerializable(typeof(List<McpHttpHeader>))]
[JsonSerializable(typeof(List<McpEnvVariable>))]
[JsonSerializable(typeof(ContentBlock))]
[JsonSerializable(typeof(TextContentBlock))]
[JsonSerializable(typeof(ImageContentBlock))]
Expand Down
20 changes: 20 additions & 0 deletions tests/SalmonEgg.Acp.Tests/Protocol/V2WireContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,26 @@ public void McpServerV2_TypedSse_RefusesToSerializeAtEveryRoot()
Assert.Equal("events", stableSetup.RootElement.GetProperty("mcpServers")[0].GetProperty("name").GetString());
}

[Fact]
public void McpServerV2_DeclaredDerivedRoots_RouteThroughTheVersionGatedConverter()
{
// Arrange
var sse = new SseMcpServer("events", "https://example.test/events");
var stdio = new StdioMcpServer { Name = "tools", Command = "tool" };

// Act
var gated = Assert.Throws<JsonException>(() => JsonSerializer.Serialize(sse, Wire.V2<SseMcpServer>()));
using var v1Root = JsonDocument.Parse(JsonSerializer.Serialize(sse, Wire.V1<SseMcpServer>()));
using var v2StdioRoot = JsonDocument.Parse(JsonSerializer.Serialize(stdio, Wire.V2<StdioMcpServer>()));

// Assert
Assert.Contains(McpServerJsonConverter.SseV1OnlyMessage, gated.Message);
Assert.Equal("sse", v1Root.RootElement.GetProperty("type").GetString());
// The discriminator must survive at every declared root too, or a derived-root payload
// could not round-trip through the v2 read path (which rejects a type-less object).
Assert.Equal("stdio", v2StdioRoot.RootElement.GetProperty("type").GetString());
}

[Theory]
[InlineData("")]
[InlineData(",\"type\":null")]
Expand Down
Loading