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
17 changes: 15 additions & 2 deletions src/SalmonEgg.Acp/Content/ContentBlock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using SalmonEgg.Acp.Protocol;
using SalmonEgg.Acp.Serialization;

namespace SalmonEgg.Acp.Content
{
Expand Down Expand Up @@ -156,7 +157,7 @@ private static AudioContentBlock ReadAudio(JsonElement root)
return block;
}

private static ResourceLinkContentBlock ReadResourceLink(JsonElement root)
internal static ResourceLinkContentBlock ReadResourceLink(JsonElement root)
{
var block = new ResourceLinkContentBlock
{
Expand All @@ -166,6 +167,7 @@ private static ResourceLinkContentBlock ReadResourceLink(JsonElement root)
Title = ReadString(root, "title"),
Description = ReadString(root, "description"),
Size = ReadInt64(root, "size"),
RawIcons = root.TryGetProperty("icons", out var icons) ? icons.Clone() : null,
Annotations = ReadAnnotations(root),
Meta = AcpMetaJson.Read(root)
};
Expand Down Expand Up @@ -354,8 +356,13 @@ private static void WriteAudio(Utf8JsonWriter writer, AudioContentBlock value, J
writer.WriteEndObject();
}

private static void WriteResourceLink(Utf8JsonWriter writer, ResourceLinkContentBlock value, JsonSerializerOptions options)
internal static void WriteResourceLink(Utf8JsonWriter writer, ResourceLinkContentBlock value, JsonSerializerOptions options)
{
if (value.HasDraftIcons && AcpWireFormat.NegotiatedVersion(options) != AcpProtocolVersion.V2)
{
throw new JsonException("Authored resource icons require ACP v2 wire; received unknown fields remain passthrough.");
}

writer.WriteStartObject();
writer.WriteString("type", value.Type);
WriteAnnotations(writer, value.Annotations, options);
Expand All @@ -365,6 +372,12 @@ private static void WriteResourceLink(Utf8JsonWriter writer, ResourceLinkContent
WriteNullableString(writer, "title", value.Title, options);
WriteNullableString(writer, "description", value.Description, options);
WriteNullableNumber(writer, "size", value.Size, options);
if (value.RawIcons is { } icons)
{
writer.WritePropertyName("icons");
writer.WriteRawValue(icons.GetRawText());
}

AcpMetaJson.Write(writer, value.Meta);
writer.WriteEndObject();
}
Expand Down
21 changes: 21 additions & 0 deletions src/SalmonEgg.Acp/Content/ResourceLinkContentBlock.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace SalmonEgg.Acp.Content
Expand All @@ -6,6 +8,7 @@ namespace SalmonEgg.Acp.Content
/// Resource link content block.
/// Represents a reference to an external resource (a URI link).
/// </summary>
[JsonConverter(typeof(ResourceLinkContentBlockJsonConverter))]
public sealed record ResourceLinkContentBlock : ContentBlock
{
/// <summary>
Expand Down Expand Up @@ -52,6 +55,12 @@ public sealed record ResourceLinkContentBlock : ContentBlock
[JsonPropertyName("size")]
public long? Size { get; init; }

[JsonIgnore]
internal JsonElement? RawIcons { get; init; }

[JsonIgnore]
internal bool HasDraftIcons { get; init; }

/// <summary>
/// Creates a new resource link content block instance.
/// </summary>
Expand Down Expand Up @@ -84,4 +93,16 @@ public ResourceLinkContentBlock(
Size = size;
}
}

internal sealed class ResourceLinkContentBlockJsonConverter : JsonConverter<ResourceLinkContentBlock>
{
public override ResourceLinkContentBlock? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var document = JsonDocument.ParseValue(ref reader);
return ContentBlockJsonConverter.ReadResourceLink(document.RootElement);
}

public override void Write(Utf8JsonWriter writer, ResourceLinkContentBlock value, JsonSerializerOptions options)
=> ContentBlockJsonConverter.WriteResourceLink(writer, value, options);
}
}
65 changes: 65 additions & 0 deletions src/SalmonEgg.Acp/Content/ResourceLinkDraftExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Text.Json;
using SalmonEgg.Acp.Protocol;
using SalmonEgg.Acp.Serialization;

namespace SalmonEgg.Acp.Content;

