Skip to content
Open
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
205 changes: 205 additions & 0 deletions src/A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,78 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo
return routeGroup;
}

/// <summary>Enables JSON-RPC A2A endpoints with per-request handler resolution.</summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="handlerFactory">Factory that resolves an <see cref="IA2ARequestHandler"/> per request from the <see cref="HttpContext"/>.</param>
/// <param name="path">The route path for the A2A endpoint.</param>
/// <returns>An endpoint convention builder for further configuration.</returns>
public static IEndpointConventionBuilder MapA2A(
this IEndpointRouteBuilder endpoints,
Func<HttpContext, IA2ARequestHandler> handlerFactory,
[StringSyntax("Route")] string path)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(handlerFactory);
ArgumentException.ThrowIfNullOrEmpty(path);

var routeGroup = endpoints.MapGroup("");
routeGroup.MapPost(path, async (HttpContext httpContext, CancellationToken cancellationToken) =>
{
IA2ARequestHandler handler;
try
{
handler = handlerFactory(httpContext);
}
catch (A2AException ex)
{
return new JsonRpcResponseResult(JsonRpcResponse.CreateJsonRpcErrorResponse(new JsonRpcId((string?)null), ex));
}

return await A2AJsonRpcProcessor.ProcessRequestAsync(handler, httpContext.Request, cancellationToken);
});

return routeGroup;
}

/// <summary>Enables JSON-RPC A2A endpoints and well-known agent card with per-request resolution.</summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="handlerFactory">Factory that resolves an <see cref="IA2ARequestHandler"/> per request from the <see cref="HttpContext"/>.</param>
/// <param name="agentCardFactory">Factory that resolves an <see cref="AgentCard"/> per request from the <see cref="HttpContext"/>.</param>
/// <param name="path">The route path for the A2A JSON-RPC endpoint.</param>
/// <returns>An endpoint convention builder for further configuration.</returns>
public static IEndpointConventionBuilder MapA2A(
this IEndpointRouteBuilder endpoints,
Func<HttpContext, IA2ARequestHandler> handlerFactory,
Func<HttpContext, AgentCard> agentCardFactory,
[StringSyntax("Route")] string path)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(handlerFactory);
ArgumentNullException.ThrowIfNull(agentCardFactory);
ArgumentException.ThrowIfNullOrEmpty(path);

var routeGroup = endpoints.MapGroup("");

routeGroup.MapPost(path, async (HttpContext httpContext, CancellationToken cancellationToken) =>
{
IA2ARequestHandler handler;
try
{
handler = handlerFactory(httpContext);
}
catch (A2AException ex)
{
return new JsonRpcResponseResult(JsonRpcResponse.CreateJsonRpcErrorResponse(new JsonRpcId((string?)null), ex));
}

return await A2AJsonRpcProcessor.ProcessRequestAsync(handler, httpContext.Request, cancellationToken);
});

routeGroup.MapGet(".well-known/agent-card.json", (HttpContext httpContext) => Results.Ok(agentCardFactory(httpContext)));

return routeGroup;
}

/// <summary>Enables the well-known agent card endpoint for agent discovery.</summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="agentCard">The agent card to serve.</param>
Expand All @@ -70,6 +142,25 @@ public static IEndpointConventionBuilder MapWellKnownAgentCard(this IEndpointRou
return routeGroup;
}

/// <summary>Enables the well-known agent card endpoint with per-request card resolution.</summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="agentCardFactory">Factory that resolves an <see cref="AgentCard"/> per request from the <see cref="HttpContext"/>.</param>
/// <param name="path">An optional route prefix.</param>
/// <returns>An endpoint convention builder for further configuration.</returns>
public static IEndpointConventionBuilder MapWellKnownAgentCard(
this IEndpointRouteBuilder endpoints,
Func<HttpContext, AgentCard> agentCardFactory,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This per-request card resolution is exactly what #353 was asking for — nice. One thing worth considering before it locks in: the factory signature is synchronous (Func<HttpContext, AgentCard>), and it's invoked synchronously in the GET handler just below (Results.Ok(agentCardFactory(httpContext))).

In practice, resolving the right agent card per request is often I/O-bound — looking it up from a tenant directory, a database, or a remote agent-definition service keyed off the subdomain/user in HttpContext. A synchronous signature forces those consumers into sync-over-async (.Result / .GetAwaiter().GetResult()) on the request path, which risks thread-pool starvation / deadlocks under load. #353 itself notes that v0.3's OnAgentCardQuery was awaitable, so async card resolution was a supported capability there.

Would you consider an async overload — Func<HttpContext, Task<AgentCard>> (or ValueTask<AgentCard>)? The GET handler can then await it cleanly:

routeGroup.MapGet(".well-known/agent-card.json",
    async (HttpContext httpContext) => Results.Ok(await agentCardFactory(httpContext)));

The same applies to the combined handler + card overloads in MapA2A and MapHttpA2A. Happy to help with the change if useful.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW the A2A.V0_3Compat layer in this same repo already takes an async card factory — Func<Task<AgentCard>> in MapAgentCardGetWithV03Compat (it awaits the factory before serving). Making the v1 overload async would keep it consistent with that existing precedent.

[StringSyntax("Route")] string path = "")
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agentCardFactory);