/// <summary>Opt-in access to ACP v2 resource-link icon metadata.</summary>
[Experimental(AcpDraftProtocol.DiagnosticId, Message = AcpDraftProtocol.Message, UrlFormat = AcpDraftProtocol.UrlFormat)]
public static class ResourceLinkDraftExtensions
{
/// <summary>Returns supported icon entries, applying the schema's default-on-error and skip-invalid-items rules.</summary>
public static IReadOnlyList<Icon> GetIcons(this ResourceLinkContentBlock resource)
{
ArgumentNullException.ThrowIfNull(resource);
var result = new List<Icon>();
if (resource.RawIcons is not { ValueKind: JsonValueKind.Array } icons)
{
return result;
}

var typeInfo = AcpWireFormat.For(AcpProtocolVersion.V2).TypeInfo<Icon>();
foreach (var item in icons.EnumerateArray())
{
try
{
if (item.Deserialize(typeInfo) is { } icon)
{
result.Add(icon);
}
}
catch (JsonException)
{
// ResourceLink.icons alone grants per-item recovery; Icon.src remains required.
}
}

return result;
}

/// <summary>Creates a resource link with v2 icon metadata. Writing the result requires a v2 wire context.</summary>
public static ResourceLinkContentBlock WithIcons(this ResourceLinkContentBlock resource, IReadOnlyList<Icon> icons)
{
ArgumentNullException.ThrowIfNull(resource);
ArgumentNullException.ThrowIfNull(icons);
var typeInfo = AcpWireFormat.For(AcpProtocolVersion.V2).TypeInfo<Icon>();
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
writer.WriteStartArray();
foreach (var icon in icons)
{
JsonSerializer.Serialize(writer, icon, typeInfo);
}

writer.WriteEndArray();
}

using var document = JsonDocument.Parse(stream.ToArray());
return resource with { RawIcons = document.RootElement.Clone(), HasDraftIcons = true };
}
}
26 changes: 17 additions & 9 deletions src/SalmonEgg.Acp/Mcp/McpServerConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ internal sealed class McpServerJsonConverter : JsonConverter<McpServer>
{
using var document = JsonDocument.ParseValue(ref reader);
var root = document.RootElement;
var transport = ResolveTransport(root);
var transport = ResolveTransport(root, options);

return transport switch
{
Expand Down Expand Up @@ -429,22 +429,30 @@ public override void Write(Utf8JsonWriter writer, McpServer value, JsonSerialize
}
}

private static McpServerTransport ResolveTransport(JsonElement root)
private static McpServerTransport ResolveTransport(JsonElement root, JsonSerializerOptions options)
{
if (!root.TryGetProperty("type", out var typeElement)
|| typeElement.ValueKind != JsonValueKind.String)
var version = AcpWireFormat.NegotiatedVersion(options);
if (!root.TryGetProperty("type", out var typeElement))
{
if (version == AcpProtocolVersion.V2)
{
throw new JsonException("ACP v2 MCP server requires a string 'type' discriminator.");
}

return McpServerTransport.Stdio;
}

if (typeElement.ValueKind != JsonValueKind.String)
{
throw new JsonException("MCP server 'type' must be a string when provided.");
}

return typeElement.GetString() switch
{
"stdio" => McpServerTransport.Stdio,
"http" => McpServerTransport.Http,
"sse" => McpServerTransport.Sse,
// V2 schema "other" branch: any type value other than stdio/http/sse (including `_` extensions and
// future ACP variants) must preserve the raw payload for forward passthrough, leaving it to the Agent
// rather than the client to tighten. Read is purely tolerant and does not branch on version.
"sse" when version == AcpProtocolVersion.V1 => McpServerTransport.Sse,
// V2 removed SSE; it belongs to the same raw passthrough as future transports.
_ => McpServerTransport.Custom
};
}
Expand Down Expand Up @@ -630,7 +638,7 @@ private static List<TValue> ReadNameValueArray<TValue>(
private static void WriteStdio(Utf8JsonWriter writer, StdioMcpServer stdio, JsonSerializerOptions options)
{
writer.WriteStartObject();
// The V2 schema discriminates stdio/http/sse via the `type` field; V1 stdio has no type field and is
// The V2 schema discriminates stdio/http via the `type` field; V1 stdio has no type field and is
// identified implicitly by its absence. Write type only when the negotiated version is V2, so a V1 Agent
// is never sent a field it does not recognize.
if (AcpWireFormat.NegotiatedVersion(options) >= AcpProtocolVersion.V2)
Expand Down
84 changes: 84 additions & 0 deletions src/SalmonEgg.Acp/Protocol/AuthCapabilityTypes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using SalmonEgg.Acp.Serialization;

namespace SalmonEgg.Acp.Protocol;

/// <summary>Opt-in authentication method types the client can execute.</summary>
[JsonConverter(typeof(AuthCapabilitiesJsonConverter))]
public sealed record AuthCapabilities : AcpProtocolObject
{
/// <summary>
/// Whether the client can reproduce the configured agent invocation in an interactive terminal.
/// This is a boolean in v1 and a presence marker in v2. An empty auth object advertises no support.
/// </summary>
[JsonPropertyName("terminal")]
public bool Terminal { get; init; }

internal JsonElement? RawPayload { get; init; }
}

internal sealed class AuthCapabilitiesJsonConverter : JsonConverter<AuthCapabilities>
{
public override AuthCapabilities? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var document = JsonDocument.ParseValue(ref reader);
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
throw new JsonException("ACP authentication capabilities must be an object.");
}

var terminal = root.TryGetProperty("terminal", out var value)
&& (AcpWireFormat.NegotiatedVersion(options) == AcpProtocolVersion.V2
? value.ValueKind == JsonValueKind.Object
: value.ValueKind == JsonValueKind.True);

return new AuthCapabilities
{
Terminal = terminal,
Meta = root.TryGetProperty("_meta", out var meta) && meta.ValueKind == JsonValueKind.Object
? AcpMetaJson.Read(root) : null,
RawPayload = root.Clone()
};
}

public override void Write(Utf8JsonWriter writer, AuthCapabilities value, JsonSerializerOptions options)
{
writer.WriteStartObject();
if (AcpWireFormat.NegotiatedVersion(options) == AcpProtocolVersion.V1)
{
writer.WriteBoolean("terminal", value.Terminal);
}
else if (value.Terminal)
{
writer.WritePropertyName("terminal");
if (value.RawPayload is { } raw && raw.TryGetProperty("terminal", out var terminal)
&& terminal.ValueKind == JsonValueKind.Object)
{
writer.WriteRawValue(terminal.GetRawText());
}
else
{
writer.WriteStartObject();
writer.WriteEndObject();
}
}

AcpMetaJson.Write(writer, value.Meta);
if (value.RawPayload is { } payload)
{
foreach (var property in payload.EnumerateObject())
{
if (property.Name is not "terminal" and not "_meta")
{
writer.WritePropertyName(property.Name);
writer.WriteRawValue(property.Value.GetRawText());
}
}
}

writer.WriteEndObject();
}
}
Loading
Loading