var routeGroup = endpoints.MapGroup(path);
routeGroup.MapGet(".well-known/agent-card.json", (HttpContext httpContext) => Results.Ok(agentCardFactory(httpContext)));

return routeGroup;
}

/// <summary>
/// Maps HTTP+JSON REST API endpoints for A2A.
/// </summary>
Expand Down Expand Up @@ -147,4 +238,118 @@ public static IEndpointConventionBuilder MapHttpA2A(

return routeGroup;
}

/// <summary>
/// Maps HTTP+JSON REST API endpoints for A2A with per-request handler and agent card resolution.
/// </summary>
/// <remarks>
/// <para>Use this overload for multi-agent hosting scenarios where the handler and agent card
/// vary per request (e.g., based on subdomain, authentication, or tenant context).</para>
/// <para><strong>Limitation:</strong> Multi-tenant route variants
/// (<c>/{tenant}/tasks/{id}</c>) defined in the A2A specification are not currently
/// supported. The <c>Tenant</c> field on request types will always be <c>null</c>
/// for REST API calls.</para>
/// </remarks>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="handlerFactory">Factory that resolves an <see cref="IA2ARequestHandler"/> per request.</param>
/// <param name="agentCardFactory">Factory that resolves an <see cref="AgentCard"/> per request.</param>
/// <param name="path">The route prefix for all REST endpoints.</param>
/// <returns>An endpoint convention builder for further configuration.</returns>
public static IEndpointConventionBuilder MapHttpA2A(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This overload is the right call for multi-agent hosting. One maintainability thought: it re-declares the full HTTP+JSON route table (tasks / message / pushNotificationConfigs / extendedAgentCard) that the existing fixed-handler MapHttpA2A overload just above already declares. Two parallel route tables tend to drift over time — adding a new endpoint, or changing one (a new query param, an error-mapping tweak), has to be mirrored by hand in both, and it's easy to update one and miss the other.

Since the fixed-handler case is just "the same routing with a constant handler/card," you could keep both public overloads but have the fixed one delegate to this factory one, so the route table lives in a single place:

public static IEndpointConventionBuilder MapHttpA2A(
    this IEndpointRouteBuilder endpoints, IA2ARequestHandler requestHandler, AgentCard agentCard, [StringSyntax("Route")] string path = "")
    => endpoints.MapHttpA2A(_ => requestHandler, _ => agentCard, path);

To be clear, this keeps the fixed-handler overload exactly as-is for callers (it's the ergonomic single-agent API) — it just routes its body through the factory version instead of duplicating the route registrations. The same pattern applies to the MapA2A overloads. Happy to help if useful.

this IEndpointRouteBuilder endpoints,
Func<HttpContext, IA2ARequestHandler> handlerFactory,
Func<HttpContext, AgentCard> agentCardFactory,
[StringSyntax("Route")] string path = "")
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(handlerFactory);
ArgumentNullException.ThrowIfNull(agentCardFactory);

var routeGroup = endpoints.MapGroup(path);
var logger = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("A2A.REST");

// Agent card
routeGroup.MapGet("/card", async (HttpContext httpContext, CancellationToken ct) =>
{
try { return await A2AHttpProcessor.GetAgentCardRestAsync(handlerFactory(httpContext), logger, agentCardFactory(httpContext), ct); }
catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); }
});

// Task operations
routeGroup.MapGet("/tasks/{id}", async (HttpContext httpContext, string id, [FromQuery] int? historyLength, CancellationToken ct) =>
{
try { return await A2AHttpProcessor.GetTaskRestAsync(handlerFactory(httpContext), logger, id, historyLength, ct); }
catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); }
});

routeGroup.MapPost("/tasks/{id}:cancel", async (HttpContext httpContext, string id, CancellationToken ct) =>
{
try { return await A2AHttpProcessor.CancelTaskRestAsync(handlerFactory(httpContext), logger, id, ct); }
catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); }
});

routeGroup.MapPost("/tasks/{id}:subscribe", (HttpContext httpContext, string id, CancellationToken ct) =>
{
try { return A2AHttpProcessor.SubscribeToTaskRest(handlerFactory(httpContext), logger, id, ct); }
catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); }
});

routeGroup.MapGet("/tasks", async (HttpContext httpContext, [FromQuery] string? contextId, [FromQuery] string? status,
[FromQuery] int? pageSize, [FromQuery] string? pageToken, [FromQuery] int? historyLength, CancellationToken ct) =>
{
try { return await A2AHttpProcessor.ListTasksRestAsync(handlerFactory(httpContext), logger, contextId, status, pageSize, pageToken, historyLength, ct); }
catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); }
});

// Message operations
routeGroup.MapPost("/message:send", async (HttpContext httpContext, [FromBody] SendMessageRequest request, CancellationToken ct) =>
{
try { return await A2AHttpProcessor.SendMessageRestAsync(handlerFactory(httpContext), logger, request, ct); }
catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); }
});

routeGroup.MapPost("/message:stream", (HttpContext httpContext, [FromBody] SendMessageRequest request, CancellationToken ct) =>
{
try { return A2AHttpProcessor.SendMessageStreamRest(handlerFactory(httpContext), logger, request, ct); }
catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); }
});

// Push notification config operations
routeGroup.MapPost("/tasks/{id}/pushNotificationConfigs",
async (HttpContext httpContext, string id, [FromBody] PushNotificationConfig config, CancellationToken ct) =>
{
try { return await A2AHttpProcessor.CreatePushNotificationConfigRestAsync(handlerFactory(httpContext), logger, id, config, ct); }
catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); }
});

routeGroup.MapGet("/tasks/{id}/pushNotificationConfigs",
async (HttpContext httpContext, string id, [FromQuery] int? pageSize, [FromQuery] string? pageToken, CancellationToken ct) =>
{
try { return await A2AHttpProcessor.ListPushNotificationConfigRestAsync(handlerFactory(httpContext), logger, id, pageSize, pageToken, ct); }
catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); }
});

routeGroup.MapGet("/tasks/{id}/pushNotificationConfigs/{configId}",
async (HttpContext httpContext, string id, string configId, CancellationToken ct) =>
{
try { return await A2AHttpProcessor.GetPushNotificationConfigRestAsync(handlerFactory(httpContext), logger, id, configId, ct); }
catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); }
});

routeGroup.MapDelete("/tasks/{id}/pushNotificationConfigs/{configId}",
async (HttpContext httpContext, string id, string configId, CancellationToken ct) =>
{
try { return await A2AHttpProcessor.DeletePushNotificationConfigRestAsync(handlerFactory(httpContext), logger, id, configId, ct); }
catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); }
});

// Extended agent card
routeGroup.MapGet("/extendedAgentCard", async (HttpContext httpContext, CancellationToken ct) =>
{
try { return await A2AHttpProcessor.GetExtendedAgentCardRestAsync(handlerFactory(httpContext), logger, ct); }
catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); }
});

return routeGroup;
}
}
2 changes: 1 addition & 1 deletion src/A2A.AspNetCore/A2AHttpProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ private static IResult WithExceptionHandling(ILogger logger, string activityName
}
}

private static IResult MapA2AExceptionToHttpResult(A2AException exception)
internal static IResult MapA2AExceptionToHttpResult(A2AException exception)
{
return exception.ErrorCode switch
{
Expand Down
13 changes: 12 additions & 1 deletion src/A2A.AspNetCore/A2AJsonRpcProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,18 @@ public static class A2AJsonRpcProcessor
return null;
}

internal static async Task<IResult> ProcessRequestAsync(IA2ARequestHandler requestHandler, HttpRequest request, CancellationToken cancellationToken)
/// <summary>
/// Processes an A2A JSON-RPC request, routing it to the appropriate handler method.
/// </summary>
/// <remarks>
/// Use this method directly for advanced scenarios such as custom per-request handler
/// routing or multi-agent middleware where the built-in <c>MapA2A</c> overloads are insufficient.
/// </remarks>
/// <param name="requestHandler">The handler to process the request.</param>
/// <param name="request">The incoming HTTP request containing the JSON-RPC payload.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>An <see cref="IResult"/> containing the JSON-RPC response.</returns>
public static async Task<IResult> ProcessRequestAsync(IA2ARequestHandler requestHandler, HttpRequest request, CancellationToken cancellationToken)
{
var preflightResult = CheckPreflight(request);
if (preflightResult != null) return preflightResult;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Moq;

namespace A2A.AspNetCore.Tests;
Expand Down Expand Up @@ -72,7 +73,7 @@ public void MapA2A_RequiresNonNullRequestHandler()
var app = WebApplication.CreateBuilder().Build();

// Act & Assert
Assert.Throws<ArgumentNullException>(() => app.MapA2A(null!, "/agent"));
Assert.Throws<ArgumentNullException>(() => app.MapA2A((IA2ARequestHandler)null!, "/agent"));
}

[Fact]
Expand All @@ -82,6 +83,93 @@ public void MapWellKnownAgentCard_RequiresNonNullAgentCard()
var app = WebApplication.CreateBuilder().Build();

// Act & Assert
Assert.Throws<ArgumentNullException>(() => app.MapWellKnownAgentCard(null!));
Assert.Throws<ArgumentNullException>(() => app.MapWellKnownAgentCard((AgentCard)null!));
}

// ── Factory overload tests ──

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice to see the factory overloads covered. These tests verify the wiring — the endpoint registers, null args throw — but they don't exercise the behavior the overloads actually add: none of them send a request through the factory. A few behaviors worth a request-level test (e.g. via TestServer / WebApplicationFactory):

  • The factory is actually invoked per request and the resolved handler processes it — ideally asserting that two requests can resolve to different handlers, since per-request resolution is the whole point of the overload.
  • When the factory throws A2AException, the response is the mapped JSON-RPC / HTTP error (the try/catch added here), not a 500.
  • The documented REST limitation (Tenant always null for these routes) — a small guard test would keep it from silently regressing.

As written, a regression in the per-request resolution or the error mapping would still pass these tests. Happy to help add a couple if useful.


[Fact]
public void MapA2A_WithFactory_RegistersEndpoint()
{
var app = WebApplication.CreateBuilder().Build();
Func<HttpContext, IA2ARequestHandler> factory = _ => new Mock<IA2ARequestHandler>().Object;
var result = app.MapA2A(factory, "/agent");
Assert.NotNull(result);
}

[Fact]
public void MapA2A_WithFactory_ThrowsArgumentNullException_WhenFactoryIsNull()
{
var app = WebApplication.CreateBuilder().Build();
Assert.Throws<ArgumentNullException>(() =>
app.MapA2A((Func<HttpContext, IA2ARequestHandler>)null!, "/agent"));
}

[Theory]
[InlineData(null)]
[InlineData("")]
public void MapA2A_WithFactory_ThrowsArgumentException_WhenPathIsNullOrEmpty(string? path)
{
var app = WebApplication.CreateBuilder().Build();
Func<HttpContext, IA2ARequestHandler> factory = _ => new Mock<IA2ARequestHandler>().Object;
if (path == null)
Assert.Throws<ArgumentNullException>(() => app.MapA2A(factory, path!));
else
Assert.Throws<ArgumentException>(() => app.MapA2A(factory, path));
}

[Fact]
public void MapA2A_WithHandlerAndCardFactories_RegistersEndpoint()
{
var app = WebApplication.CreateBuilder().Build();
Func<HttpContext, IA2ARequestHandler> handlerFactory = _ => new Mock<IA2ARequestHandler>().Object;
Func<HttpContext, AgentCard> cardFactory = _ => new AgentCard { Name = "Test", Description = "Test" };
var result = app.MapA2A(handlerFactory, cardFactory, "/agent");
Assert.NotNull(result);
}

[Fact]
public void MapWellKnownAgentCard_WithFactory_RegistersEndpoint()
{
var app = WebApplication.CreateBuilder().Build();
Func<HttpContext, AgentCard> factory = _ => new AgentCard { Name = "Test", Description = "Test" };
var result = app.MapWellKnownAgentCard(factory);
Assert.NotNull(result);
}

[Fact]
public void MapWellKnownAgentCard_WithFactory_ThrowsArgumentNullException_WhenFactoryIsNull()
{
var app = WebApplication.CreateBuilder().Build();
Assert.Throws<ArgumentNullException>(() =>
app.MapWellKnownAgentCard((Func<HttpContext, AgentCard>)null!));
}

[Fact]
public void MapHttpA2A_WithFactories_RegistersEndpoint()
{
var app = WebApplication.CreateBuilder().Build();
Func<HttpContext, IA2ARequestHandler> handlerFactory = _ => new Mock<IA2ARequestHandler>().Object;
Func<HttpContext, AgentCard> cardFactory = _ => new AgentCard { Name = "Test", Description = "Test" };
var result = app.MapHttpA2A(handlerFactory, cardFactory);
Assert.NotNull(result);
}

[Fact]
public void MapHttpA2A_WithFactories_ThrowsArgumentNullException_WhenHandlerFactoryIsNull()
{
var app = WebApplication.CreateBuilder().Build();
Func<HttpContext, AgentCard> cardFactory = _ => new AgentCard { Name = "Test", Description = "Test" };
Assert.Throws<ArgumentNullException>(() =>
app.MapHttpA2A((Func<HttpContext, IA2ARequestHandler>)null!, cardFactory));
}

[Fact]
public void MapHttpA2A_WithFactories_ThrowsArgumentNullException_WhenCardFactoryIsNull()
{
var app = WebApplication.CreateBuilder().Build();
Func<HttpContext, IA2ARequestHandler> handlerFactory = _ => new Mock<IA2ARequestHandler>().Object;
Assert.Throws<ArgumentNullException>(() =>
app.MapHttpA2A(handlerFactory, (Func<HttpContext, AgentCard>)null!));
}
}