diff --git a/boms/extras/pom.xml b/boms/extras/pom.xml index 7ccb9c71e..456b69df3 100644 --- a/boms/extras/pom.xml +++ b/boms/extras/pom.xml @@ -89,6 +89,11 @@ a2a-java-queue-manager-replication-mp-reactive ${project.version} + + ${project.groupId} + a2a-java-extras-multitenancy + ${project.version} + diff --git a/boms/extras/src/it/extras-usage-test/pom.xml b/boms/extras/src/it/extras-usage-test/pom.xml index 6715a81cf..6b8bab1e2 100644 --- a/boms/extras/src/it/extras-usage-test/pom.xml +++ b/boms/extras/src/it/extras-usage-test/pom.xml @@ -88,6 +88,10 @@ org.a2aproject.sdk a2a-java-queue-manager-replication-mp-reactive + + org.a2aproject.sdk + a2a-java-extras-multitenancy + diff --git a/client/transport/grpc/src/main/java/org/a2aproject/sdk/client/transport/grpc/GrpcTransport.java b/client/transport/grpc/src/main/java/org/a2aproject/sdk/client/transport/grpc/GrpcTransport.java index 7eb805f00..00c86b660 100644 --- a/client/transport/grpc/src/main/java/org/a2aproject/sdk/client/transport/grpc/GrpcTransport.java +++ b/client/transport/grpc/src/main/java/org/a2aproject/sdk/client/transport/grpc/GrpcTransport.java @@ -48,6 +48,7 @@ import org.a2aproject.sdk.spec.TaskIdParams; import org.a2aproject.sdk.spec.TaskPushNotificationConfig; import org.a2aproject.sdk.spec.TaskQueryParams; +import org.a2aproject.sdk.spec.util.Utils; import io.grpc.Channel; import io.grpc.Metadata; import io.grpc.StatusException; @@ -84,6 +85,9 @@ public GrpcTransport(Channel channel, AgentCard agentCard, @Nullable String agen this.blockingStub = A2AServiceGrpc.newBlockingV2Stub(channel); this.agentCard = agentCard; this.interceptors = interceptors; + if (agentTenant != null && !agentTenant.isBlank()) { + Utils.validateTenant(agentTenant); + } this.agentTenant = agentTenant == null || agentTenant.isBlank() ? "" : agentTenant; } @@ -352,11 +356,9 @@ private MessageSendParams createRequestWithTenant(MessageSendParams request) { @Override public AgentCard getExtendedAgentCard(GetExtendedAgentCardParams params, @Nullable ClientCallContext context) throws A2AClientException { - GetExtendedAgentCardRequest.Builder builder = GetExtendedAgentCardRequest.newBuilder(); - if (params.tenant() != null) { - builder.setTenant(params.tenant()); - } - GetExtendedAgentCardRequest request = builder.build(); + GetExtendedAgentCardRequest request = GetExtendedAgentCardRequest.newBuilder() + .setTenant(resolveTenant(params.tenant())) + .build(); PayloadAndHeaders payloadAndHeaders = applyInterceptors(GET_EXTENDED_AGENT_CARD_METHOD, request, agentCard, context); try { diff --git a/client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/client/transport/jsonrpc/JSONRPCTransport.java b/client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/client/transport/jsonrpc/JSONRPCTransport.java index 61c7a4434..d92956714 100644 --- a/client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/client/transport/jsonrpc/JSONRPCTransport.java +++ b/client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/client/transport/jsonrpc/JSONRPCTransport.java @@ -98,7 +98,7 @@ public EventKind sendMessage(MessageSendParams request, @Nullable ClientCallCont agentCard, context); try { - String httpResponseBody = sendPostRequest(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, SEND_MESSAGE_METHOD); + String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, SEND_MESSAGE_METHOD); SendMessageResponse response = unmarshalResponse(httpResponseBody, SEND_MESSAGE_METHOD); return response.getResult(); } catch (A2AClientException e) { @@ -120,7 +120,7 @@ public void sendMessageStreaming(MessageSendParams request, Consumer sseEventListener.onMessage(event, ref.get()), throwable -> sseEventListener.onError(throwable, ref.get()), @@ -144,7 +144,7 @@ public Task getTask(TaskQueryParams request, @Nullable ClientCallContext context agentCard, context); try { - String httpResponseBody = sendPostRequest(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, GET_TASK_METHOD); + String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, GET_TASK_METHOD); GetTaskResponse response = unmarshalResponse(httpResponseBody, GET_TASK_METHOD); return response.getResult(); } catch (A2AClientException e) { @@ -161,7 +161,7 @@ public Task cancelTask(CancelTaskParams request, @Nullable ClientCallContext con agentCard, context); try { - String httpResponseBody = sendPostRequest(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, CANCEL_TASK_METHOD); + String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, CANCEL_TASK_METHOD); CancelTaskResponse response = unmarshalResponse(httpResponseBody, CANCEL_TASK_METHOD); return response.getResult(); } catch (A2AClientException e) { @@ -177,7 +177,7 @@ public ListTasksResult listTasks(ListTasksParams request, @Nullable ClientCallCo PayloadAndHeaders payloadAndHeaders = applyInterceptors(LIST_TASK_METHOD, ProtoUtils.ToProto.listTasksParams(request), agentCard, context); try { - String httpResponseBody = sendPostRequest(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, LIST_TASK_METHOD); + String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, LIST_TASK_METHOD); ListTasksResponse response = unmarshalResponse(httpResponseBody, LIST_TASK_METHOD); return response.getResult(); } catch (IOException | InterruptedException | JsonProcessingException e) { @@ -193,7 +193,7 @@ public TaskPushNotificationConfig createTaskPushNotificationConfiguration(TaskPu ProtoUtils.ToProto.createTaskPushNotificationConfigRequest(request), agentCard, context); try { - String httpResponseBody = sendPostRequest(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); CreateTaskPushNotificationConfigResponse response = unmarshalResponse(httpResponseBody, SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); return response.getResult(); } catch (A2AClientException e) { @@ -211,7 +211,7 @@ public TaskPushNotificationConfig getTaskPushNotificationConfiguration(GetTaskPu ProtoUtils.ToProto.getTaskPushNotificationConfigRequest(request), agentCard, context); try { - String httpResponseBody = sendPostRequest(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); GetTaskPushNotificationConfigResponse response = unmarshalResponse(httpResponseBody, GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); return response.getResult(); } catch (A2AClientException e) { @@ -230,7 +230,7 @@ public ListTaskPushNotificationConfigsResult listTaskPushNotificationConfigurati ProtoUtils.ToProto.listTaskPushNotificationConfigsRequest(request), agentCard, context); try { - String httpResponseBody = sendPostRequest(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); ListTaskPushNotificationConfigsResponse response = unmarshalResponse(httpResponseBody, LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); return response.getResult(); } catch (A2AClientException e) { @@ -248,7 +248,7 @@ public void deleteTaskPushNotificationConfigurations(DeleteTaskPushNotificationC ProtoUtils.ToProto.deleteTaskPushNotificationConfigRequest(request), agentCard, context); try { - String httpResponseBody = sendPostRequest(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); DeleteTaskPushNotificationConfigResponse response = unmarshalResponse(httpResponseBody, DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); // Response validated (no error), but no result to return } catch (A2AClientException e) { @@ -270,7 +270,7 @@ public void subscribeToTask(TaskIdParams request, Consumer e SSEEventListener sseEventListener = new SSEEventListener(eventConsumer, errorConsumer); try { - A2AHttpClient.PostBuilder builder = createPostBuilder(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, SUBSCRIBE_TO_TASK_METHOD); + A2AHttpClient.PostBuilder builder = createPostBuilder(agentInterface.url(), payloadAndHeaders, SUBSCRIBE_TO_TASK_METHOD); ref.set(builder.postAsyncSSE( event -> sseEventListener.onMessage(event, ref.get()), throwable -> sseEventListener.onError(throwable, ref.get()), @@ -294,7 +294,7 @@ public AgentCard getExtendedAgentCard(GetExtendedAgentCardParams params, @Nullab ProtoUtils.ToProto.extendedAgentCard(params), agentCard, context); try { - String httpResponseBody = sendPostRequest(Utils.buildBaseUrl(agentInterface, params.tenant()), payloadAndHeaders, GET_EXTENDED_AGENT_CARD_METHOD); + String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, GET_EXTENDED_AGENT_CARD_METHOD); GetExtendedAgentCardResponse response = unmarshalResponse(httpResponseBody, GET_EXTENDED_AGENT_CARD_METHOD); return response.getResult(); } catch (IOException | InterruptedException | JsonProcessingException e) { diff --git a/docs/content/dev/extra/multi-tenancy.md b/docs/content/dev/extra/multi-tenancy.md new file mode 100644 index 000000000..7c39cb91c --- /dev/null +++ b/docs/content/dev/extra/multi-tenancy.md @@ -0,0 +1,167 @@ +--- +title: Multi-Tenancy +description: CDI-based multitenancy support for A2A Java SDK servers — per-tenant AgentExecutor and AgentCard routing with the @Tenant qualifier. +layout: page +--- + +# Multi-Tenancy + +Lets a single A2A server serve multiple tenants with different agent behavior — each tenant gets its own `AgentExecutor` and `AgentCard`, while requests without a recognized tenant automatically fall back to the default beans. + +## Module + +| Artifact ID | Description | +|-------------|-------------| +| `a2a-java-extras-multitenancy` | `@Tenant` qualifier, `CdiAgentExecutorRouter`, `CdiAgentCardRouter` | + +### Add Dependency + +```xml + + org.a2aproject.sdk + a2a-java-extras-multitenancy + +``` + +> **Tip:** Use the [extras BOM](../boms) to manage the version without specifying it explicitly. + +The module activates automatically via CDI when present on the classpath — no additional server configuration is required. + +## Architecture + +
+flowchart TD
+    A["Incoming Request\ntenant field"] --> B["CdiAgentExecutorRouter"]
+    B -->|"@Tenant match found"| C["@Tenant AgentExecutor"]
+    B -->|"null / blank / unknown"| D["Default AgentExecutor"]
+    C --> E["RequestContext.getTenant()"]
+    D --> E
+
+    F["getExtendedAgentCard\npublic card URL"] --> G["CdiAgentCardRouter"]
+    G -->|"@Tenant match found"| H["@Tenant AgentCard"]
+    G -->|"null / blank / unknown"| I["Default AgentCard"]
+
+ +Two CDI routers are registered automatically: + +- **`CdiAgentExecutorRouter`** — looks up an `AgentExecutor` bean qualified with `@Tenant(value)` for each request. Falls back to the unqualified default executor when no match is found. +- **`CdiAgentCardRouter`** — resolves the appropriate `AgentCard` for `getExtendedAgentCard` and tenant-specific public card endpoints. + +## Declaring Per-Tenant Beans + +Use the `@Tenant` qualifier on CDI producer methods: + +### AgentExecutor + +```java +@ApplicationScoped +public class MyExecutors { + + @Produces + public AgentExecutor defaultExecutor() { + return new DefaultAgentExecutor(); + } + + @Produces + @Tenant("acme") + public AgentExecutor acmeExecutor() { + return new AcmeAgentExecutor(); + } + + @Produces + @Tenant("beta") + public AgentExecutor betaExecutor() { + return new BetaAgentExecutor(); + } +} +``` + +### AgentCard + +```java +@ApplicationScoped +public class MyAgentCards { + + // Default public card — used by /.well-known/agent-card.json + @Produces + @PublicAgentCard + public AgentCard publicCard() { + return AgentCard.builder().name("My Agent")...build(); + } + + // Default extended card — used when no tenant match + @Produces + @ExtendedAgentCard + public AgentCard defaultExtendedCard() { + return AgentCard.builder().name("My Agent (extended)")...build(); + } + + // Tenant-specific extended card + @Produces + @Tenant("acme") + @ExtendedAgentCard + public AgentCard acmeExtendedCard() { + return AgentCard.builder().name("Acme Agent")...build(); + } + + // Tenant-specific public card — no @PublicAgentCard qualifier (see note below) + @Produces + @Tenant("acme") + public AgentCard acmePublicCard() { + return AgentCard.builder().name("Acme Agent")...build(); + } +} +``` + +> **Note:** Tenant-specific public cards must **not** carry `@PublicAgentCard` — that qualifier is reserved for the single default public card. Adding it to a `@Tenant` bean causes CDI ambiguity. + +## Routing Rules + +### AgentExecutor routing + +| Request tenant | Result | +|----------------|--------| +| `null` or blank | Default (unqualified) executor | +| Known tenant (e.g. `"acme"`) | `@Tenant("acme")` executor | +| Unknown tenant | Default (unqualified) executor | + +### AgentCard routing + +| Request / URL | Card returned | +|---------------|---------------| +| `getExtendedAgentCard` with no tenant | Default `@ExtendedAgentCard` | +| `getExtendedAgentCard` with `tenant: "acme"` | `@Tenant("acme") @ExtendedAgentCard`, or default | +| `GET /.well-known/agent-card.json` | Default `@PublicAgentCard` | +| `GET /.well-known/acme/agent-card.json` | `@Tenant("acme")` card (no `@PublicAgentCard`), or default | + +## Accessing the Tenant in AgentExecutor + +The resolved tenant is available in `RequestContext`: + +```java +@Override +public void execute(RequestContext context, AgentEmitter emitter) throws A2AError { + String tenant = context.getTenant(); // null for the default tenant + // ... +} +``` + +## Tenant Source + +The tenant is read from the `tenant` field in the request payload (e.g. `MessageSendParams.tenant()`, `CancelTaskParams.tenant()`). For the REST transport the tenant can also come from the URL path (e.g. `/\{tenant}/extendedAgentCard`); the payload value takes precedence when both are present. + +Tenant identifiers are restricted to `a-zA-Z0-9_-.` characters — path segments containing `/` or `?` are rejected. + +## Limitations + +- **TaskStore and QueueManager are shared** across all tenants — tasks are keyed by UUID, not partitioned per tenant. +- **Per-tenant TaskAuthorizationProvider** is not yet supported — a single provider applies to all tenants. + +## Without the Module + +When `a2a-java-extras-multitenancy` is **not** on the classpath, the server behaves as a single-tenant deployment: the default `AgentExecutor` handles all requests, the default cards are returned, and the `tenant` field in request payloads is silently ignored. Existing single-tenant code requires no changes when the module is added. + +## See Also + +- [Multi-Tenancy concept page](../../multi-tenancy) — conceptual overview and setup guide +- [Extras BOM](../boms) — version management for all extras modules diff --git a/docs/content/dev/extras.md b/docs/content/dev/extras.md index 58723c33d..ff379350b 100644 --- a/docs/content/dev/extras.md +++ b/docs/content/dev/extras.md @@ -40,6 +40,10 @@ JPA-backed replacements for the in-memory stores, providing database persistence Replaces the default `InMemoryQueueManager` with event replication across multiple A2A server instances via message brokers. Required for multi-instance deployments. The core module (`a2a-java-queue-manager-replicated-core`) pairs with a MicroProfile Reactive Messaging strategy (`a2a-java-queue-manager-replication-mp-reactive`) supporting Apache Kafka, Pulsar, or AMQP. You can also write your own `ReplicationStrategy`. +## [Multi-Tenancy](../extra/multi-tenancy) + +Serve multiple tenants from a single A2A server with per-tenant `AgentExecutor` and `AgentCard` routing. The module (`a2a-java-extras-multitenancy`) provides a `@Tenant` CDI qualifier and automatic routing — requests are dispatched to tenant-specific beans based on the `tenant` field in the request payload, with fallback to the default beans for unknown tenants. + ## [OpenTelemetry](../extra/opentelemetry) Distributed tracing, metrics, and context propagation for A2A servers and clients using OpenTelemetry. The server module (`a2a-java-sdk-opentelemetry-server`) adds automatic span creation for all protocol methods with context propagation across async boundaries. Client modules (`a2a-java-sdk-opentelemetry-client`, `a2a-java-sdk-opentelemetry-client-propagation`) instrument A2A client operations. diff --git a/docs/content/dev/multi-tenancy.md b/docs/content/dev/multi-tenancy.md new file mode 100644 index 000000000..ac462b3ab --- /dev/null +++ b/docs/content/dev/multi-tenancy.md @@ -0,0 +1,13 @@ +--- +title: Multi-Tenancy +description: Serve multiple tenants from a single A2A server — per-tenant AgentExecutor and AgentCard routing with CDI qualifiers. +layout: page +--- + +# Multi-Tenancy + +Multi-tenancy lets a single A2A server provide different agent behavior per tenant. Each tenant can have its own `AgentExecutor` (business logic) and `AgentCard` (capabilities, skills, metadata). Requests without a recognized tenant automatically fall back to the default beans. + +This feature is provided by the `a2a-java-extras-multitenancy` extras module, which registers two CDI routers — `CdiAgentExecutorRouter` and `CdiAgentCardRouter` — that dispatch each request to the matching `@Tenant`-qualified bean. + +For setup instructions, configuration reference, and code examples, see the **[Multi-Tenancy extras page](../extra/multi-tenancy)**. diff --git a/docs/content/dev/server.md b/docs/content/dev/server.md index e93f2a1d7..e41f74093 100644 --- a/docs/content/dev/server.md +++ b/docs/content/dev/server.md @@ -223,6 +223,10 @@ public class CloseStreamsHook implements TaskStreamLifecycleHook { See the [`examples/stream-lifecycle`](https://github.com/a2aproject/a2a-java/tree/main/examples/stream-lifecycle) directory for a complete working example with server, client, and integration tests for all three transports. +## Multi-Tenancy (Optional) + +Serve multiple tenants from a single server with per-tenant `AgentExecutor` and `AgentCard` beans. See [Multi-Tenancy](multi-tenancy) for setup and configuration. + ## Observability (Optional) Add distributed tracing to your server with the [OpenTelemetry extras module](extra/opentelemetry). It decorates the request handler to create spans for every A2A protocol method, with automatic error tracking and optional request/response extraction. diff --git a/docs/data/versions/dev.yml b/docs/data/versions/dev.yml index 2f6cccebf..8e93f7630 100644 --- a/docs/data/versions/dev.yml +++ b/docs/data/versions/dev.yml @@ -22,6 +22,9 @@ menu: - title: "Compatibility" path: "/compatibility" icon: "fa-solid fa-code-branch" + - title: "Multi-Tenancy" + path: "/multi-tenancy" + icon: "fa-solid fa-building" - title: "Extras" path: "/extras" icon: "fa-solid fa-puzzle-piece" diff --git a/docs/src/main/resources/templates/partials/roq-default/head-scripts.html b/docs/src/main/resources/templates/partials/roq-default/head-scripts.html index 785d23f93..432feb092 100644 --- a/docs/src/main/resources/templates/partials/roq-default/head-scripts.html +++ b/docs/src/main/resources/templates/partials/roq-default/head-scripts.html @@ -7,6 +7,11 @@ {#bundle /} {#search-script /} + + {#if site.data.containsKey("analytics")} {#ga4 tag=site.data.getJsonObject('analytics').getString("ga4", "") /} {/if} diff --git a/extras/multitenancy/pom.xml b/extras/multitenancy/pom.xml new file mode 100644 index 000000000..77dcb8db6 --- /dev/null +++ b/extras/multitenancy/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + + org.a2aproject.sdk + a2a-java-sdk-parent + 1.2.1.Final-SNAPSHOT + ../../pom.xml + + + a2a-java-extras-multitenancy + A2A Java SDK :: Extras :: Multitenancy + CDI-based multitenancy support with @Tenant qualifier and tenant-aware routing + + + + org.a2aproject.sdk + a2a-java-sdk-server-common + + + jakarta.enterprise + jakarta.enterprise.cdi-api + provided + + + jakarta.inject + jakarta.inject-api + provided + + + org.junit.jupiter + junit-jupiter-api + test + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + ${project.groupId} + a2a-java-sdk-reference-jsonrpc + test + + + ${project.groupId} + a2a-java-sdk-reference-rest + test + + + ${project.groupId} + a2a-java-sdk-client-transport-jsonrpc + test + + + ${project.groupId} + a2a-java-sdk-client + test + + + diff --git a/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouter.java b/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouter.java new file mode 100644 index 000000000..14429fef4 --- /dev/null +++ b/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouter.java @@ -0,0 +1,79 @@ +package org.a2aproject.sdk.extras.multitenancy; + +import java.lang.annotation.Annotation; +import java.util.Set; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Any; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + +import org.a2aproject.sdk.server.ExtendedAgentCard; +import org.a2aproject.sdk.server.PublicAgentCard; +import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; +import org.a2aproject.sdk.server.util.CdiUtils; +import org.a2aproject.sdk.spec.AgentCard; +import org.jspecify.annotations.Nullable; + +/** + * CDI-based {@link AgentCardRouter} that resolves tenant-specific {@link AgentCard} beans. + *

+ * Extended cards are resolved via {@code @Tenant("x") @ExtendedAgentCard}-qualified beans. + * Tenant-specific public cards are resolved via {@code @Tenant("x")}-qualified beans that + * carry neither {@code @ExtendedAgentCard} nor {@code @PublicAgentCard}. The {@code @PublicAgentCard} + * qualifier must NOT be used on tenant-specific public cards because it would cause CDI + * ambiguity on injection points requesting the default public card. + *

+ * Falls back to the default (non-{@code @Tenant}) card when the tenant is {@code null}, + * blank, or does not match any registered tenant. + */ +@ApplicationScoped +public class CdiAgentCardRouter implements AgentCardRouter { + + @Inject + @Any + Instance allCards; + + private @Nullable AgentCard defaultExtendedCard; + private @Nullable AgentCard defaultPublicCard; + + @PostConstruct + void init() { + defaultExtendedCard = CdiUtils.resolveDefaultBean( + allCards, Tenant.class, ExtendedAgentCard.class, "@ExtendedAgentCard"); + defaultPublicCard = CdiUtils.resolveDefaultBean( + allCards, Tenant.class, PublicAgentCard.class, "@PublicAgentCard"); + } + + @Override + public @Nullable AgentCard resolveExtendedCard(@Nullable String tenant) { + if (tenant == null || tenant.isBlank()) { + return defaultExtendedCard; + } + Instance selected = allCards.select( + new Tenant.Literal(tenant), ExtendedAgentCard.Literal.INSTANCE); + if (selected.isResolvable()) { + return selected.get(); + } + return defaultExtendedCard; + } + + @Override + public @Nullable AgentCard resolvePublicCard(@Nullable String tenant) { + if (tenant == null || tenant.isBlank()) { + return defaultPublicCard; + } + for (Instance.Handle handle : allCards.handles()) { + Set qualifiers = handle.getBean().getQualifiers(); + boolean matchesTenant = qualifiers.stream() + .anyMatch(a -> a instanceof Tenant t && tenant.equals(t.value())); + if (matchesTenant + && qualifiers.stream().noneMatch(ExtendedAgentCard.class::isInstance) + && qualifiers.stream().noneMatch(PublicAgentCard.class::isInstance)) { + return handle.get(); + } + } + return defaultPublicCard; + } +} diff --git a/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouter.java b/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouter.java new file mode 100644 index 000000000..cfbc21818 --- /dev/null +++ b/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouter.java @@ -0,0 +1,47 @@ +package org.a2aproject.sdk.extras.multitenancy; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Any; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.multitenancy.AgentExecutorRouter; +import org.a2aproject.sdk.server.util.CdiUtils; +import org.jspecify.annotations.Nullable; + +/** + * CDI-based {@link AgentExecutorRouter} that resolves tenant-specific {@link AgentExecutor} beans + * using the {@link Tenant} qualifier. + *

+ * Falls back to the default (unqualified) executor when the tenant is {@code null}, blank, + * or does not match any {@code @Tenant}-qualified bean. + */ +@ApplicationScoped +public class CdiAgentExecutorRouter implements AgentExecutorRouter { + + @Inject + @Any + Instance allExecutors; + + private @Nullable AgentExecutor defaultExecutor; + + @PostConstruct + void init() { + defaultExecutor = CdiUtils.resolveDefaultBean( + allExecutors, Tenant.class, null, "AgentExecutor"); + } + + @Override + public AgentExecutor resolve(@Nullable String tenant) { + if (tenant == null || tenant.isBlank()) { + return defaultExecutor; + } + Instance selected = allExecutors.select(new Tenant.Literal(tenant)); + if (selected.isResolvable()) { + return selected.get(); + } + return defaultExecutor; + } +} diff --git a/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/Tenant.java b/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/Tenant.java new file mode 100644 index 000000000..f79182bfc --- /dev/null +++ b/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/Tenant.java @@ -0,0 +1,63 @@ +package org.a2aproject.sdk.extras.multitenancy; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import jakarta.enterprise.util.AnnotationLiteral; +import jakarta.inject.Qualifier; + +/** + * CDI qualifier for tenant-specific beans. + *

+ * Use this qualifier on {@link org.a2aproject.sdk.server.agentexecution.AgentExecutor} + * and {@link org.a2aproject.sdk.spec.AgentCard} producer methods to declare per-tenant + * implementations: + *

{@code
+ * @Produces @Tenant("acme")
+ * AgentExecutor acmeExecutor() { return new AcmeAgentExecutor(); }
+ *
+ * @Produces @Tenant("acme") @ExtendedAgentCard
+ * AgentCard acmeExtendedCard() { return AgentCard.builder()...build(); }
+ * }
+ *

+ * The {@link #value()} is a binding member — CDI {@code Instance.select()} matches by value. + */ +@Qualifier +@Retention(RUNTIME) +@Target({FIELD, TYPE, METHOD, PARAMETER}) +public @interface Tenant { + + /** + * The tenant identifier. + * + * @return the tenant identifier + */ + String value(); + + /** + * {@link AnnotationLiteral} for programmatic CDI lookups. + */ + final class Literal extends AnnotationLiteral implements Tenant { + private final String value; + + /** + * Creates a new Literal for the given tenant identifier. + * + * @param value the tenant identifier + */ + public Literal(String value) { + this.value = value; + } + + @Override + public String value() { + return value; + } + } +} diff --git a/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantAgentCardProducer.java b/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantAgentCardProducer.java new file mode 100644 index 000000000..a521082e7 --- /dev/null +++ b/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantAgentCardProducer.java @@ -0,0 +1,138 @@ +package org.a2aproject.sdk.extras.multitenancy.it; + +import java.util.List; + +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Singleton; + +import org.a2aproject.sdk.extras.multitenancy.Tenant; +import org.a2aproject.sdk.server.ExtendedAgentCard; +import org.a2aproject.sdk.server.PublicAgentCard; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.TransportProtocol; + +@Singleton +public class MultiTenantAgentCardProducer { + + private static final String BASE_URL = "http://localhost:8081"; + + @Produces + @PublicAgentCard + public AgentCard publicCard() { + return AgentCard.builder() + .name("Multi-Tenant Test Agent") + .description("Test agent for multitenancy integration tests") + .version("1.0.0") + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .capabilities(AgentCapabilities.builder() + .streaming(true) + .extendedAgentCard(true) + .build()) + .skills(List.of()) + .supportedInterfaces(List.of( + new AgentInterface(TransportProtocol.JSONRPC.asString(), BASE_URL), + new AgentInterface(TransportProtocol.HTTP_JSON.asString(), BASE_URL))) + .build(); + } + + @Produces + @Singleton + @ExtendedAgentCard + public AgentCard defaultExtendedCard() { + return AgentCard.builder() + .name("default-extended") + .description("Default extended card") + .version("1.0.0") + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .capabilities(AgentCapabilities.builder().build()) + .skills(List.of()) + .supportedInterfaces(List.of( + new AgentInterface(TransportProtocol.JSONRPC.asString(), BASE_URL), + new AgentInterface(TransportProtocol.HTTP_JSON.asString(), BASE_URL))) + .build(); + } + + @Produces + @Singleton + @Tenant("acme") + @ExtendedAgentCard + public AgentCard acmeExtendedCard() { + return AgentCard.builder() + .name("acme-extended") + .description("Acme extended card") + .version("1.0.0") + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .capabilities(AgentCapabilities.builder().build()) + .skills(List.of()) + .supportedInterfaces(List.of( + new AgentInterface(TransportProtocol.JSONRPC.asString(), BASE_URL), + new AgentInterface(TransportProtocol.HTTP_JSON.asString(), BASE_URL + "/acme"))) + .build(); + } + + @Produces + @Singleton + @Tenant("acme") + public AgentCard acmePublicCard() { + return AgentCard.builder() + .name("Acme Agent") + .description("Acme-specific public agent card") + .version("1.0.0") + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .capabilities(AgentCapabilities.builder() + .streaming(true) + .extendedAgentCard(true) + .build()) + .skills(List.of()) + .supportedInterfaces(List.of( + new AgentInterface(TransportProtocol.JSONRPC.asString(), BASE_URL), + new AgentInterface(TransportProtocol.HTTP_JSON.asString(), BASE_URL + "/acme"))) + .build(); + } + + @Produces + @Singleton + @Tenant("beta") + @ExtendedAgentCard + public AgentCard betaExtendedCard() { + return AgentCard.builder() + .name("beta-extended") + .description("Beta extended card") + .version("1.0.0") + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .capabilities(AgentCapabilities.builder().build()) + .skills(List.of()) + .supportedInterfaces(List.of( + new AgentInterface(TransportProtocol.JSONRPC.asString(), BASE_URL), + new AgentInterface(TransportProtocol.HTTP_JSON.asString(), BASE_URL + "/beta"))) + .build(); + } + + @Produces + @Singleton + @Tenant("beta") + public AgentCard betaPublicCard() { + return AgentCard.builder() + .name("Beta Agent") + .description("Beta-specific public agent card") + .version("1.0.0") + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .capabilities(AgentCapabilities.builder() + .streaming(true) + .extendedAgentCard(true) + .build()) + .skills(List.of()) + .supportedInterfaces(List.of( + new AgentInterface(TransportProtocol.JSONRPC.asString(), BASE_URL), + new AgentInterface(TransportProtocol.HTTP_JSON.asString(), BASE_URL + "/beta"))) + .build(); + } +} diff --git a/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantAgentExecutorProducer.java b/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantAgentExecutorProducer.java new file mode 100644 index 000000000..80c68d1cb --- /dev/null +++ b/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantAgentExecutorProducer.java @@ -0,0 +1,54 @@ +package org.a2aproject.sdk.extras.multitenancy.it; + +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; + +import org.a2aproject.sdk.extras.multitenancy.Tenant; +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.TextPart; + +@ApplicationScoped +public class MultiTenantAgentExecutorProducer { + + @Produces + public AgentExecutor defaultExecutor() { + return new TenantEchoExecutor("default"); + } + + @Produces + @Tenant("acme") + public AgentExecutor acmeExecutor() { + return new TenantEchoExecutor("acme"); + } + + @Produces + @Tenant("beta") + public AgentExecutor betaExecutor() { + return new TenantEchoExecutor("beta"); + } + + static class TenantEchoExecutor implements AgentExecutor { + private final String label; + + TenantEchoExecutor(String label) { + this.label = label; + } + + @Override + public void execute(RequestContext context, AgentEmitter emitter) throws A2AError { + emitter.startWork(); + emitter.addArtifact(List.of(new TextPart(label))); + emitter.complete(); + } + + @Override + public void cancel(RequestContext context, AgentEmitter emitter) throws A2AError { + emitter.cancel(); + } + } +} diff --git a/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantJSONRPCTest.java b/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantJSONRPCTest.java new file mode 100644 index 000000000..54977067a --- /dev/null +++ b/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantJSONRPCTest.java @@ -0,0 +1,230 @@ +package org.a2aproject.sdk.extras.multitenancy.it; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import io.restassured.path.json.JsonPath; +import io.restassured.response.Response; +import org.a2aproject.sdk.common.A2AHeaders; +import org.a2aproject.sdk.spec.AgentInterface; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +@QuarkusTest +public class MultiTenantJSONRPCTest { + + @Test + public void knownTenantRoutesToAcmeExecutor() { + String response = rawPost(buildSendMessageRequest("acme")); + assertArtifactText(response, "acme"); + } + + @Test + public void secondTenantRoutesToBetaExecutor() { + String response = rawPost(buildSendMessageRequest("beta")); + assertArtifactText(response, "beta"); + } + + @Test + public void unknownTenantFallsBackToDefault() { + String response = rawPost(buildSendMessageRequest("unknown-corp")); + assertArtifactText(response, "default"); + } + + @Test + public void nullTenantUsesDefault() { + String response = rawPost(buildSendMessageRequest(null)); + assertArtifactText(response, "default"); + } + + @Test + public void getExtendedAgentCardWithAcmeTenant() { + String response = rawPost(buildGetExtendedAgentCardRequest("acme")); + JsonPath json = JsonPath.from(response); + assertEquals("acme-extended", json.getString("result.name")); + } + + @Test + public void getExtendedAgentCardWithBetaTenant() { + String response = rawPost(buildGetExtendedAgentCardRequest("beta")); + JsonPath json = JsonPath.from(response); + assertEquals("beta-extended", json.getString("result.name")); + } + + @Test + public void getExtendedAgentCardWithUnknownTenant() { + String response = rawPost(buildGetExtendedAgentCardRequest("unknown")); + JsonPath json = JsonPath.from(response); + assertEquals("default-extended", json.getString("result.name")); + } + + @Test + public void getExtendedAgentCardWithoutTenant() { + String response = rawPost(buildGetExtendedAgentCardRequest(null)); + JsonPath json = JsonPath.from(response); + assertEquals("default-extended", json.getString("result.name")); + } + + @Test + public void streamingWithKnownTenant() { + String msgId = UUID.randomUUID().toString(); + Response response = RestAssured.given() + .header(A2AHeaders.A2A_VERSION, AgentInterface.CURRENT_PROTOCOL_VERSION) + .contentType("application/json") + .body(buildStreamingMessageRequest("acme", msgId)) + .when().post("/"); + + assertEquals(200, response.getStatusCode()); + String body = response.getBody().asString(); + assertTrue(body.contains("\"text\":\"acme\""), "Stream should contain acme artifact: " + body); + } + + @Test + public void streamingWithUnknownTenantUsesDefault() { + String msgId = UUID.randomUUID().toString(); + Response response = RestAssured.given() + .header(A2AHeaders.A2A_VERSION, AgentInterface.CURRENT_PROTOCOL_VERSION) + .contentType("application/json") + .body(buildStreamingMessageRequest("unknown", msgId)) + .when().post("/"); + + assertEquals(200, response.getStatusCode()); + String body = response.getBody().asString(); + assertTrue(body.contains("\"text\":\"default\""), "Stream should contain default artifact: " + body); + } + + @Test + public void getPublicAgentCardWithAcmeTenant() { + String response = RestAssured.given() + .when().get("/.well-known/acme/agent-card.json") + .then().statusCode(200) + .extract().asString(); + JsonPath json = JsonPath.from(response); + assertEquals("Acme Agent", json.getString("name")); + } + + @Test + public void getPublicAgentCardWithBetaTenant() { + String response = RestAssured.given() + .when().get("/.well-known/beta/agent-card.json") + .then().statusCode(200) + .extract().asString(); + JsonPath json = JsonPath.from(response); + assertEquals("Beta Agent", json.getString("name")); + } + + @Test + public void getPublicAgentCardWithUnknownTenantFallsBackToDefault() { + String response = RestAssured.given() + .when().get("/.well-known/unknown/agent-card.json") + .then().statusCode(200) + .extract().asString(); + JsonPath json = JsonPath.from(response); + assertEquals("Multi-Tenant Test Agent", json.getString("name")); + } + + @Test + public void getPublicAgentCardWithoutTenantReturnsDefault() { + String response = RestAssured.given() + .when().get("/.well-known/agent-card.json") + .then().statusCode(200) + .extract().asString(); + JsonPath json = JsonPath.from(response); + assertEquals("Multi-Tenant Test Agent", json.getString("name")); + } + + @Test + public void concurrentRequestsForDifferentTenants() throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(3); + try { + CompletableFuture acmeFuture = CompletableFuture.supplyAsync( + () -> rawPost(buildSendMessageRequest("acme", UUID.randomUUID().toString())), pool); + CompletableFuture betaFuture = CompletableFuture.supplyAsync( + () -> rawPost(buildSendMessageRequest("beta", UUID.randomUUID().toString())), pool); + CompletableFuture defaultFuture = CompletableFuture.supplyAsync( + () -> rawPost(buildSendMessageRequest(null, UUID.randomUUID().toString())), pool); + + assertArtifactText(acmeFuture.get(), "acme"); + assertArtifactText(betaFuture.get(), "beta"); + assertArtifactText(defaultFuture.get(), "default"); + } finally { + pool.shutdown(); + } + } + + private String rawPost(String body) { + return RestAssured.given() + .header(A2AHeaders.A2A_VERSION, AgentInterface.CURRENT_PROTOCOL_VERSION) + .contentType("application/json") + .body(body) + .when().post("/") + .then().statusCode(200) + .extract().asString(); + } + + private void assertArtifactText(String response, String expected) { + JsonPath json = JsonPath.from(response); + assertNotNull(json.getString("result"), "Expected result in response: " + response); + assertEquals(expected, json.getString("result.task.artifacts[0].parts[0].text")); + } + + private static String buildSendMessageRequest(@Nullable String tenant) { + return buildSendMessageRequest(tenant, "msg-1"); + } + + private static String buildSendMessageRequest(@Nullable String tenant, String messageId) { + return """ + { + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "%s", + "role": "ROLE_USER", + "parts": [{"text": "hello"}] + }%s + } + }""".formatted(messageId, buildTenantField(tenant)); + } + + private static String buildStreamingMessageRequest(@Nullable String tenant, String messageId) { + return """ + { + "jsonrpc": "2.0", + "id": "1", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "%s", + "role": "ROLE_USER", + "parts": [{"text": "hello"}] + }%s + } + }""".formatted(messageId, buildTenantField(tenant)); + } + + private static String buildTenantField(@Nullable String tenant) { + return tenant != null && !tenant.isBlank() + ? """ + , "tenant": "%s\"""".formatted(tenant) + : ""; + } + + private static String buildGetExtendedAgentCardRequest(@Nullable String tenant) { + String params = tenant != null + ? """ + , "params": { "tenant": "%s" }""".formatted(tenant) + : ""; + return """ + { "jsonrpc": "2.0", "method": "GetExtendedAgentCard", "id": "1"%s }""".formatted(params); + } +} diff --git a/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantRESTTest.java b/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantRESTTest.java new file mode 100644 index 000000000..bc0612028 --- /dev/null +++ b/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantRESTTest.java @@ -0,0 +1,206 @@ +package org.a2aproject.sdk.extras.multitenancy.it; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import io.restassured.path.json.JsonPath; +import io.restassured.response.Response; +import org.a2aproject.sdk.spec.AgentInterface; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +@QuarkusTest +public class MultiTenantRESTTest { + + @Test + public void knownTenantRoutesToAcmeExecutor() { + String response = postMessageSend("acme"); + assertArtifactText(response, "acme"); + } + + @Test + public void secondTenantRoutesToBetaExecutor() { + String response = postMessageSend("beta"); + assertArtifactText(response, "beta"); + } + + @Test + public void unknownTenantFallsBackToDefault() { + String response = postMessageSend("unknown-corp"); + assertArtifactText(response, "default"); + } + + @Test + public void noTenantUsesDefault() { + String response = postMessageSend(null); + assertArtifactText(response, "default"); + } + + @Test + public void getExtendedAgentCardWithAcmeTenant() { + String response = RestAssured.given() + .header("A2A-Version", AgentInterface.CURRENT_PROTOCOL_VERSION) + .when().get("/acme/extendedAgentCard") + .then().statusCode(200) + .extract().asString(); + JsonPath json = JsonPath.from(response); + assertEquals("acme-extended", json.getString("name")); + } + + @Test + public void getExtendedAgentCardWithBetaTenant() { + String response = RestAssured.given() + .header("A2A-Version", AgentInterface.CURRENT_PROTOCOL_VERSION) + .when().get("/beta/extendedAgentCard") + .then().statusCode(200) + .extract().asString(); + JsonPath json = JsonPath.from(response); + assertEquals("beta-extended", json.getString("name")); + } + + @Test + public void getExtendedAgentCardWithUnknownTenant() { + String response = RestAssured.given() + .header("A2A-Version", AgentInterface.CURRENT_PROTOCOL_VERSION) + .when().get("/unknown/extendedAgentCard") + .then().statusCode(200) + .extract().asString(); + JsonPath json = JsonPath.from(response); + assertEquals("default-extended", json.getString("name")); + } + + @Test + public void getExtendedAgentCardWithoutTenant() { + String response = RestAssured.given() + .header("A2A-Version", AgentInterface.CURRENT_PROTOCOL_VERSION) + .when().get("/extendedAgentCard") + .then().statusCode(200) + .extract().asString(); + JsonPath json = JsonPath.from(response); + assertEquals("default-extended", json.getString("name")); + } + + @Test + public void streamingWithKnownTenant() { + Response response = RestAssured.given() + .urlEncodingEnabled(false) + .header("A2A-Version", AgentInterface.CURRENT_PROTOCOL_VERSION) + .contentType("application/json") + .body(buildMessageBody()) + .when().post("/acme/message:stream"); + + assertEquals(200, response.getStatusCode()); + String body = response.getBody().asString(); + assertTrue(body.contains("\"text\":\"acme\""), "Stream should contain acme artifact: " + body); + } + + @Test + public void streamingWithUnknownTenantUsesDefault() { + Response response = RestAssured.given() + .urlEncodingEnabled(false) + .header("A2A-Version", AgentInterface.CURRENT_PROTOCOL_VERSION) + .contentType("application/json") + .body(buildMessageBody()) + .when().post("/unknown/message:stream"); + + assertEquals(200, response.getStatusCode()); + String body = response.getBody().asString(); + assertTrue(body.contains("\"text\":\"default\""), "Stream should contain default artifact: " + body); + } + + @Test + public void getPublicAgentCardWithAcmeTenant() { + String response = RestAssured.given() + .when().get("/.well-known/acme/agent-card.json") + .then().statusCode(200) + .extract().asString(); + JsonPath json = JsonPath.from(response); + assertEquals("Acme Agent", json.getString("name")); + } + + @Test + public void getPublicAgentCardWithBetaTenant() { + String response = RestAssured.given() + .when().get("/.well-known/beta/agent-card.json") + .then().statusCode(200) + .extract().asString(); + JsonPath json = JsonPath.from(response); + assertEquals("Beta Agent", json.getString("name")); + } + + @Test + public void getPublicAgentCardWithUnknownTenantFallsBackToDefault() { + String response = RestAssured.given() + .when().get("/.well-known/unknown/agent-card.json") + .then().statusCode(200) + .extract().asString(); + JsonPath json = JsonPath.from(response); + assertEquals("Multi-Tenant Test Agent", json.getString("name")); + } + + @Test + public void getPublicAgentCardWithoutTenantReturnsDefault() { + String response = RestAssured.given() + .when().get("/.well-known/agent-card.json") + .then().statusCode(200) + .extract().asString(); + JsonPath json = JsonPath.from(response); + assertEquals("Multi-Tenant Test Agent", json.getString("name")); + } + + @Test + public void concurrentRequestsForDifferentTenants() throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(3); + try { + CompletableFuture acmeFuture = CompletableFuture.supplyAsync( + () -> postMessageSend("acme"), pool); + CompletableFuture betaFuture = CompletableFuture.supplyAsync( + () -> postMessageSend("beta"), pool); + CompletableFuture defaultFuture = CompletableFuture.supplyAsync( + () -> postMessageSend(null), pool); + + assertArtifactText(acmeFuture.get(), "acme"); + assertArtifactText(betaFuture.get(), "beta"); + assertArtifactText(defaultFuture.get(), "default"); + } finally { + pool.shutdown(); + } + } + + private String postMessageSend(@Nullable String tenant) { + String path = tenant != null ? "/" + tenant + "/message:send" : "/message:send"; + return RestAssured.given() + .urlEncodingEnabled(false) + .header("A2A-Version", AgentInterface.CURRENT_PROTOCOL_VERSION) + .contentType("application/json") + .body(buildMessageBody()) + .when().post(path) + .then().statusCode(200) + .extract().asString(); + } + + private void assertArtifactText(String response, String expected) { + JsonPath json = JsonPath.from(response); + assertNotNull(json.getString("task"), "Expected task in response: " + response); + assertEquals(expected, json.getString("task.artifacts[0].parts[0].text")); + } + + private static String buildMessageBody() { + return """ + { + "message": { + "messageId": "%s", + "role": "ROLE_USER", + "parts": [{"text": "hello"}] + } + }""".formatted(UUID.randomUUID().toString()); + } +} diff --git a/extras/task-store-database-jpa/src/main/java/org/a2aproject/sdk/extras/taskstore/database/jpa/JpaDatabaseTaskStore.java b/extras/task-store-database-jpa/src/main/java/org/a2aproject/sdk/extras/taskstore/database/jpa/JpaDatabaseTaskStore.java index 4019f287d..f685e1acb 100644 --- a/extras/task-store-database-jpa/src/main/java/org/a2aproject/sdk/extras/taskstore/database/jpa/JpaDatabaseTaskStore.java +++ b/extras/task-store-database-jpa/src/main/java/org/a2aproject/sdk/extras/taskstore/database/jpa/JpaDatabaseTaskStore.java @@ -24,6 +24,7 @@ import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult; import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.auth.TaskAuthorizationProvider; +import org.a2aproject.sdk.server.util.CdiUtils; import org.a2aproject.sdk.server.config.A2AConfigProvider; import org.a2aproject.sdk.server.tasks.TaskStateProvider; import org.a2aproject.sdk.server.tasks.TaskStore; @@ -64,9 +65,7 @@ public JpaDatabaseTaskStore() { @Inject public JpaDatabaseTaskStore(@Any Instance authorizationProviderInstance) { - this.authorizationProvider = authorizationProviderInstance.isResolvable() - ? authorizationProviderInstance.get() - : null; + this.authorizationProvider = CdiUtils.getIfResolvable(authorizationProviderInstance); } /** diff --git a/jsonrpc-common/src/main/java/org/a2aproject/sdk/jsonrpc/common/wrappers/GetExtendedAgentCardRequest.java b/jsonrpc-common/src/main/java/org/a2aproject/sdk/jsonrpc/common/wrappers/GetExtendedAgentCardRequest.java index 5bfc6cfa2..7824bac47 100644 --- a/jsonrpc-common/src/main/java/org/a2aproject/sdk/jsonrpc/common/wrappers/GetExtendedAgentCardRequest.java +++ b/jsonrpc-common/src/main/java/org/a2aproject/sdk/jsonrpc/common/wrappers/GetExtendedAgentCardRequest.java @@ -7,6 +7,8 @@ import org.a2aproject.sdk.spec.AgentCapabilities; import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.ExtendedAgentCardNotConfiguredError; +import org.a2aproject.sdk.spec.GetExtendedAgentCardParams; +import org.jspecify.annotations.Nullable; /** * JSON-RPC request to retrieve an agent's extended card with authenticated details. @@ -32,20 +34,21 @@ * @see ExtendedAgentCardNotConfiguredError for the error when unsupported * @see A2A Protocol Specification */ -public final class GetExtendedAgentCardRequest extends NonStreamingJSONRPCRequest { +public final class GetExtendedAgentCardRequest extends NonStreamingJSONRPCRequest { private GetExtendedAgentCardRequest() { } - /** - * Constructs request with full parameters. + * Constructs request with optional parameters. * * @param jsonrpc the JSON-RPC version * @param id the request ID + * @param params the request parameters, may be {@code null} */ - public GetExtendedAgentCardRequest(String jsonrpc, Object id) { - super(jsonrpc, GET_EXTENDED_AGENT_CARD_METHOD, id); + public GetExtendedAgentCardRequest(String jsonrpc, Object id, @Nullable GetExtendedAgentCardParams params) { + // params are optional for getExtendedAgentCard (tenant is optional) + validateAndSetJsonParameters(jsonrpc, GET_EXTENDED_AGENT_CARD_METHOD, id, params, false); } /** @@ -54,7 +57,7 @@ public GetExtendedAgentCardRequest(String jsonrpc, Object id) { * @param id the request ID */ public GetExtendedAgentCardRequest(String id) { - this(null, id); + this(null, id, null); } /** @@ -72,6 +75,7 @@ public static Builder builder() { public static class Builder { private String jsonrpc; private Object id; + private @Nullable GetExtendedAgentCardParams params; /** * Creates a new Builder with all fields unset. @@ -101,6 +105,17 @@ public GetExtendedAgentCardRequest.Builder id(Object id) { return this; } + /** + * Sets the request parameters. + * + * @param params the request parameters + * @return this builder for method chaining + */ + public GetExtendedAgentCardRequest.Builder params(@Nullable GetExtendedAgentCardParams params) { + this.params = params; + return this; + } + /** * Builds the instance. * @@ -110,7 +125,7 @@ public GetExtendedAgentCardRequest build() { if (id == null) { id = UUID.randomUUID().toString(); } - return new GetExtendedAgentCardRequest(jsonrpc, id); + return new GetExtendedAgentCardRequest(jsonrpc, id, params); } } } diff --git a/jsonrpc-common/src/test/java/org/a2aproject/sdk/jsonrpc/common/wrappers/GetExtendedAgentCardRequestTest.java b/jsonrpc-common/src/test/java/org/a2aproject/sdk/jsonrpc/common/wrappers/GetExtendedAgentCardRequestTest.java new file mode 100644 index 000000000..fe83b5742 --- /dev/null +++ b/jsonrpc-common/src/test/java/org/a2aproject/sdk/jsonrpc/common/wrappers/GetExtendedAgentCardRequestTest.java @@ -0,0 +1,52 @@ +package org.a2aproject.sdk.jsonrpc.common.wrappers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.a2aproject.sdk.spec.A2AMethods; +import org.a2aproject.sdk.spec.GetExtendedAgentCardParams; +import org.junit.jupiter.api.Test; + +class GetExtendedAgentCardRequestTest { + + @Test + void constructorWithNullParams() { + GetExtendedAgentCardRequest request = new GetExtendedAgentCardRequest("2.0", "1", null); + + assertEquals("1", request.getId()); + assertEquals(A2AMethods.GET_EXTENDED_AGENT_CARD_METHOD, request.getMethod()); + assertNull(request.getParams()); + } + + @Test + void convenienceConstructor() { + GetExtendedAgentCardRequest request = new GetExtendedAgentCardRequest("1"); + + assertEquals("1", request.getId()); + assertEquals(A2AMethods.GET_EXTENDED_AGENT_CARD_METHOD, request.getMethod()); + assertNull(request.getParams()); + } + + @Test + void builderWithParams() { + GetExtendedAgentCardParams params = new GetExtendedAgentCardParams("acme"); + GetExtendedAgentCardRequest request = GetExtendedAgentCardRequest.builder() + .id("1") + .params(params) + .build(); + + assertEquals("1", request.getId()); + assertNotNull(request.getParams()); + assertEquals("acme", request.getParams().tenant()); + } + + @Test + void builderAutoGeneratesId() { + GetExtendedAgentCardRequest request = GetExtendedAgentCardRequest.builder().build(); + + assertNotNull(request.getId()); + assertEquals(A2AMethods.GET_EXTENDED_AGENT_CARD_METHOD, request.getMethod()); + } + +} diff --git a/pom.xml b/pom.xml index aeca2d94c..03fea1061 100644 --- a/pom.xml +++ b/pom.xml @@ -168,6 +168,11 @@ a2a-java-extras-common ${project.version} + + ${project.groupId} + a2a-java-extras-multitenancy + ${project.version} + ${project.groupId} a2a-java-sdk-transport-grpc @@ -592,6 +597,7 @@ extras/http-client-vertx extras/http-client-android extras/http-client-cdi + extras/multitenancy http-client jsonrpc-common integrations/microprofile-config @@ -623,6 +629,9 @@ reference/multiversion-jsonrpc reference/multiversion-rest + + tests/multitenancy + tests/multiversion/jsonrpc tests/multiversion/rest diff --git a/reference/grpc/src/main/java/org/a2aproject/sdk/server/grpc/quarkus/QuarkusGrpcHandler.java b/reference/grpc/src/main/java/org/a2aproject/sdk/server/grpc/quarkus/QuarkusGrpcHandler.java index b7ef70432..1f0973ab8 100644 --- a/reference/grpc/src/main/java/org/a2aproject/sdk/server/grpc/quarkus/QuarkusGrpcHandler.java +++ b/reference/grpc/src/main/java/org/a2aproject/sdk/server/grpc/quarkus/QuarkusGrpcHandler.java @@ -2,11 +2,14 @@ import java.util.concurrent.Executor; +import jakarta.enterprise.inject.Any; import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; import org.a2aproject.sdk.server.ExtendedAgentCard; import org.a2aproject.sdk.server.PublicAgentCard; +import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; +import org.a2aproject.sdk.server.util.CdiUtils; import org.a2aproject.sdk.server.requesthandlers.RequestHandler; import org.a2aproject.sdk.server.util.async.Internal; import org.a2aproject.sdk.spec.AgentCard; @@ -80,6 +83,8 @@ public class QuarkusGrpcHandler extends GrpcHandler { private final Instance callContextFactoryInstance; private final Executor executor; + private @Nullable AgentCardRouter agentCardRouter; + /** * Constructs a new QuarkusGrpcHandler with CDI-injected dependencies. * @@ -97,6 +102,7 @@ public class QuarkusGrpcHandler extends GrpcHandler { *

    *
  • {@code extendedAgentCard} - Extended agent card (can be unresolvable)
  • *
  • {@code callContextFactoryInstance} - Custom context factory (can be unsatisfied)
  • + *
  • {@code agentCardRouterInstance} - Agent card router for multitenancy (can be unsatisfied)
  • *
* * @param agentCard the public agent card (qualified with {@code @PublicAgentCard}) @@ -104,18 +110,21 @@ public class QuarkusGrpcHandler extends GrpcHandler { * @param requestHandler the request handler for protocol operations * @param callContextFactoryInstance the call context factory instance (optional) * @param executor the executor for async operations (qualified with {@code @Internal}) + * @param agentCardRouterInstance optional agent card router instance for multitenancy */ @Inject public QuarkusGrpcHandler(@PublicAgentCard Instance agentCard, @ExtendedAgentCard Instance extendedAgentCard, RequestHandler requestHandler, Instance callContextFactoryInstance, - @Internal Executor executor) { + @Internal Executor executor, + @Any @Nullable Instance agentCardRouterInstance) { this.agentCard = agentCard; this.extendedAgentCard = extendedAgentCard; this.requestHandler = requestHandler; this.callContextFactoryInstance = callContextFactoryInstance; this.executor = executor; + this.agentCardRouter = CdiUtils.getIfResolvable(agentCardRouterInstance); } @Override @@ -145,4 +154,9 @@ protected CallContextFactory getCallContextFactory() { protected Executor getExecutor() { return executor; } + + @Override + protected @Nullable AgentCardRouter getAgentCardRouter() { + return agentCardRouter; + } } diff --git a/reference/grpc/src/test/java/org/a2aproject/sdk/server/grpc/quarkus/QuarkusGrpcHandlerLazyResolutionTest.java b/reference/grpc/src/test/java/org/a2aproject/sdk/server/grpc/quarkus/QuarkusGrpcHandlerLazyResolutionTest.java index 767cd97d4..104f6a843 100644 --- a/reference/grpc/src/test/java/org/a2aproject/sdk/server/grpc/quarkus/QuarkusGrpcHandlerLazyResolutionTest.java +++ b/reference/grpc/src/test/java/org/a2aproject/sdk/server/grpc/quarkus/QuarkusGrpcHandlerLazyResolutionTest.java @@ -36,7 +36,8 @@ void constructorDoesNotResolveAgentCardInstances() { throwOnGet, null, FixedInstance.empty(), - Runnable::run)); + Runnable::run, + null)); } @Test @@ -63,7 +64,8 @@ public AgentCard get() { trackingInstance, null, FixedInstance.empty(), - Runnable::run); + Runnable::run, + null); assertFalse(resolved.get(), "should not resolve during construction"); AgentCard result = handler.getExtendedAgentCard(); diff --git a/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java b/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java index f0349d03f..4ec4905ad 100644 --- a/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java +++ b/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java @@ -237,6 +237,21 @@ void setupRoutes(@Observes Router router) { ctx.response().setStatusCode(500).end("Internal Server Error"); } }); + + // Tenant-specific agent card: GET /.well-known/{tenant}/agent-card.json + router.getWithRegex("^\\/.well-known\\/(?[^\\/]+)\\/agent-card\\.json$") + .produces(APPLICATION_JSON) + .handler(ctx -> { + try { + String agentCard = getTenantAgentCard(ctx); + ctx.response() + .setStatusCode(200) + .putHeader(CONTENT_TYPE, APPLICATION_JSON) + .end(agentCard); + } catch (JsonProcessingException e) { + ctx.response().setStatusCode(500).end("Internal Server Error"); + } + }); } /** @@ -417,6 +432,19 @@ public String getAgentCard(RoutingContext rc) throws JsonProcessingException { return JsonUtil.toJson(jsonRpcHandler.getAgentCard()); } + /** + * Retrieves the public agent card for a specific tenant. + * + * @param rc the Vert.x routing context (must contain a {@code tenant} path parameter) + * @return the tenant-specific agent card as a JSON string + * @throws JsonProcessingException if serialization fails + */ + public String getTenantAgentCard(RoutingContext rc) throws JsonProcessingException { + String tenant = extractTenant(rc); + cacheMetadata.getHttpHeadersMap().forEach((k, v) -> rc.response().putHeader(k, v)); + return JsonUtil.toJson(jsonRpcHandler.getAgentCard(tenant)); + } + /** * Routes non-streaming JSON-RPC requests to the appropriate handler method. * @@ -599,12 +627,11 @@ private ServerCallContext createCallContext(RoutingContext rc) { * Extracts the tenant identifier from the request path. * *

The tenant is determined by the normalized path, with leading and trailing - * slashes stripped: + * slashes stripped. The tenant must be a simple identifier (no {@code /} or {@code ?}): *

    *
  • {@code /} → empty tenant
  • *
  • {@code /tenant1} → "tenant1"
  • *
  • {@code /tenant1/} → "tenant1"
  • - *
  • {@code /org/team} → "org/team"
  • *
* * @param rc the routing context diff --git a/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/package-info.java b/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/package-info.java index b4b4501c5..f96306709 100644 --- a/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/package-info.java +++ b/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/package-info.java @@ -112,7 +112,6 @@ *
    *
  • {@code POST /} → empty tenant
  • *
  • {@code POST /tenant1} → tenant "tenant1"
  • - *
  • {@code POST /org/team} → tenant "org/team"
  • *
* *

Customization

diff --git a/reference/jsonrpc/src/test/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutesTest.java b/reference/jsonrpc/src/test/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutesTest.java index f2a746b2c..b13e62ab9 100644 --- a/reference/jsonrpc/src/test/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutesTest.java +++ b/reference/jsonrpc/src/test/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutesTest.java @@ -20,6 +20,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -521,8 +522,8 @@ public void testGetExtendedCard_MethodNameSetInContext() { } @Test - public void testTenantExtraction_MultiSegmentPath() { - // Arrange - simulate request to /test/titi + public void testTenantExtraction_MultiSegmentPath_Rejected() { + // Arrange - multi-segment tenant paths (containing '/') are rejected when(mockRoutingContext.normalizedPath()).thenReturn("/test/titi"); String jsonRpcRequest = """ { @@ -536,25 +537,13 @@ public void testTenantExtraction_MultiSegmentPath() { }"""; when(mockRequestBody.asString()).thenReturn(jsonRpcRequest); - Task responseTask = Task.builder() - .id("de38c76d-d54c-436c-8b9f-4c2703648d64") - .contextId("context-1234") - .status(new TaskStatus(TaskState.TASK_STATE_SUBMITTED)) - .build(); - GetTaskResponse realResponse = new GetTaskResponse("1", responseTask); - when(mockJsonRpcHandler.onGetTask(any(GetTaskRequest.class), any(ServerCallContext.class))) - .thenReturn(realResponse); - - ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(ServerCallContext.class); - // Act routes.invokeJSONRPCHandler(jsonRpcRequest, mockRoutingContext); - // Assert - verify(mockJsonRpcHandler).onGetTask(any(GetTaskRequest.class), contextCaptor.capture()); - ServerCallContext capturedContext = contextCaptor.getValue(); - assertNotNull(capturedContext); - assertEquals("test/titi", capturedContext.getState().get(TENANT_KEY)); + // Assert - handler is NOT called; an error response is returned + verify(mockJsonRpcHandler, never()).onGetTask(any(GetTaskRequest.class), any(ServerCallContext.class)); + verify(mockHttpResponse).putHeader(CONTENT_TYPE, APPLICATION_JSON); + verify(mockHttpResponse).end(anyString()); } @Test @@ -632,8 +621,8 @@ public void testTenantExtraction_SingleSegmentPath() { } @Test - public void testTenantExtraction_ThreeSegmentPath() { - // Arrange - simulate request to /tenant1/api/v1 + public void testTenantExtraction_ThreeSegmentPath_Rejected() { + // Arrange - three-segment tenant paths (containing '/') are rejected when(mockRoutingContext.normalizedPath()).thenReturn("/tenant1/api/v1"); String jsonRpcRequest = """ { @@ -647,31 +636,19 @@ public void testTenantExtraction_ThreeSegmentPath() { }"""; when(mockRequestBody.asString()).thenReturn(jsonRpcRequest); - Task responseTask = Task.builder() - .id("de38c76d-d54c-436c-8b9f-4c2703648d64") - .contextId("context-1234") - .status(new TaskStatus(TaskState.TASK_STATE_SUBMITTED)) - .build(); - GetTaskResponse realResponse = new GetTaskResponse("1", responseTask); - when(mockJsonRpcHandler.onGetTask(any(GetTaskRequest.class), any(ServerCallContext.class))) - .thenReturn(realResponse); - - ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(ServerCallContext.class); - // Act routes.invokeJSONRPCHandler(jsonRpcRequest, mockRoutingContext); - // Assert - verify(mockJsonRpcHandler).onGetTask(any(GetTaskRequest.class), contextCaptor.capture()); - ServerCallContext capturedContext = contextCaptor.getValue(); - assertNotNull(capturedContext); - assertEquals("tenant1/api/v1", capturedContext.getState().get(TENANT_KEY)); + // Assert - handler is NOT called; an error response is returned + verify(mockJsonRpcHandler, never()).onGetTask(any(GetTaskRequest.class), any(ServerCallContext.class)); + verify(mockHttpResponse).putHeader(CONTENT_TYPE, APPLICATION_JSON); + verify(mockHttpResponse).end(anyString()); } @Test public void testTenantExtraction_StreamingRequest() { - // Arrange - simulate streaming request to /myTenant/api - when(mockRoutingContext.normalizedPath()).thenReturn("/myTenant/api"); + // Arrange - simulate streaming request to /myTenant + when(mockRoutingContext.normalizedPath()).thenReturn("/myTenant"); String jsonRpcRequest = """ { "jsonrpc": "2.0", @@ -713,7 +690,7 @@ public void testTenantExtraction_StreamingRequest() { contextCaptor.capture()); ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); - assertEquals("myTenant/api", capturedContext.getState().get(TENANT_KEY)); + assertEquals("myTenant", capturedContext.getState().get(TENANT_KEY)); } @Test diff --git a/reference/rest/src/main/java/org/a2aproject/sdk/server/rest/quarkus/A2AServerRoutes.java b/reference/rest/src/main/java/org/a2aproject/sdk/server/rest/quarkus/A2AServerRoutes.java index 8cd9f7af3..686101372 100644 --- a/reference/rest/src/main/java/org/a2aproject/sdk/server/rest/quarkus/A2AServerRoutes.java +++ b/reference/rest/src/main/java/org/a2aproject/sdk/server/rest/quarkus/A2AServerRoutes.java @@ -246,6 +246,12 @@ void setupRouter(@Observes @Priority(10) Router router) { .produces(APPLICATION_JSON) .handler(this::getAgentCard); + // GET /.well-known/{tenant}/agent-card.json - Tenant-specific public agent card (no auth required) + router.getWithRegex("^\\/.well-known\\/(?[^\\/]+)\\/agent-card\\.json$") + .order(1) + .produces(APPLICATION_JSON) + .handler(this::getTenantAgentCard); + // GET /{tenant}/extendedAgentCard - Extended agent card (auth required) router.getWithRegex("^\\/(?[^\\/]*\\/?)extendedAgentCard$") .order(1) @@ -827,6 +833,22 @@ public void getAgentCard(RoutingContext rc) { sendResponse(rc, response); } + /** + * Retrieves the public agent card for a specific tenant. + * + *

Maps {@code GET /.well-known/{tenant}/agent-card.json} to + * {@link RestHandler#getAgentCard(String)}. + * + *

Authentication: {@code @PermitAll} - Public endpoint requiring no authentication + * + * @param rc the Vert.x routing context + */ + @PermitAll + public void getTenantAgentCard(RoutingContext rc) { + HTTPRestResponse response = jsonRestHandler.getAgentCard(extractTenant(rc)); + sendResponse(rc, response); + } + /** * Retrieves the extended agent card with additional metadata. * diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardCacheMetadata.java b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardCacheMetadata.java index 4696e3b74..561dcff94 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardCacheMetadata.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardCacheMetadata.java @@ -19,6 +19,7 @@ import org.jspecify.annotations.Nullable; import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException; +import org.a2aproject.sdk.server.util.CdiUtils; import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; import org.a2aproject.sdk.server.config.A2AConfigProvider; import org.a2aproject.sdk.spec.AgentCard; @@ -92,12 +93,13 @@ void init() { // 2. Direct constructor: agentCard and config already set if (agentCard == null && agentCardInstance != null) { - // CDI path - only initialize if AgentCard bean is available - if (agentCardInstance.isUnsatisfied() || configInstance.isUnsatisfied()) { + // CDI path — in multitenancy setups, multiple @PublicAgentCard beans may exist, + // making the injection point ambiguous; getIfResolvable returns null in that case. + this.agentCard = CdiUtils.getIfResolvable(agentCardInstance); + this.config = CdiUtils.getIfResolvable(configInstance); + if (this.agentCard == null || this.config == null) { return; } - this.agentCard = agentCardInstance.get(); - this.config = configInstance.get(); } // At this point, agentCard and config should be set (either via CDI or constructor) diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/ExtendedAgentCard.java b/server-common/src/main/java/org/a2aproject/sdk/server/ExtendedAgentCard.java index 86ff36dfa..3b19e1ed7 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/ExtendedAgentCard.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/ExtendedAgentCard.java @@ -9,10 +9,15 @@ import java.lang.annotation.Retention; import java.lang.annotation.Target; +import jakarta.enterprise.util.AnnotationLiteral; import jakarta.inject.Qualifier; @Qualifier @Retention(RUNTIME) @Target({FIELD, TYPE, METHOD, PARAMETER}) public @interface ExtendedAgentCard { + + final class Literal extends AnnotationLiteral implements ExtendedAgentCard { + public static final Literal INSTANCE = new Literal(); + } } diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/agentexecution/RequestContext.java b/server-common/src/main/java/org/a2aproject/sdk/server/agentexecution/RequestContext.java index 129668328..284e055e0 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/agentexecution/RequestContext.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/agentexecution/RequestContext.java @@ -78,6 +78,7 @@ public class RequestContext { private final @Nullable MessageSendParams params; private final String taskId; private final String contextId; + private final @Nullable String tenant; private final @Nullable Task task; private final List relatedTasks; private final @Nullable ServerCallContext callContext; @@ -92,6 +93,7 @@ public class RequestContext { * @param params the message send parameters (can be null for cancel operations) * @param taskId the task identifier (must not be null) * @param contextId the context identifier (must not be null) + * @param tenant the tenant identifier (can be null) * @param task the existing task state (null for new conversations) * @param relatedTasks other tasks in the same context (must not be null, can be empty) * @param callContext the server call context (can be null) @@ -100,12 +102,14 @@ private RequestContext( @Nullable MessageSendParams params, String taskId, String contextId, + @Nullable String tenant, @Nullable Task task, List relatedTasks, @Nullable ServerCallContext callContext) { this.params = params; this.taskId = taskId; this.contextId = contextId; + this.tenant = tenant; this.task = task; this.relatedTasks = relatedTasks; this.callContext = callContext; @@ -228,10 +232,10 @@ public List getRelatedTasks() { * customer or organization the request belongs to. *

* - * @return the tenant identifier, or null if no params or tenant not set + * @return the tenant identifier, or null if not set */ public @Nullable String getTenant() { - return params != null ? params.tenant() : null; + return tenant; } /** @@ -323,6 +327,7 @@ public static class Builder { private @Nullable MessageSendParams params; private @Nullable String taskId; private @Nullable String contextId; + private @Nullable String tenant; private @Nullable Task task; private @Nullable List relatedTasks; private @Nullable ServerCallContext serverCallContext; @@ -342,6 +347,17 @@ public Builder setContextId(@Nullable String contextId) { return this; } + /** + * Sets the tenant identifier. + * + * @param tenant the tenant identifier, may be {@code null} + * @return this builder for method chaining + */ + public Builder setTenant(@Nullable String tenant) { + this.tenant = tenant; + return this; + } + public Builder setTask(@Nullable Task task) { this.task = task; return this; @@ -431,7 +447,7 @@ public RequestContext build() throws InvalidParamsError { // 6. Call constructor with finalized values (IDs guaranteed non-null) return new RequestContext(finalParams, finalTaskId, finalContextId, - task, finalRelatedTasks, serverCallContext); + tenant, task, finalRelatedTasks, serverCallContext); } } diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/AgentCardRouter.java b/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/AgentCardRouter.java new file mode 100644 index 000000000..8f7b0d5fd --- /dev/null +++ b/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/AgentCardRouter.java @@ -0,0 +1,36 @@ +package org.a2aproject.sdk.server.multitenancy; + +import org.a2aproject.sdk.spec.AgentCard; +import org.jspecify.annotations.Nullable; + +/** + * Resolves tenant-specific {@link AgentCard} instances. + *

+ * Implementations should return the default (unqualified) card when the tenant + * is {@code null}, blank, or does not match any registered tenant. + */ +public interface AgentCardRouter { + + /** + * Resolves the extended {@link AgentCard} for the given tenant. + * + * @param tenant the tenant identifier, may be {@code null} + * @return the resolved extended agent card, or {@code null} if none is configured + */ + @Nullable AgentCard resolveExtendedCard(@Nullable String tenant); + + /** + * Resolves the public {@link AgentCard} for the given tenant. + *

+ * Returns {@code null} by default, signaling the handler to fall back to the + * default (non-tenant-specific) public agent card injected via {@code @PublicAgentCard}. + * Implementations that manage tenant-specific public cards should return + * a non-{@code null} card for known tenants. + * + * @param tenant the tenant identifier, may be {@code null} + * @return the tenant-specific public agent card, or {@code null} to fall back to the default public card + */ + default @Nullable AgentCard resolvePublicCard(@Nullable String tenant) { + return null; + } +} diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/AgentExecutorRouter.java b/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/AgentExecutorRouter.java new file mode 100644 index 000000000..6e9329116 --- /dev/null +++ b/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/AgentExecutorRouter.java @@ -0,0 +1,21 @@ +package org.a2aproject.sdk.server.multitenancy; + +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.jspecify.annotations.Nullable; + +/** + * Resolves the {@link AgentExecutor} for a given tenant. + *

+ * Implementations should return the default (unqualified) executor when the tenant + * is {@code null}, blank, or does not match any registered tenant. + */ +public interface AgentExecutorRouter { + + /** + * Resolves the {@link AgentExecutor} for the given tenant. + * + * @param tenant the tenant identifier, may be {@code null} + * @return the resolved executor, never {@code null} + */ + AgentExecutor resolve(@Nullable String tenant); +} diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/AuthorizationRequestHandlerDecorator.java b/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/AuthorizationRequestHandlerDecorator.java index 67007aef0..04e5f589a 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/AuthorizationRequestHandlerDecorator.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/AuthorizationRequestHandlerDecorator.java @@ -8,6 +8,7 @@ import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult; +import org.a2aproject.sdk.server.util.CdiUtils; import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.auth.TaskAuthorizationProvider; import org.a2aproject.sdk.server.auth.TaskOperation; @@ -70,11 +71,7 @@ public AuthorizationRequestHandlerDecorator(RequestHandler delegate, @PostConstruct void init() { - if (authorizationProviderInstance != null) { - authorizationProvider = authorizationProviderInstance.isResolvable() - ? authorizationProviderInstance.get() - : null; - } + authorizationProvider = CdiUtils.getIfResolvable(authorizationProviderInstance); } private Flow.Publisher wrapPublisherForOwnership( diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java b/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java index 4b63c6716..57a614712 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java @@ -41,12 +41,14 @@ import org.a2aproject.sdk.server.events.EventQueueItem; import org.a2aproject.sdk.server.events.MainEventBusProcessor; import org.a2aproject.sdk.server.events.QueueManager; +import org.a2aproject.sdk.server.multitenancy.AgentExecutorRouter; import org.a2aproject.sdk.server.tasks.AgentEmitter; import org.a2aproject.sdk.server.tasks.PushNotificationConfigStore; import org.a2aproject.sdk.server.tasks.PushNotificationSender; import org.a2aproject.sdk.server.tasks.ResultAggregator; import org.a2aproject.sdk.server.tasks.TaskManager; import org.a2aproject.sdk.server.tasks.TaskStore; +import org.a2aproject.sdk.server.util.CdiUtils; import org.a2aproject.sdk.server.util.async.EventConsumerExecutorProducer.EventConsumerExecutor; import org.a2aproject.sdk.server.util.async.Internal; import org.a2aproject.sdk.spec.A2AError; @@ -208,7 +210,13 @@ public class DefaultRequestHandler implements RequestHandler { @SuppressWarnings("NullAway") @Nullable Instance authorizationProviderInstance; + @Inject + @Any + @SuppressWarnings("NullAway") + @Nullable Instance agentExecutorRouterInstance; + private @Nullable TaskAuthorizationProvider authorizationProvider; + private @Nullable AgentExecutorRouter agentExecutorRouter; /** * Timeout in seconds to wait for agent execution to complete in blocking calls. @@ -320,9 +328,8 @@ void initConfig() { configProvider.getValue(A2A_BLOCKING_CONSUMPTION_TIMEOUT_SECONDS)); reconciliationTimeoutSeconds = Integer.parseInt( configProvider.getValue(A2A_BLOCKING_RECONCILIATION_TIMEOUT_SECONDS)); - if (authorizationProviderInstance != null && authorizationProviderInstance.isResolvable()) { - authorizationProvider = authorizationProviderInstance.get(); - } + authorizationProvider = CdiUtils.getIfResolvable(authorizationProviderInstance); + agentExecutorRouter = CdiUtils.getIfResolvable(agentExecutorRouterInstance); boolean populateReferredTasks = Boolean.parseBoolean( configProvider.getValue(A2A_REQUEST_CONTEXT_POPULATE_REFERRED_TASKS)); this.requestContextBuilder = () -> new SimpleRequestContextBuilder(taskStore, populateReferredTasks, authorizationProvider); @@ -343,6 +350,7 @@ public static class Builder { private Executor executor; private Executor eventConsumerExecutor; private @Nullable TaskAuthorizationProvider authorizationProvider; + private @Nullable AgentExecutorRouter agentExecutorRouter; private boolean populateReferredTasks; public Builder agentExecutor(AgentExecutor agentExecutor) { @@ -385,6 +393,17 @@ public Builder authorizationProvider(@Nullable TaskAuthorizationProvider authori return this; } + /** + * Sets the optional {@link AgentExecutorRouter} for tenant-based executor resolution. + * + * @param agentExecutorRouter the router, may be {@code null} + * @return this builder for method chaining + */ + public Builder agentExecutorRouter(@Nullable AgentExecutorRouter agentExecutorRouter) { + this.agentExecutorRouter = agentExecutorRouter; + return this; + } + public Builder populateReferredTasks(boolean populateReferredTasks) { this.populateReferredTasks = populateReferredTasks; return this; @@ -405,6 +424,7 @@ public DefaultRequestHandler build() { handler.consumptionCompletionTimeoutSeconds = 2; handler.reconciliationTimeoutSeconds = 1; handler.authorizationProvider = authorizationProvider; + handler.agentExecutorRouter = agentExecutorRouter; handler.requestContextBuilder = () -> new SimpleRequestContextBuilder(taskStore, populateReferredTasks, authorizationProvider); return handler; @@ -523,15 +543,18 @@ private Task doCancelTask(CancelTaskParams params, ServerCallContext context) th RequestContext cancelRequestContext = requestContextBuilder.get() .setTaskId(task.id()) .setContextId(task.contextId()) + .setTenant(params.tenant()) .setTask(task) .setServerCallContext(context) .build(); AgentEmitter emitter = new AgentEmitter(cancelRequestContext, queue); + AgentExecutor resolvedExecutor = resolveAgentExecutor(cancelRequestContext.getTenant()); + // Call agentExecutor.cancel() with error handling // AgentExecutor is user-provided, so catch all exceptions try { - agentExecutor.cancel(cancelRequestContext, emitter); + resolvedExecutor.cancel(cancelRequestContext, emitter); } catch (TaskNotCancelableError e) { // Expected error - log and enqueue LOGGER.info("Task {} is not cancelable, agent threw: {}", task.id(), e.getMessage()); @@ -1070,13 +1093,14 @@ private boolean shouldAddPushInfo(MessageSendParams params) { private EnhancedRunnable registerAndExecuteAgentAsync(String taskId, RequestContext requestContext, EventQueue queue, EnhancedRunnable.DoneCallback doneCallback) { LOGGER.debug("Registering agent execution for task {}, runningAgents.size() before: {}", taskId, runningAgents.size()); logThreadStats("AGENT START"); + AgentExecutor resolvedExecutor = resolveAgentExecutor(requestContext.getTenant()); EnhancedRunnable runnable = new EnhancedRunnable() { @Override public void run() { LOGGER.debug("Agent execution starting for task {}", taskId); AgentEmitter emitter = new AgentEmitter(requestContext, queue); try { - agentExecutor.execute(requestContext, emitter); + resolvedExecutor.execute(requestContext, emitter); } catch (A2AError e) { // Log A2A errors at WARN level with full stack trace // These are expected business errors but should be tracked @@ -1188,6 +1212,7 @@ private MessageSendSetup initMessageSend(MessageSendParams params, ServerCallCon .setParams(requestParams) .setTaskId(requestParams.message().taskId()) .setContextId(task != null ? task.contextId() : requestParams.message().contextId()) + .setTenant(requestParams.tenant()) .setTask(task) .setServerCallContext(context) .build(); @@ -1215,6 +1240,7 @@ private MessageSendSetup initMessageSend(MessageSendParams params, ServerCallCon .setParams(requestParams) .setTask(task) .setContextId(task.contextId()) + .setTenant(requestParams.tenant()) .setServerCallContext(context) .build(); } @@ -1222,6 +1248,13 @@ private MessageSendSetup initMessageSend(MessageSendParams params, ServerCallCon return new MessageSendSetup(taskManager, task, requestContext); } + private AgentExecutor resolveAgentExecutor(@Nullable String tenant) { + if (agentExecutorRouter != null) { + return agentExecutorRouter.resolve(tenant); + } + return agentExecutor; + } + /** * The authorization is done by the AuthorizationRequestHandlerDecorator */ diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/tasks/InMemoryTaskStore.java b/server-common/src/main/java/org/a2aproject/sdk/server/tasks/InMemoryTaskStore.java index 0fb0eb484..ce095e60b 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/tasks/InMemoryTaskStore.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/tasks/InMemoryTaskStore.java @@ -7,6 +7,7 @@ import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult; import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.auth.TaskAuthorizationProvider; +import org.a2aproject.sdk.server.util.CdiUtils; import org.a2aproject.sdk.spec.Artifact; import org.a2aproject.sdk.spec.ListTasksParams; import org.a2aproject.sdk.spec.Message; @@ -98,9 +99,7 @@ public InMemoryTaskStore() { @Inject public InMemoryTaskStore(@Any Instance authorizationProviderInstance) { - this.authorizationProvider = authorizationProviderInstance.isResolvable() - ? authorizationProviderInstance.get() - : null; + this.authorizationProvider = CdiUtils.getIfResolvable(authorizationProviderInstance); } /** diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/util/CdiUtils.java b/server-common/src/main/java/org/a2aproject/sdk/server/util/CdiUtils.java new file mode 100644 index 000000000..c42e2db9d --- /dev/null +++ b/server-common/src/main/java/org/a2aproject/sdk/server/util/CdiUtils.java @@ -0,0 +1,83 @@ +package org.a2aproject.sdk.server.util; + +import java.lang.annotation.Annotation; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Singleton; + +import org.jspecify.annotations.Nullable; + +/** + * CDI utility methods shared across server modules. + */ +public final class CdiUtils { + + private static final Logger LOGGER = Logger.getLogger(CdiUtils.class.getName()); + + private CdiUtils() { + } + + /** + * Resolves a bean from an optional CDI {@link Instance}. + * + * @param instance the CDI instance, may be {@code null} + * @param the bean type + * @return the resolved bean, or {@code null} if the instance is absent or not uniquely resolvable + */ + public static @Nullable T getIfResolvable(@Nullable Instance instance) { + if (instance != null && instance.isResolvable()) { + return instance.get(); + } + return null; + } + + /** + * Finds the single default bean of a given type, excluding beans that carry a specific + * qualifier and optionally requiring another. Warns on duplicate defaults and validates + * that the bean scope is {@code @ApplicationScoped} or {@code @Singleton}. + * + * @param allBeans the CDI instance containing all beans of type {@code T} + * @param excludeQualifier qualifier whose presence causes a bean to be skipped + * @param requiredQualifier qualifier that the default bean must carry, or {@code null} to accept any + * non-excluded bean + * @param beanTypeName human-readable label used in warning messages (e.g. {@code "@ExtendedAgentCard"}) + * @param the bean type + * @return the resolved default bean, or {@code null} if none was found + */ + public static @Nullable T resolveDefaultBean( + Instance allBeans, + Class excludeQualifier, + @Nullable Class requiredQualifier, + String beanTypeName) { + T defaultBean = null; + for (Instance.Handle handle : allBeans.handles()) { + Set qualifiers = handle.getBean().getQualifiers(); + if (qualifiers.stream().anyMatch(excludeQualifier::isInstance)) { + continue; + } + if (requiredQualifier != null && qualifiers.stream().noneMatch(requiredQualifier::isInstance)) { + continue; + } + if (defaultBean != null) { + throw new IllegalStateException(String.format( + "Multiple default %s beans detected — found %s but already resolved one; " + + "ensure only one %s bean without the excluded qualifier exists", + beanTypeName, handle.getBean().getBeanClass().getName(), beanTypeName)); + } else { + Class scope = handle.getBean().getScope(); + if (!ApplicationScoped.class.equals(scope) && !Singleton.class.equals(scope)) { + LOGGER.log(Level.WARNING, + "Default {0} bean {1} has scope {2} — only @ApplicationScoped or @Singleton is safe here", + new Object[] { beanTypeName, handle.getBean().getBeanClass().getName(), + scope.getSimpleName() }); + } + defaultBean = handle.get(); + } + } + return defaultBean; + } +} diff --git a/server-common/src/test/java/org/a2aproject/sdk/server/agentexecution/RequestContextTest.java b/server-common/src/test/java/org/a2aproject/sdk/server/agentexecution/RequestContextTest.java index 9e81e5202..a307a3095 100644 --- a/server-common/src/test/java/org/a2aproject/sdk/server/agentexecution/RequestContextTest.java +++ b/server-common/src/test/java/org/a2aproject/sdk/server/agentexecution/RequestContextTest.java @@ -441,6 +441,7 @@ public void testBuilderPreservesTenantWhenUpdatingMessage() { RequestContext context = new RequestContext.Builder() .setParams(mockParams) .setTaskId(builderTaskId) // Forces message update + .setTenant(tenantId) .build(); // Verify the message was updated with builder's task ID diff --git a/server-common/src/test/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandlerTest.java b/server-common/src/test/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandlerTest.java index 48205fe7b..f034ba50d 100644 --- a/server-common/src/test/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandlerTest.java +++ b/server-common/src/test/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandlerTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -26,6 +27,7 @@ import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.multitenancy.AgentExecutorRouter; import org.a2aproject.sdk.server.config.A2AConfigProvider; import org.a2aproject.sdk.server.events.EventQueue; import org.a2aproject.sdk.server.events.EventQueueItem; @@ -44,6 +46,7 @@ import org.a2aproject.sdk.spec.Event; import org.a2aproject.sdk.spec.EventKind; import org.a2aproject.sdk.spec.InvalidParamsError; +import org.a2aproject.sdk.spec.ListTasksParams; import org.a2aproject.sdk.spec.Message; import org.a2aproject.sdk.spec.MessageSendConfiguration; import org.a2aproject.sdk.spec.MessageSendParams; @@ -1259,4 +1262,268 @@ void testConcurrentCancelsAreSerialized() throws Exception { cancelExec.shutdownNow(); } } + + @Test + void agentExecutorRouterRoutesToTenantSpecificExecutor() throws Exception { + AgentExecutor tenantExecutor = new AgentExecutor() { + @Override + public void execute(RequestContext context, AgentEmitter emitter) { + assertEquals("acme", context.getTenant()); + emitter.complete(); + } + + @Override + public void cancel(RequestContext context, AgentEmitter emitter) { + } + }; + + requestHandler = buildHandlerWithRouter(tenant -> { + if ("acme".equals(tenant)) { + return tenantExecutor; + } + return executor; + }); + + MessageSendParams params = MessageSendParams.builder() + .message(Message.builder() + .messageId("msg-tenant") + .role(Message.Role.ROLE_USER) + .parts(new TextPart("hello")) + .build()) + .configuration(DEFAULT_CONFIG) + .tenant("acme") + .build(); + + EventKind result = requestHandler.onMessageSend(params, NULL_CONTEXT); + assertInstanceOf(Task.class, result); + Task task = (Task) result; + assertEquals(TaskState.TASK_STATE_COMPLETED, task.status().state()); + } + + @Test + void cancelFlowSetsTenantOnRequestContext() throws Exception { + AtomicReference capturedTenant = new AtomicReference<>(); + + agentExecutorExecute = (context, emitter) -> { + emitter.startWork(); + }; + + MessageSendParams params = MessageSendParams.builder() + .message(Message.builder() + .messageId("msg-cancel-tenant") + .role(Message.Role.ROLE_USER) + .parts(new TextPart("hello")) + .build()) + .configuration(DEFAULT_CONFIG) + .tenant("acme") + .build(); + + requestHandler.onMessageSend(params, NULL_CONTEXT); + Task task = taskStore.list(ListTasksParams.builder().build(), NULL_CONTEXT).tasks().get(0); + + agentExecutorCancel = (context, emitter) -> { + capturedTenant.set(context.getTenant()); + emitter.cancel(); + }; + + requestHandler.onCancelTask(new CancelTaskParams(task.id(), "acme", Map.of()), NULL_CONTEXT); + assertEquals("acme", capturedTenant.get()); + } + + @Test + void agentExecutorRouterNullTenantFallsBackToDefault() throws Exception { + AtomicReference capturedTenant = new AtomicReference<>("not-set"); + + agentExecutorExecute = (context, emitter) -> { + capturedTenant.set(context.getTenant()); + emitter.complete(); + }; + + requestHandler = buildHandlerWithRouter(tenant -> executor); + + MessageSendParams params = MessageSendParams.builder() + .message(Message.builder() + .messageId("msg-null-tenant") + .role(Message.Role.ROLE_USER) + .parts(new TextPart("hello")) + .build()) + .configuration(DEFAULT_CONFIG) + .build(); + + EventKind result = requestHandler.onMessageSend(params, NULL_CONTEXT); + assertInstanceOf(Task.class, result); + assertEquals(TaskState.TASK_STATE_COMPLETED, ((Task) result).status().state()); + assertNull(capturedTenant.get()); + } + + @Test + void agentExecutorRouterPropagatesUnknownTenantToContext() throws Exception { + AtomicReference capturedTenant = new AtomicReference<>(); + + agentExecutorExecute = (context, emitter) -> { + capturedTenant.set(context.getTenant()); + emitter.complete(); + }; + + requestHandler = buildHandlerWithRouter(tenant -> executor); + + MessageSendParams params = MessageSendParams.builder() + .message(Message.builder() + .messageId("msg-unknown-tenant") + .role(Message.Role.ROLE_USER) + .parts(new TextPart("hello")) + .build()) + .configuration(DEFAULT_CONFIG) + .tenant("unknown") + .build(); + + EventKind result = requestHandler.onMessageSend(params, NULL_CONTEXT); + assertInstanceOf(Task.class, result); + assertEquals(TaskState.TASK_STATE_COMPLETED, ((Task) result).status().state()); + assertEquals("unknown", capturedTenant.get()); + } + + @Test + void agentExecutorRouterWorksWithStreaming() throws Exception { + AtomicReference capturedTenant = new AtomicReference<>(); + + AgentExecutor tenantExecutor = new AgentExecutor() { + @Override + public void execute(RequestContext context, AgentEmitter emitter) { + capturedTenant.set(context.getTenant()); + emitter.complete(); + } + + @Override + public void cancel(RequestContext context, AgentEmitter emitter) { + } + }; + + requestHandler = buildHandlerWithRouter(tenant -> { + if ("acme".equals(tenant)) { + return tenantExecutor; + } + return executor; + }); + + MessageSendParams params = MessageSendParams.builder() + .message(Message.builder() + .messageId("msg-stream-tenant") + .role(Message.Role.ROLE_USER) + .parts(new TextPart("hello")) + .build()) + .configuration(MessageSendConfiguration.builder() + .returnImmediately(true) + .acceptedOutputModes(List.of()) + .build()) + .tenant("acme") + .build(); + + CountDownLatch streamDone = new CountDownLatch(1); + ServerCallContext streamContext = contextWithVersion("1.0"); + Flow.Publisher publisher = requestHandler.onMessageSendStream(params, streamContext); + publisher.subscribe(new Flow.Subscriber<>() { + @Override + public void onSubscribe(Flow.Subscription s) { + s.request(Long.MAX_VALUE); + } + + @Override + public void onNext(StreamingEventKind item) { + } + + @Override + public void onError(Throwable t) { + streamDone.countDown(); + } + + @Override + public void onComplete() { + streamDone.countDown(); + } + }); + + assertTrue(streamDone.await(5, TimeUnit.SECONDS), "Stream should complete"); + assertEquals("acme", capturedTenant.get()); + } + + @Test + void noRouterUsesDefaultExecutor() throws Exception { + agentExecutorExecute = (context, emitter) -> { + emitter.complete(); + }; + + MessageSendParams params = MessageSendParams.builder() + .message(Message.builder() + .messageId("msg-no-router") + .role(Message.Role.ROLE_USER) + .parts(new TextPart("hello")) + .build()) + .configuration(DEFAULT_CONFIG) + .tenant("acme") + .build(); + + EventKind result = requestHandler.onMessageSend(params, NULL_CONTEXT); + assertInstanceOf(Task.class, result); + assertEquals(TaskState.TASK_STATE_COMPLETED, ((Task) result).status().state()); + } + + @Test + void routerResolvedExecutorErrorSetsTaskFailed() throws Exception { + CountDownLatch executorRan = new CountDownLatch(1); + + AgentExecutor failingExecutor = new AgentExecutor() { + @Override + public void execute(RequestContext context, AgentEmitter emitter) { + executorRan.countDown(); + emitter.fail(); + } + + @Override + public void cancel(RequestContext context, AgentEmitter emitter) { + } + }; + + requestHandler = buildHandlerWithRouter(tenant -> failingExecutor); + + MessageSendParams params = MessageSendParams.builder() + .message(Message.builder() + .messageId("msg-failing") + .role(Message.Role.ROLE_USER) + .parts(new TextPart("hello")) + .build()) + .configuration(DEFAULT_CONFIG) + .tenant("acme") + .build(); + + EventKind result = requestHandler.onMessageSend(params, NULL_CONTEXT); + assertInstanceOf(Task.class, result); + Task task = (Task) result; + + assertTrue(executorRan.await(5, TimeUnit.SECONDS), "Executor should have run"); + // Poll for the async state update instead of a fixed sleep + Task storedTask = null; + for (int i = 0; i < 50; i++) { + storedTask = taskStore.get(task.id()); + if (storedTask != null && storedTask.status().state() == TaskState.TASK_STATE_FAILED) { + break; + } + Thread.sleep(20); + } + assertNotNull(storedTask); + assertEquals(TaskState.TASK_STATE_FAILED, storedTask.status().state()); + } + + private DefaultRequestHandler buildHandlerWithRouter(AgentExecutorRouter router) { + return DefaultRequestHandler.builder() + .agentExecutor(executor) + .taskStore(taskStore) + .queueManager(queueManager) + .pushConfigStore(pushConfigStore) + .mainEventBusProcessor(mainEventBusProcessor) + .executor(internalExecutor) + .eventConsumerExecutor(internalExecutor) + .agentExecutorRouter(router) + .build(); + } } diff --git a/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtils.java b/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtils.java index d42a5aa4f..40abafa8e 100644 --- a/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtils.java +++ b/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtils.java @@ -11,6 +11,8 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Supplier; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; @@ -57,6 +59,7 @@ import org.a2aproject.sdk.spec.A2AError; import org.a2aproject.sdk.spec.ContentTypeNotSupportedError; import org.a2aproject.sdk.spec.ExtendedAgentCardNotConfiguredError; +import org.a2aproject.sdk.spec.GetExtendedAgentCardParams; import org.a2aproject.sdk.spec.ExtensionSupportRequiredError; import org.a2aproject.sdk.spec.InvalidAgentResponseError; import org.a2aproject.sdk.spec.InvalidParamsError; @@ -195,89 +198,84 @@ public static A2ARequest parseRequestBody(String body, @Nullable String tenan } } + private static void setTenantIfAbsent(Supplier existingTenantGetter, Consumer setter, @Nullable String tenantOverride) { + if (tenantOverride != null && !tenantOverride.isBlank() && existingTenantGetter.get().isBlank()) { + setter.accept(tenantOverride); + } + } + private static A2ARequest parseMethodRequest(String version, Object id, String method, JsonElement paramsNode, @Nullable String tenant) throws InvalidParamsError, MethodNotFoundJsonMappingException, JsonProcessingException { switch (method) { case GET_TASK_METHOD -> { org.a2aproject.sdk.grpc.GetTaskRequest.Builder builder = org.a2aproject.sdk.grpc.GetTaskRequest.newBuilder(); parseRequestBody(paramsNode, builder, id); - if (tenant != null && !tenant.isBlank() && (builder.getTenant() == null || builder.getTenant().isBlank())) { - builder.setTenant(tenant); - } + setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant); return new GetTaskRequest(version, id, ProtoUtils.FromProto.taskQueryParams(builder)); } case CANCEL_TASK_METHOD -> { org.a2aproject.sdk.grpc.CancelTaskRequest.Builder builder = org.a2aproject.sdk.grpc.CancelTaskRequest.newBuilder(); parseRequestBody(paramsNode, builder, id); - if (tenant != null && !tenant.isBlank() && (builder.getTenant() == null || builder.getTenant().isBlank())) { - builder.setTenant(tenant); - } + setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant); return new CancelTaskRequest(version, id, ProtoUtils.FromProto.cancelTaskParams(builder)); } case LIST_TASK_METHOD -> { org.a2aproject.sdk.grpc.ListTasksRequest.Builder builder = org.a2aproject.sdk.grpc.ListTasksRequest.newBuilder(); parseRequestBody(paramsNode, builder, id); - if (tenant != null && !tenant.isBlank() && (builder.getTenant() == null || builder.getTenant().isBlank())) { - builder.setTenant(tenant); - } + setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant); return new ListTasksRequest(version, id, ProtoUtils.FromProto.listTasksParams(builder)); } case SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD -> { org.a2aproject.sdk.grpc.TaskPushNotificationConfig.Builder builder = org.a2aproject.sdk.grpc.TaskPushNotificationConfig.newBuilder(); parseRequestBody(paramsNode, builder, id); - if (tenant != null && !tenant.isBlank() && (builder.getTenant() == null || builder.getTenant().isBlank())) { - builder.setTenant(tenant); - } + setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant); return new CreateTaskPushNotificationConfigRequest(version, id, ProtoUtils.FromProto.createTaskPushNotificationConfig(builder)); } case GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD -> { org.a2aproject.sdk.grpc.GetTaskPushNotificationConfigRequest.Builder builder = org.a2aproject.sdk.grpc.GetTaskPushNotificationConfigRequest.newBuilder(); parseRequestBody(paramsNode, builder, id); - if (tenant != null && !tenant.isBlank() && (builder.getTenant() == null || builder.getTenant().isBlank())) { - builder.setTenant(tenant); - } + setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant); return new GetTaskPushNotificationConfigRequest(version, id, ProtoUtils.FromProto.getTaskPushNotificationConfigParams(builder)); } case SEND_MESSAGE_METHOD -> { org.a2aproject.sdk.grpc.SendMessageRequest.Builder builder = org.a2aproject.sdk.grpc.SendMessageRequest.newBuilder(); parseRequestBody(paramsNode, builder, id); - if (tenant != null && !tenant.isBlank() && (builder.getTenant() == null || builder.getTenant().isBlank())) { - builder.setTenant(tenant); - } + setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant); return new SendMessageRequest(version, id, ProtoUtils.FromProto.messageSendParams(builder)); } case LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD -> { org.a2aproject.sdk.grpc.ListTaskPushNotificationConfigsRequest.Builder builder = org.a2aproject.sdk.grpc.ListTaskPushNotificationConfigsRequest.newBuilder(); parseRequestBody(paramsNode, builder, id); - if (tenant != null && !tenant.isBlank() && (builder.getTenant() == null || builder.getTenant().isBlank())) { - builder.setTenant(tenant); - } + setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant); return new ListTaskPushNotificationConfigsRequest(version, id, ProtoUtils.FromProto.listTaskPushNotificationConfigsParams(builder)); } case DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD -> { org.a2aproject.sdk.grpc.DeleteTaskPushNotificationConfigRequest.Builder builder = org.a2aproject.sdk.grpc.DeleteTaskPushNotificationConfigRequest.newBuilder(); parseRequestBody(paramsNode, builder, id); - if (tenant != null && !tenant.isBlank() && (builder.getTenant() == null || builder.getTenant().isBlank())) { - builder.setTenant(tenant); - } + setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant); return new DeleteTaskPushNotificationConfigRequest(version, id, ProtoUtils.FromProto.deleteTaskPushNotificationConfigParams(builder)); } case GET_EXTENDED_AGENT_CARD_METHOD -> { - return new GetExtendedAgentCardRequest(version, id); + if (paramsNode != null && !paramsNode.isJsonNull()) { + org.a2aproject.sdk.grpc.GetExtendedAgentCardRequest.Builder builder = org.a2aproject.sdk.grpc.GetExtendedAgentCardRequest.newBuilder(); + parseRequestBody(paramsNode, builder, id); + setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant); + return new GetExtendedAgentCardRequest(version, id, ProtoUtils.FromProto.getExtendedAgentCardParams(builder)); + } + if (tenant != null && !tenant.isBlank()) { + return new GetExtendedAgentCardRequest(version, id, new GetExtendedAgentCardParams(tenant)); + } + return new GetExtendedAgentCardRequest(version, id, null); } case SEND_STREAMING_MESSAGE_METHOD -> { org.a2aproject.sdk.grpc.SendMessageRequest.Builder builder = org.a2aproject.sdk.grpc.SendMessageRequest.newBuilder(); parseRequestBody(paramsNode, builder, id); - if (tenant != null && !tenant.isBlank() && (builder.getTenant() == null || builder.getTenant().isBlank())) { - builder.setTenant(tenant); - } + setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant); return new SendStreamingMessageRequest(version, id, ProtoUtils.FromProto.messageSendParams(builder)); } case SUBSCRIBE_TO_TASK_METHOD -> { org.a2aproject.sdk.grpc.SubscribeToTaskRequest.Builder builder = org.a2aproject.sdk.grpc.SubscribeToTaskRequest.newBuilder(); parseRequestBody(paramsNode, builder, id); - if (tenant != null && !tenant.isBlank() && (builder.getTenant() == null || builder.getTenant().isBlank())) { - builder.setTenant(tenant); - } + setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant); return new SubscribeToTaskRequest(version, id, ProtoUtils.FromProto.taskIdParams(builder)); } default -> diff --git a/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/ProtoUtils.java b/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/ProtoUtils.java index a7e93533a..26bed34c5 100644 --- a/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/ProtoUtils.java +++ b/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/ProtoUtils.java @@ -229,6 +229,21 @@ public static CancelTaskParams cancelTaskParams(org.a2aproject.sdk.grpc.CancelTa return convert(() -> TaskIdParamsMapper.INSTANCE.fromProtoCancelTaskRequest(reqProto)); } + /** + * Converts a protobuf {@link org.a2aproject.sdk.grpc.GetExtendedAgentCardRequest} to a spec + * {@link GetExtendedAgentCardParams}. + *

+ * Protobuf uses empty string as the default for unset string fields, so an empty tenant + * is normalized to {@code null} to match the spec convention. + * + * @param request the protobuf request + * @return the spec params, with an empty tenant normalized to {@code null} + */ + public static GetExtendedAgentCardParams getExtendedAgentCardParams(org.a2aproject.sdk.grpc.GetExtendedAgentCardRequestOrBuilder request) { + String tenant = request.getTenant(); + return new GetExtendedAgentCardParams(tenant.isBlank() ? null : tenant); + } + public static MessageSendParams messageSendParams(org.a2aproject.sdk.grpc.SendMessageRequestOrBuilder request) { org.a2aproject.sdk.grpc.SendMessageRequest requestProto = request instanceof org.a2aproject.sdk.grpc.SendMessageRequest ? (org.a2aproject.sdk.grpc.SendMessageRequest) request diff --git a/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtilsTest.java b/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtilsTest.java index 9a9f4f232..5e47bd188 100644 --- a/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtilsTest.java +++ b/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtilsTest.java @@ -25,8 +25,10 @@ import org.a2aproject.sdk.jsonrpc.common.wrappers.A2ARequest; import org.a2aproject.sdk.jsonrpc.common.wrappers.CreateTaskPushNotificationConfigRequest; import org.a2aproject.sdk.jsonrpc.common.wrappers.CreateTaskPushNotificationConfigResponse; +import org.a2aproject.sdk.jsonrpc.common.wrappers.GetExtendedAgentCardRequest; import org.a2aproject.sdk.jsonrpc.common.wrappers.GetTaskPushNotificationConfigRequest; import org.a2aproject.sdk.jsonrpc.common.wrappers.GetTaskPushNotificationConfigResponse; +import org.a2aproject.sdk.spec.GetExtendedAgentCardParams; import org.a2aproject.sdk.spec.InvalidParamsError; import org.a2aproject.sdk.spec.JSONParseError; import org.a2aproject.sdk.spec.Message; @@ -540,4 +542,58 @@ public void testToJsonRPCResultResponse_doesNotHtmlEscapeAngleBrackets() { "JSON must not contain HTML-escaped '>' (\\u003e) but got: " + json); } + @Test + void parseGetExtendedAgentCardWithTenantParams() throws Exception { + String json = """ + {"jsonrpc": "2.0", "id": "1", "method": "GetExtendedAgentCard", "params": {"tenant": "acme"}} + """; + A2ARequest request = JSONRPCUtils.parseRequestBody(json, null); + assertInstanceOf(GetExtendedAgentCardRequest.class, request); + GetExtendedAgentCardRequest extRequest = (GetExtendedAgentCardRequest) request; + assertNotNull(extRequest.getParams()); + assertEquals("acme", extRequest.getParams().tenant()); + } + + @Test + void parseGetExtendedAgentCardWithNoParams() throws Exception { + String json = """ + {"jsonrpc": "2.0", "id": "2", "method": "GetExtendedAgentCard"} + """; + A2ARequest request = JSONRPCUtils.parseRequestBody(json, null); + assertInstanceOf(GetExtendedAgentCardRequest.class, request); + } + + @Test + void parseGetExtendedAgentCardWithNullParams() throws Exception { + String json = """ + {"jsonrpc": "2.0", "id": "3", "method": "GetExtendedAgentCard", "params": null} + """; + A2ARequest request = JSONRPCUtils.parseRequestBody(json, null); + assertInstanceOf(GetExtendedAgentCardRequest.class, request); + } + + @Test + void parseGetExtendedAgentCardWithUrlTenant() throws Exception { + String json = """ + {"jsonrpc": "2.0", "id": "4", "method": "GetExtendedAgentCard"} + """; + A2ARequest request = JSONRPCUtils.parseRequestBody(json, "url-tenant"); + assertInstanceOf(GetExtendedAgentCardRequest.class, request); + GetExtendedAgentCardRequest extRequest = (GetExtendedAgentCardRequest) request; + assertNotNull(extRequest.getParams()); + assertEquals("url-tenant", extRequest.getParams().tenant()); + } + + @Test + void parseGetExtendedAgentCardBodyTenantTakesPrecedence() throws Exception { + String json = """ + {"jsonrpc": "2.0", "id": "5", "method": "GetExtendedAgentCard", "params": {"tenant": "body-tenant"}} + """; + A2ARequest request = JSONRPCUtils.parseRequestBody(json, "url-tenant"); + assertInstanceOf(GetExtendedAgentCardRequest.class, request); + GetExtendedAgentCardRequest extRequest = (GetExtendedAgentCardRequest) request; + assertNotNull(extRequest.getParams()); + assertEquals("body-tenant", extRequest.getParams().tenant()); + } + } diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/CancelTaskParams.java b/spec/src/main/java/org/a2aproject/sdk/spec/CancelTaskParams.java index 49946f1f5..6a841e92e 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/CancelTaskParams.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/CancelTaskParams.java @@ -4,6 +4,7 @@ import org.a2aproject.sdk.util.Assert; import org.a2aproject.sdk.spec.util.CollectionCopies; +import org.a2aproject.sdk.spec.util.Utils; import org.jspecify.annotations.Nullable; /** @@ -28,6 +29,7 @@ public record CancelTaskParams(String id, @Nullable String tenant, MapA2A Protocol Specification */ public record GetExtendedAgentCardParams(@Nullable String tenant) { + + public GetExtendedAgentCardParams { + Utils.validateTenant(tenant); + } } diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/GetTaskPushNotificationConfigParams.java b/spec/src/main/java/org/a2aproject/sdk/spec/GetTaskPushNotificationConfigParams.java index d405c21c7..eeb950cd8 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/GetTaskPushNotificationConfigParams.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/GetTaskPushNotificationConfigParams.java @@ -3,6 +3,7 @@ import org.a2aproject.sdk.util.Assert; +import org.a2aproject.sdk.spec.util.Utils; import org.jspecify.annotations.Nullable; /** @@ -30,6 +31,7 @@ public record GetTaskPushNotificationConfigParams(String taskId, String id, @Nul public GetTaskPushNotificationConfigParams { Assert.checkNotNullParam("taskId", taskId); Assert.checkNotNullParam("id", id); + Utils.validateTenant(tenant); } /** diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/ListTaskPushNotificationConfigsParams.java b/spec/src/main/java/org/a2aproject/sdk/spec/ListTaskPushNotificationConfigsParams.java index 36d0f58fb..81c0126e9 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/ListTaskPushNotificationConfigsParams.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/ListTaskPushNotificationConfigsParams.java @@ -1,6 +1,7 @@ package org.a2aproject.sdk.spec; import org.a2aproject.sdk.util.Assert; +import org.a2aproject.sdk.spec.util.Utils; import org.jspecify.annotations.Nullable; /** @@ -29,6 +30,7 @@ public record ListTaskPushNotificationConfigsParams(String id, int pageSize, Str */ public ListTaskPushNotificationConfigsParams { Assert.checkNotNullParam("id", id); + Utils.validateTenant(tenant); } /** diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/ListTasksParams.java b/spec/src/main/java/org/a2aproject/sdk/spec/ListTasksParams.java index 20d3d70b6..e2d5acf63 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/ListTasksParams.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/ListTasksParams.java @@ -3,6 +3,7 @@ import org.a2aproject.sdk.util.Assert; import java.time.Instant; +import org.a2aproject.sdk.spec.util.Utils; import org.jspecify.annotations.Nullable; /** @@ -45,6 +46,7 @@ public record ListTasksParams( * @throws InvalidParamsError if pageSize or historyLength are out of valid range */ public ListTasksParams { + Utils.validateTenant(tenant); // Validate pageSize (1-100) if (pageSize != null && (pageSize < MIN_PAGE_SIZE || pageSize > MAX_PAGE_SIZE)) { throw new InvalidParamsError(null, diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/MessageSendParams.java b/spec/src/main/java/org/a2aproject/sdk/spec/MessageSendParams.java index 3ccae732d..efa47335e 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/MessageSendParams.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/MessageSendParams.java @@ -4,6 +4,7 @@ import org.a2aproject.sdk.util.Assert; import org.a2aproject.sdk.spec.util.CollectionCopies; +import org.a2aproject.sdk.spec.util.Utils; import org.jspecify.annotations.Nullable; /** @@ -36,6 +37,7 @@ public record MessageSendParams(Message message, @Nullable MessageSendConfigurat */ public MessageSendParams { Assert.checkNotNullParam("message", message); + Utils.validateTenant(tenant); metadata = CollectionCopies.unmodifiableNullableShallowMap(metadata); } diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/TaskIdParams.java b/spec/src/main/java/org/a2aproject/sdk/spec/TaskIdParams.java index e92ce222a..b6aed6ef3 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/TaskIdParams.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/TaskIdParams.java @@ -1,6 +1,7 @@ package org.a2aproject.sdk.spec; import org.a2aproject.sdk.util.Assert; +import org.a2aproject.sdk.spec.util.Utils; import org.jspecify.annotations.Nullable; /** @@ -24,6 +25,7 @@ public record TaskIdParams(String id, @Nullable String tenant) { */ public TaskIdParams { Assert.checkNotNullParam("id", id); + Utils.validateTenant(tenant); } /** diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/TaskPushNotificationConfig.java b/spec/src/main/java/org/a2aproject/sdk/spec/TaskPushNotificationConfig.java index 571300120..dc3fa7a0f 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/TaskPushNotificationConfig.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/TaskPushNotificationConfig.java @@ -1,6 +1,7 @@ package org.a2aproject.sdk.spec; import org.a2aproject.sdk.util.Assert; +import org.a2aproject.sdk.spec.util.Utils; import org.jspecify.annotations.Nullable; /** @@ -47,6 +48,7 @@ public record TaskPushNotificationConfig(String id, @Nullable String taskId, Str public TaskPushNotificationConfig { Assert.checkNotNullParam("id", id); Assert.checkNotNullParam("url", url); + Utils.validateTenant(tenant); } /** diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/TaskQueryParams.java b/spec/src/main/java/org/a2aproject/sdk/spec/TaskQueryParams.java index 56c636371..da483bf4d 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/TaskQueryParams.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/TaskQueryParams.java @@ -1,6 +1,7 @@ package org.a2aproject.sdk.spec; import org.a2aproject.sdk.util.Assert; +import org.a2aproject.sdk.spec.util.Utils; import org.jspecify.annotations.Nullable; /** @@ -23,6 +24,7 @@ public record TaskQueryParams(String id, @Nullable Integer historyLength, @Nulla */ public TaskQueryParams { Assert.checkNotNullParam("id", id); + Utils.validateTenant(tenant); if (historyLength != null && historyLength < 0) { throw new IllegalArgumentException("Invalid history length"); } diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/util/Utils.java b/spec/src/main/java/org/a2aproject/sdk/spec/util/Utils.java index a8515722d..3fe46eabe 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/util/Utils.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/util/Utils.java @@ -41,6 +41,7 @@ public class Utils { public static final String DEFAULT_AGENT_CARD_PATH = "/.well-known/agent-card.json"; + static final int MAX_TENANT_LENGTH = 256; private static final Logger log = Logger.getLogger(Utils.class.getName()); @@ -222,19 +223,25 @@ public static String stripWellKnownSuffix(String baseUrl) { } /** - * Builds a base URL by combining a raw base URL string with an optional tenant path. + * Builds a base URL by combining a raw base URL string with an optional tenant identifier. * *

- * Normalizes trailing slashes on the base URL and validates/normalizes the tenant path. + * Normalizes trailing slashes on the base URL and validates the tenant (must be a simple + * identifier — no {@code /} or {@code ?}). * * @param baseUrl the base URL string, must not be null - * @param tenant the tenant path override, may be null for no tenant + * @param tenant the tenant identifier, may be null for no tenant * @return the complete base URL with tenant path appended * @throws IllegalArgumentException if tenant validation fails */ public static String buildBaseUrl(String baseUrl, @Nullable String tenant) { checkNotNullParam("baseUrl", baseUrl); - return stripTrailingSlash(baseUrl) + extractTenant("", tenant); + String stripped = stripTrailingSlash(baseUrl); + String tenantPath = extractTenant("", tenant); + if (!tenantPath.isEmpty() && stripped.endsWith(tenantPath)) { + return stripped; + } + return stripped + tenantPath; } private static String stripTrailingSlash(String s) { @@ -256,89 +263,94 @@ public static AgentInterface getFavoriteInterface(AgentCard agentCard) throws A2 } /** - * Validates that a tenant path is safe and well-formed. + * Validates that a tenant identifier is safe and well-formed. *

- * This method performs security validation to prevent: + * A tenant must be a simple identifier — it must not contain URL path elements + * such as {@code /} or {@code ?}. Null and blank values are silently accepted + * (they mean "no tenant"). This method rejects: *

    - *
  • Path traversal attacks (e.g., {@code ../../admin})
  • *
  • Excessive length (max 256 characters)
  • - *
  • Invalid characters (only allows {@code /a-zA-Z0-9_-.})
  • + *
  • Invalid characters (only allows {@code a-zA-Z0-9_-.})
  • *
* - * @param tenant the tenant path to validate + * @param tenant the tenant identifier to validate, may be {@code null} * @throws IllegalArgumentException if the tenant is invalid or unsafe */ - private static void validateTenant(String tenant) { - if (tenant.isEmpty()) { - return; // Empty string is valid (no tenant) + public static void validateTenant(@Nullable String tenant) { + if (tenant == null || tenant.isBlank()) { + return; } - if (tenant.length() > 256) { - throw new IllegalArgumentException("Tenant path exceeds maximum length of 256 characters"); + String stripped = normalizeTenant(tenant); + if (stripped.isEmpty()) { + return; } - if (tenant.contains("..")) { - throw new IllegalArgumentException("Tenant path contains invalid '..' sequence (path traversal attempt)"); + if (stripped.length() > MAX_TENANT_LENGTH) { + throw new IllegalArgumentException("Tenant exceeds maximum length of " + MAX_TENANT_LENGTH + " characters"); } - if (tenant.contains("//")) { - throw new IllegalArgumentException("Tenant path contains invalid '//' sequence"); + if (!stripped.matches("^[a-zA-Z0-9_.\\-]+$")) { + throw new IllegalArgumentException( + "Tenant contains invalid characters. Only a-zA-Z0-9_-. are allowed"); } + } - if (!tenant.matches("^[/a-zA-Z0-9_.\\-]+$")) { - throw new IllegalArgumentException("Tenant path contains invalid characters. Only /a-zA-Z0-9_-. are allowed"); + private static String normalizeTenant(String tenant) { + String stripped = tenant; + if (stripped.startsWith("/")) { + stripped = stripped.substring(1); + } + if (stripped.endsWith("/")) { + stripped = stripped.substring(0, stripped.length() - 1); } + return stripped; } /** - * Extracts and normalizes a tenant path, using the agent's default tenant if no override is provided. + * Extracts and normalizes a tenant identifier into a URL path segment, using the agent's + * default tenant if no override is provided. *

- * This method normalizes tenant paths by ensuring they: - *

    - *
  • Start with a forward slash ({@code /})
  • - *
  • Do not end with a forward slash (unless it's just {@code /})
  • - *
  • Are validated for security (no path traversal, length limits, valid characters)
  • - *
+ * Leading and trailing slashes are stripped before validation — the tenant must be a simple + * identifier (e.g. {@code "acme"}), not a path (e.g. {@code "org/team"}). *

- * If the provided {@code tenant} parameter is null or blank, the {@code agentTenant} is returned instead. + * If the provided {@code tenant} parameter is null or blank, the {@code agentTenant} is + * returned instead. * * @param agentTenant the default tenant from the agent card, may be null or blank * @param tenant the tenant override from the request, may be null or blank - * @return the normalized tenant path + * @return the tenant as a URL path segment (e.g. {@code "/acme"}), or empty string if no tenant * @throws IllegalArgumentException if the tenant is invalid or unsafe */ private static String extractTenant(@Nullable String agentTenant, @Nullable String tenant) { - String tenantPath = tenant; - if (tenantPath == null || tenantPath.isBlank()) { - tenantPath = agentTenant; + String raw = tenant; + if (raw == null || raw.isBlank()) { + raw = agentTenant; } - if (tenantPath == null || tenantPath.isBlank()) { + if (raw == null || raw.isBlank()) { return ""; } - // Normalize slashes - if (!tenantPath.startsWith("/")) { - tenantPath = '/' + tenantPath; - } - if (tenantPath.endsWith("/") && tenantPath.length() > 1) { - tenantPath = tenantPath.substring(0, tenantPath.length() - 1); - } + validateTenant(raw); - // Validate for security - validateTenant(tenantPath); + String stripped = normalizeTenant(raw); + if (stripped.isEmpty()) { + return ""; + } - return tenantPath; + return "/" + stripped; } /** - * Builds a base URL for A2A operations by combining the agent's URL with a tenant path. + * Builds a base URL for A2A operations by combining the agent's URL with a tenant identifier. *

* This method: *

    *
  • Uses the tenant from the {@link AgentInterface} as the default
  • - *
  • Allows overriding with a custom tenant path if provided
  • + *
  • Allows overriding with a custom tenant if provided
  • *
  • Normalizes trailing slashes on the base URL
  • - *
  • Validates and normalizes the tenant path
  • + *
  • Validates the tenant (must be a simple identifier — no {@code /} or {@code ?})
  • + *
  • Avoids doubling the tenant when the URL already ends with the tenant path
  • *
*

* Example: @@ -349,16 +361,26 @@ private static String extractTenant(@Nullable String agentTenant, @Nullable Stri * * String url2 = Utils.buildBaseUrl(iface, "custom-tenant"); * // Returns: "http://example.com/custom-tenant" + * + * // URL already contains the tenant — no doubling + * AgentInterface iface3 = new AgentInterface("http+json", "http://example.com/acme"); + * String url3 = Utils.buildBaseUrl(iface3, "acme"); + * // Returns: "http://example.com/acme" * } * * @param agentInterface the agent interface containing the base URL and default tenant, must not be null - * @param tenant the tenant override from the request, may be null to use the interface default + * @param tenant the tenant identifier override, may be null to use the interface default * @return the complete base URL with tenant path appended * @throws IllegalArgumentException if agentInterface is null or tenant validation fails */ public static String buildBaseUrl(AgentInterface agentInterface, @Nullable String tenant) { checkNotNullParam("agentInterface", agentInterface); - return stripTrailingSlash(agentInterface.url()) + extractTenant(agentInterface.tenant(), tenant); + String baseUrl = stripTrailingSlash(agentInterface.url()); + String tenantPath = extractTenant(agentInterface.tenant(), tenant); + if (!tenantPath.isEmpty() && baseUrl.endsWith(tenantPath)) { + return baseUrl; + } + return baseUrl + tenantPath; } } diff --git a/spec/src/test/java/org/a2aproject/sdk/spec/util/UtilsTest.java b/spec/src/test/java/org/a2aproject/sdk/spec/util/UtilsTest.java index c8c97470b..ca6221d7d 100644 --- a/spec/src/test/java/org/a2aproject/sdk/spec/util/UtilsTest.java +++ b/spec/src/test/java/org/a2aproject/sdk/spec/util/UtilsTest.java @@ -68,7 +68,7 @@ void testValidateTenant_pathTraversal_throws() { Utils.buildBaseUrl(iface, "../../admin"); }); assertNotNull(ex.getMessage()); - assertEquals("Tenant path contains invalid '..' sequence (path traversal attempt)", ex.getMessage()); + assertEquals("Tenant contains invalid characters. Only a-zA-Z0-9_-. are allowed", ex.getMessage()); } @Test @@ -78,98 +78,116 @@ void testValidateTenant_pathTraversalWithSlash_throws() { Utils.buildBaseUrl(iface, "/../admin"); }); assertNotNull(ex.getMessage()); - assertEquals("Tenant path contains invalid '..' sequence (path traversal attempt)", ex.getMessage()); + assertEquals("Tenant contains invalid characters. Only a-zA-Z0-9_-. are allowed", ex.getMessage()); } @Test void testValidateTenant_tooLong_throws() { - String longTenant = "/" + "a".repeat(256); + String longTenant = "a".repeat(257); IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> { AgentInterface iface = new AgentInterface("JSONRPC", "http://example.com", ""); Utils.buildBaseUrl(iface, longTenant); }); assertNotNull(ex.getMessage()); - assertEquals("Tenant path exceeds maximum length of 256 characters", ex.getMessage()); + assertEquals("Tenant exceeds maximum length of 256 characters", ex.getMessage()); } @Test void testValidateTenant_maxLengthAllowed_succeeds() { - // 256 characters total (including leading slash) AgentInterface iface = new AgentInterface("JSONRPC", "http://example.com", ""); - String maxTenant = "/" + "a".repeat(255); + String maxTenant = "a".repeat(256); String url = Utils.buildBaseUrl(iface, maxTenant); assertNotNull(url); - assertEquals("http://example.com/" + "a".repeat(255), url); + assertEquals("http://example.com/" + "a".repeat(256), url); } @Test void testValidateTenant_invalidCharactersSpace_throws() { IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> { AgentInterface iface = new AgentInterface("JSONRPC", "http://example.com", ""); - Utils.buildBaseUrl(iface, "/tenant with spaces"); + Utils.buildBaseUrl(iface, "tenant with spaces"); }); assertNotNull(ex.getMessage()); - assertEquals("Tenant path contains invalid characters. Only /a-zA-Z0-9_-. are allowed", ex.getMessage()); + assertEquals("Tenant contains invalid characters. Only a-zA-Z0-9_-. are allowed", ex.getMessage()); } @Test void testValidateTenant_invalidCharactersSpecial_throws() { IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> { AgentInterface iface = new AgentInterface("JSONRPC", "http://example.com", ""); - Utils.buildBaseUrl(iface, "/tenant@123"); + Utils.buildBaseUrl(iface, "tenant@123"); }); assertNotNull(ex.getMessage()); - assertEquals("Tenant path contains invalid characters. Only /a-zA-Z0-9_-. are allowed", ex.getMessage()); + assertEquals("Tenant contains invalid characters. Only a-zA-Z0-9_-. are allowed", ex.getMessage()); } @Test void testValidateTenant_invalidCharactersQuery_throws() { IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> { AgentInterface iface = new AgentInterface("JSONRPC", "http://example.com", ""); - Utils.buildBaseUrl(iface, "/tenant?param=value"); + Utils.buildBaseUrl(iface, "tenant?param=value"); }); assertNotNull(ex.getMessage()); - assertEquals("Tenant path contains invalid characters. Only /a-zA-Z0-9_-. are allowed", ex.getMessage()); + assertEquals("Tenant contains invalid characters. Only a-zA-Z0-9_-. are allowed", ex.getMessage()); + } + + @Test + void testValidateTenant_slashInTenant_throws() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> { + AgentInterface iface = new AgentInterface("JSONRPC", "http://example.com", ""); + Utils.buildBaseUrl(iface, "multi/level/tenant"); + }); + assertNotNull(ex.getMessage()); + assertEquals("Tenant contains invalid characters. Only a-zA-Z0-9_-. are allowed", ex.getMessage()); + } + + @Test + void testValidateTenant_leadingSlashMultiSegment_throws() { + assertThrows(IllegalArgumentException.class, () -> { + Utils.validateTenant("/multi/level"); + }); + } + + @Test + void testValidateTenant_intermediateSegments_throws() { + assertThrows(IllegalArgumentException.class, () -> { + Utils.validateTenant("tenant/api/v1"); + }); + } + + @Test + void testValidateTenant_trailingSlashMultiSegment_throws() { + assertThrows(IllegalArgumentException.class, () -> { + Utils.validateTenant("multi/level/"); + }); } @Test void testValidateTenant_validCharacters_succeeds() { - // Test all allowed characters: /a-zA-Z0-9_-. AgentInterface iface = new AgentInterface("JSONRPC", "http://example.com", ""); - String url1 = Utils.buildBaseUrl(iface, "/tenant-name"); + String url1 = Utils.buildBaseUrl(iface, "tenant-name"); assertEquals("http://example.com/tenant-name", url1); - String url2 = Utils.buildBaseUrl(iface, "/tenant_name"); + String url2 = Utils.buildBaseUrl(iface, "tenant_name"); assertEquals("http://example.com/tenant_name", url2); - String url3 = Utils.buildBaseUrl(iface, "/Tenant123"); + String url3 = Utils.buildBaseUrl(iface, "Tenant123"); assertEquals("http://example.com/Tenant123", url3); - String url4 = Utils.buildBaseUrl(iface, "/multi/level/tenant"); - assertEquals("http://example.com/multi/level/tenant", url4); + String url4 = Utils.buildBaseUrl(iface, "tenant.v1"); + assertEquals("http://example.com/tenant.v1", url4); - String url5 = Utils.buildBaseUrl(iface, "/tenant.v1"); - assertEquals("http://example.com/tenant.v1", url5); - - String url6 = Utils.buildBaseUrl(iface, "/.well-known"); - assertEquals("http://example.com/.well-known", url6); + String url5 = Utils.buildBaseUrl(iface, ".well-known"); + assertEquals("http://example.com/.well-known", url5); } @Test void testValidateTenant_emptyString_succeeds() { - // Empty string is valid (no tenant) AgentInterface iface = new AgentInterface("JSONRPC", "http://example.com", ""); String url = Utils.buildBaseUrl(iface, ""); assertEquals("http://example.com", url); } - @Test - void testValidateTenant_multiLevelTenant_succeeds() { - AgentInterface iface = new AgentInterface("JSONRPC", "http://example.com", ""); - String url = Utils.buildBaseUrl(iface, "/org/team/tenant"); - assertEquals("http://example.com/org/team/tenant", url); - } - // ========== Edge Case Tests ========== @Test @@ -194,6 +212,55 @@ void testBuildBaseUrl_httpsUrl() { assertEquals("https://secure.example.com/tenant", url); } + // ========== Tenant deduplication Tests ========== + + @Test + void testBuildBaseUrl_urlAlreadyContainsTenant_noDoubling() { + AgentInterface iface = new AgentInterface("HTTP+JSON", "http://example.com/acme"); + String url = Utils.buildBaseUrl(iface, "acme"); + assertEquals("http://example.com/acme", url); + } + + @Test + void testBuildBaseUrl_urlAlreadyContainsTenantWithSlash_noDoubling() { + AgentInterface iface = new AgentInterface("HTTP+JSON", "http://example.com/acme"); + String url = Utils.buildBaseUrl(iface, "/acme"); + assertEquals("http://example.com/acme", url); + } + + @Test + void testBuildBaseUrl_urlAlreadyContainsTenantWithTrailingSlash_noDoubling() { + AgentInterface iface = new AgentInterface("HTTP+JSON", "http://example.com/acme/"); + String url = Utils.buildBaseUrl(iface, "acme"); + assertEquals("http://example.com/acme", url); + } + + @Test + void testBuildBaseUrl_urlContainsDifferentTenant_appends() { + AgentInterface iface = new AgentInterface("HTTP+JSON", "http://example.com/acme"); + String url = Utils.buildBaseUrl(iface, "beta"); + assertEquals("http://example.com/acme/beta", url); + } + + @Test + void testBuildBaseUrl_tenantSubstringOfPathSegment_noFalsePositive() { + // Tenant "/lic" must not match the end of "/public" — extractTenant normalizes + // the tenant to start with "/" so endsWith checks a full segment boundary. + AgentInterface iface = new AgentInterface("HTTP+JSON", "http://example.com/public"); + String url = Utils.buildBaseUrl(iface, "lic"); + assertEquals("http://example.com/public/lic", url); + } + + @Test + void testBuildBaseUrl_string_urlAlreadyContainsTenant_noDoubling() { + assertEquals("http://example.com/acme", Utils.buildBaseUrl("http://example.com/acme", "acme")); + } + + @Test + void testBuildBaseUrl_string_urlAlreadyContainsTenantWithTrailingSlash_noDoubling() { + assertEquals("http://example.com/acme", Utils.buildBaseUrl("http://example.com/acme/", "acme")); + } + // ========== buildBaseUrl(String, String) Tests ========== @Test diff --git a/tests/multitenancy/pom.xml b/tests/multitenancy/pom.xml new file mode 100644 index 000000000..54aa35ea9 --- /dev/null +++ b/tests/multitenancy/pom.xml @@ -0,0 +1,45 @@ + + + 4.0.0 + + + org.a2aproject.sdk + a2a-java-sdk-parent + 1.2.1.Final-SNAPSHOT + ../../pom.xml + + + a2a-java-sdk-tests-multitenancy + + jar + + Java A2A SDK Tests Multitenancy + Weld SE unit tests for CDI-based multitenancy routers + + + true + + + + + ${project.groupId} + a2a-java-extras-multitenancy + + + ${project.groupId} + a2a-java-sdk-server-common + + + org.junit.jupiter + junit-jupiter-api + test + + + org.jboss.weld.se + weld-se-core + test + + + diff --git a/tests/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouterTest.java b/tests/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouterTest.java new file mode 100644 index 000000000..f683d1f8a --- /dev/null +++ b/tests/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouterTest.java @@ -0,0 +1,174 @@ +package org.a2aproject.sdk.extras.multitenancy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Collections; +import java.util.List; + +import jakarta.enterprise.inject.Produces; +import jakarta.enterprise.inject.se.SeContainer; +import jakarta.enterprise.inject.se.SeContainerInitializer; + +import org.a2aproject.sdk.server.ExtendedAgentCard; +import org.a2aproject.sdk.server.PublicAgentCard; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class CdiAgentCardRouterTest { + + private SeContainer container; + + private void startContainer(Class... beanClasses) { + SeContainerInitializer initializer = SeContainerInitializer.newInstance() + .disableDiscovery() + .addBeanClasses(CdiAgentCardRouter.class); + for (Class beanClass : beanClasses) { + initializer.addBeanClasses(beanClass); + } + container = initializer.initialize(); + } + + @AfterEach + void closeContainer() { + if (container != null) { + container.close(); + } + } + + @Test + void knownTenantResolvesToTenantSpecificCard() { + startContainer(DefaultAndAcmeCardProducer.class); + CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); + assertEquals("acme-extended", router.resolveExtendedCard("acme").name()); + } + + @Test + void unknownTenantFallsBackToDefaultCard() { + startContainer(DefaultAndAcmeCardProducer.class); + CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); + assertEquals("default-extended", router.resolveExtendedCard("unknown").name()); + } + + @Test + void nullTenantReturnsDefaultCard() { + startContainer(DefaultAndAcmeCardProducer.class); + CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); + assertEquals("default-extended", router.resolveExtendedCard(null).name()); + } + + @Test + void blankTenantReturnsDefaultCard() { + startContainer(DefaultAndAcmeCardProducer.class); + CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); + assertEquals("default-extended", router.resolveExtendedCard("").name()); + assertEquals("default-extended", router.resolveExtendedCard(" ").name()); + } + + @Test + void noDefaultCardReturnsNull() { + startContainer(TenantOnlyCardProducer.class); + CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); + assertNull(router.resolveExtendedCard(null)); + assertNull(router.resolveExtendedCard("unknown")); + } + + @Test + void publicCardKnownTenantResolvesToTenantSpecific() { + startContainer(FullCardProducer.class); + CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); + assertEquals("acme-public", router.resolvePublicCard("acme").name()); + } + + @Test + void publicCardUnknownTenantFallsBackToDefault() { + startContainer(FullCardProducer.class); + CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); + assertEquals("default-public", router.resolvePublicCard("unknown").name()); + } + + @Test + void publicCardNullTenantReturnsDefault() { + startContainer(FullCardProducer.class); + CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); + assertEquals("default-public", router.resolvePublicCard(null).name()); + } + + @Test + void publicCardNoDefaultReturnsNull() { + startContainer(TenantOnlyCardProducer.class); + CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); + assertNull(router.resolvePublicCard(null)); + assertNull(router.resolvePublicCard("unknown")); + } + + private static AgentCard buildCard(String name) { + return AgentCard.builder() + .name(name) + .description(name) + .version("1.0.0") + .supportedInterfaces(Collections.singletonList(new AgentInterface("jsonrpc", "http://localhost:8080"))) + .capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .build(); + } + + static class DefaultAndAcmeCardProducer { + + @Produces + @ExtendedAgentCard + AgentCard defaultExtendedCard() { + return buildCard("default-extended"); + } + + @Produces + @Tenant("acme") + @ExtendedAgentCard + AgentCard acmeExtendedCard() { + return buildCard("acme-extended"); + } + } + + static class TenantOnlyCardProducer { + + @Produces + @Tenant("acme") + @ExtendedAgentCard + AgentCard acmeExtendedCard() { + return buildCard("acme-extended"); + } + } + + static class FullCardProducer { + + @Produces + @PublicAgentCard + AgentCard defaultPublicCard() { + return buildCard("default-public"); + } + + @Produces + @Tenant("acme") + AgentCard acmePublicCard() { + return buildCard("acme-public"); + } + + @Produces + @ExtendedAgentCard + AgentCard defaultExtendedCard() { + return buildCard("default-extended"); + } + + @Produces + @Tenant("acme") + @ExtendedAgentCard + AgentCard acmeExtendedCard() { + return buildCard("acme-extended"); + } + } +} diff --git a/tests/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouterTest.java b/tests/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouterTest.java new file mode 100644 index 000000000..1e7c78157 --- /dev/null +++ b/tests/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouterTest.java @@ -0,0 +1,99 @@ +package org.a2aproject.sdk.extras.multitenancy; + +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.util.List; + +import jakarta.enterprise.inject.Produces; +import jakarta.enterprise.inject.se.SeContainer; +import jakarta.enterprise.inject.se.SeContainerInitializer; + +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.TextPart; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class CdiAgentExecutorRouterTest { + + private SeContainer container; + + @BeforeEach + void startContainer() { + container = SeContainerInitializer.newInstance() + .disableDiscovery() + .addBeanClasses(CdiAgentExecutorRouter.class, ExecutorProducer.class) + .initialize(); + } + + @AfterEach + void closeContainer() { + container.close(); + } + + @Test + void knownTenantResolvesToTenantSpecificExecutor() { + CdiAgentExecutorRouter router = container.select(CdiAgentExecutorRouter.class).get(); + AgentExecutor resolved = router.resolve("acme"); + assertSame(ExecutorProducer.ACME, resolved); + } + + @Test + void unknownTenantFallsBackToDefault() { + CdiAgentExecutorRouter router = container.select(CdiAgentExecutorRouter.class).get(); + assertSame(ExecutorProducer.DEFAULT, router.resolve("unknown")); + } + + @Test + void nullTenantReturnsDefault() { + CdiAgentExecutorRouter router = container.select(CdiAgentExecutorRouter.class).get(); + assertSame(ExecutorProducer.DEFAULT, router.resolve(null)); + } + + @Test + void blankTenantReturnsDefault() { + CdiAgentExecutorRouter router = container.select(CdiAgentExecutorRouter.class).get(); + assertSame(ExecutorProducer.DEFAULT, router.resolve("")); + assertSame(ExecutorProducer.DEFAULT, router.resolve(" ")); + } + + static class ExecutorProducer { + + static final AgentExecutor DEFAULT = new LabelExecutor("default"); + static final AgentExecutor ACME = new LabelExecutor("acme"); + + @Produces + AgentExecutor defaultExecutor() { + return DEFAULT; + } + + @Produces + @Tenant("acme") + AgentExecutor acmeExecutor() { + return ACME; + } + } + + static class LabelExecutor implements AgentExecutor { + private final String label; + + LabelExecutor(String label) { + this.label = label; + } + + @Override + public void execute(RequestContext context, AgentEmitter emitter) throws A2AError { + emitter.startWork(); + emitter.addArtifact(List.of(new TextPart(label))); + emitter.complete(); + } + + @Override + public void cancel(RequestContext context, AgentEmitter emitter) throws A2AError { + emitter.cancel(); + } + } +} diff --git a/transport/grpc/src/main/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandler.java b/transport/grpc/src/main/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandler.java index 6eb84607b..3e132bc09 100644 --- a/transport/grpc/src/main/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandler.java +++ b/transport/grpc/src/main/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandler.java @@ -29,6 +29,7 @@ import org.a2aproject.sdk.server.auth.UnauthenticatedUser; import org.a2aproject.sdk.server.auth.User; import org.a2aproject.sdk.server.extensions.A2AExtensions; +import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; import org.a2aproject.sdk.server.requesthandlers.RequestHandler; import org.a2aproject.sdk.server.version.A2AVersionValidator; import org.a2aproject.sdk.spec.A2AError; @@ -62,6 +63,7 @@ import org.a2aproject.sdk.spec.A2AErrorCodes; import org.a2aproject.sdk.spec.UnsupportedOperationError; import org.a2aproject.sdk.spec.VersionNotSupportedError; +import org.a2aproject.sdk.spec.util.Utils; import org.a2aproject.sdk.transport.grpc.context.GrpcContextKeys; import io.grpc.Context; import io.grpc.Metadata; @@ -563,12 +565,22 @@ private void completeStream() { public void getExtendedAgentCard(org.a2aproject.sdk.grpc.GetExtendedAgentCardRequest request, StreamObserver responseObserver) { try { - if (!getAgentCard().capabilities().extendedAgentCard()) { + if (!resolveAgentCard().capabilities().extendedAgentCard()) { handleError(responseObserver, new UnsupportedOperationError()); return; } - ServerCallContext context = createCallContext(responseObserver); - AgentCard extendedAgentCard = getExtendedAgentCard(); + // Removing this 2A causes protocol version validation and required extensions validation to no longer run for gRPC getExtendedAgentCard requests. + //This violates Section 3.6.2 of the A2A spec which requires version checking on every request. + createCallContext(responseObserver); + String tenant = request.getTenant().isBlank() ? null : request.getTenant(); + Utils.validateTenant(tenant); + AgentCardRouter router = getAgentCardRouter(); + AgentCard extendedAgentCard; + if (router != null) { + extendedAgentCard = router.resolveExtendedCard(tenant); + } else { + extendedAgentCard = getExtendedAgentCard(); + } if (extendedAgentCard != null) { responseObserver.onNext(ToProto.agentCard(extendedAgentCard)); responseObserver.onCompleted(); @@ -917,6 +929,16 @@ public static void setStreamingSubscribedRunnable(Runnable runnable) { */ protected abstract Executor getExecutor(); + /** + * Returns the optional agent card router for tenant-based resolution, or {@code null}. + * Subclasses may override to provide a router implementation. + * + * @return the agent card router, or {@code null} if multitenancy is not configured + */ + protected @Nullable AgentCardRouter getAgentCardRouter() { + return null; + } + /** * Attempts to extract the A2A-Version header from the current gRPC context. * This will only work if a server interceptor has been configured to capture diff --git a/transport/grpc/src/test/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandlerTest.java b/transport/grpc/src/test/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandlerTest.java index 974d99e00..92068204d 100644 --- a/transport/grpc/src/test/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandlerTest.java +++ b/transport/grpc/src/test/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandlerTest.java @@ -42,6 +42,7 @@ import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.auth.UnauthenticatedUser; import org.a2aproject.sdk.server.events.EventConsumer; +import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; import org.a2aproject.sdk.server.requesthandlers.AbstractA2ARequestHandlerTest; import org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler; import org.a2aproject.sdk.server.requesthandlers.RequestHandler; @@ -809,9 +810,65 @@ public void testDeletePushNotificationConfigNoPushConfigStore() { assertGrpcError(streamRecorder, Status.Code.UNIMPLEMENTED); } - @Disabled - public void testOnGetExtendedAgentCard() throws Exception { - // TODO - getting the authenticated extended agent card isn't supported for gRPC right now + @Test + public void testExtendedAgentCardWithRouterKnownTenant() throws Exception { + AgentCard cardWithExtCapability = AgentCard.builder(AbstractA2ARequestHandlerTest.CARD) + .capabilities(AgentCapabilities.builder().extendedAgentCard(true).build()).build(); + AgentCard tenantCard = AgentCard.builder(cardWithExtCapability).name("acme-card").build(); + AgentCardRouter router = tenant -> "acme".equals(tenant) ? tenantCard : cardWithExtCapability; + + GrpcHandler handler = new TestGrpcHandler(cardWithExtCapability, requestHandler, internalExecutor) { + @Override + protected AgentCardRouter getAgentCardRouter() { + return router; + } + }; + + org.a2aproject.sdk.grpc.GetExtendedAgentCardRequest request = + org.a2aproject.sdk.grpc.GetExtendedAgentCardRequest.newBuilder().setTenant("acme").build(); + StreamRecorder recorder = StreamRecorder.create(); + handler.getExtendedAgentCard(request, recorder); + + Assertions.assertNull(recorder.getError()); + assertEquals(1, recorder.getValues().size()); + assertEquals("acme-card", recorder.getValues().get(0).getName()); + } + + @Test + public void testExtendedAgentCardWithRouterReturnsNull() throws Exception { + AgentCard cardWithExtCapability = AgentCard.builder(AbstractA2ARequestHandlerTest.CARD) + .capabilities(AgentCapabilities.builder().extendedAgentCard(true).build()).build(); + AgentCardRouter router = tenant -> null; + + GrpcHandler handler = new TestGrpcHandler(cardWithExtCapability, requestHandler, internalExecutor) { + @Override + protected AgentCardRouter getAgentCardRouter() { + return router; + } + }; + + org.a2aproject.sdk.grpc.GetExtendedAgentCardRequest request = + org.a2aproject.sdk.grpc.GetExtendedAgentCardRequest.newBuilder().setTenant("acme").build(); + StreamRecorder recorder = StreamRecorder.create(); + handler.getExtendedAgentCard(request, recorder); + + assertGrpcError(recorder, Status.Code.FAILED_PRECONDITION); + } + + @Test + public void testExtendedAgentCardWithoutRouter() throws Exception { + AgentCard cardWithExtCapability = AgentCard.builder(AbstractA2ARequestHandlerTest.CARD) + .capabilities(AgentCapabilities.builder().extendedAgentCard(true).build()).build(); + + GrpcHandler handler = new TestGrpcHandler(cardWithExtCapability, requestHandler, internalExecutor); + + org.a2aproject.sdk.grpc.GetExtendedAgentCardRequest request = + org.a2aproject.sdk.grpc.GetExtendedAgentCardRequest.newBuilder().build(); + StreamRecorder recorder = StreamRecorder.create(); + handler.getExtendedAgentCard(request, recorder); + + Assertions.assertNull(recorder.getError()); + assertEquals(1, recorder.getValues().size()); } @Test diff --git a/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java b/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java index 44dc6c1b7..77d8d7686 100644 --- a/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java +++ b/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java @@ -10,6 +10,7 @@ import java.util.logging.Logger; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Any; import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; @@ -43,6 +44,8 @@ import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.auth.TaskOperation; import org.a2aproject.sdk.server.extensions.A2AExtensions; +import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; +import org.a2aproject.sdk.server.util.CdiUtils; import org.a2aproject.sdk.server.requesthandlers.RequestHandler; import org.a2aproject.sdk.server.util.async.Internal; import org.a2aproject.sdk.server.version.A2AVersionValidator; @@ -59,6 +62,7 @@ import org.a2aproject.sdk.spec.TaskNotFoundError; import org.a2aproject.sdk.spec.TaskPushNotificationConfig; import org.a2aproject.sdk.spec.UnsupportedOperationError; +import org.a2aproject.sdk.spec.util.Utils; import org.jspecify.annotations.Nullable; /** @@ -146,6 +150,8 @@ public class JSONRPCHandler { private Executor executor; private final AtomicBoolean transportValidated = new AtomicBoolean(false); + private @Nullable AgentCardRouter agentCardRouter; + /** * No-args constructor for CDI proxy creation. * CDI requires a non-private constructor to create proxies for @ApplicationScoped beans. @@ -167,15 +173,18 @@ protected JSONRPCHandler() { * @param extendedAgentCard optional extended agent card instance * @param requestHandler the handler for processing A2A requests * @param executor the executor for asynchronous operations + * @param agentCardRouterInstance optional agent card router instance for multitenancy */ @Inject public JSONRPCHandler(@PublicAgentCard Instance agentCardInstance, @Nullable @ExtendedAgentCard Instance extendedAgentCard, - RequestHandler requestHandler, @Internal Executor executor) { + RequestHandler requestHandler, @Internal Executor executor, + @Any @Nullable Instance agentCardRouterInstance) { this.agentCardInstance = agentCardInstance; this.extendedAgentCard = extendedAgentCard; this.requestHandler = requestHandler; this.executor = executor; + this.agentCardRouter = CdiUtils.getIfResolvable(agentCardRouterInstance); } /** @@ -186,7 +195,7 @@ public JSONRPCHandler(@PublicAgentCard Instance agentCardInstance, * @param executor the executor for asynchronous operations */ public JSONRPCHandler(@PublicAgentCard AgentCard agentCard, RequestHandler requestHandler, Executor executor) { - this(new FixedInstance<>(agentCard), null, requestHandler, executor); + this(new FixedInstance<>(agentCard), null, requestHandler, executor, null); } /** @@ -682,12 +691,21 @@ public GetExtendedAgentCardResponse onGetExtendedCardRequest( if (!resolveAgentCard().capabilities().extendedAgentCard()) { return new GetExtendedAgentCardResponse(request.getId(), new UnsupportedOperationError()); } - if (extendedAgentCard == null || !extendedAgentCard.isResolvable()) { - return new GetExtendedAgentCardResponse(request.getId(), - new ExtendedAgentCardNotConfiguredError(null, "Extended Card not configured", null)); - } try { validateVersionAndExtensions(context); + String tenant = request.getParams() != null ? request.getParams().tenant() : null; + if (agentCardRouter != null) { + AgentCard card = agentCardRouter.resolveExtendedCard(tenant); + if (card == null) { + return new GetExtendedAgentCardResponse(request.getId(), + new ExtendedAgentCardNotConfiguredError(null, "Extended Card not configured", null)); + } + return new GetExtendedAgentCardResponse(request.getId(), card); + } + if (extendedAgentCard == null || !extendedAgentCard.isResolvable()) { + return new GetExtendedAgentCardResponse(request.getId(), + new ExtendedAgentCardNotConfiguredError(null, "Extended Card not configured", null)); + } return new GetExtendedAgentCardResponse(request.getId(), extendedAgentCard.get()); } catch (A2AError e) { return new GetExtendedAgentCardResponse(request.getId(), e); @@ -722,6 +740,27 @@ private void validateVersionAndExtensions(ServerCallContext context) throws A2AE * @see AgentCard */ public AgentCard getAgentCard() { + return getAgentCard(null); + } + + /** + * Returns the public agent card, optionally for a specific tenant. + *

+ * When a tenant is specified and an {@link AgentCardRouter} is available, the router + * resolves a tenant-specific public card. Falls back to the default public card + * if no tenant-specific card is configured. + * + * @param tenant the tenant identifier, may be {@code null} + * @return the public agent card + */ + public AgentCard getAgentCard(@Nullable String tenant) { + Utils.validateTenant(tenant); + if (agentCardRouter != null && tenant != null && !tenant.isBlank()) { + AgentCard card = agentCardRouter.resolvePublicCard(tenant); + if (card != null) { + return card; + } + } return resolveAgentCard(); } diff --git a/transport/jsonrpc/src/test/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandlerTest.java b/transport/jsonrpc/src/test/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandlerTest.java index 04c58453d..179f2da2a 100644 --- a/transport/jsonrpc/src/test/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandlerTest.java +++ b/transport/jsonrpc/src/test/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandlerTest.java @@ -52,6 +52,7 @@ import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.auth.UnauthenticatedUser; import org.a2aproject.sdk.server.events.EventConsumer; +import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; import org.a2aproject.sdk.server.requesthandlers.AbstractA2ARequestHandlerTest; import org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler; import org.a2aproject.sdk.server.tasks.ResultAggregator; @@ -66,6 +67,7 @@ import org.a2aproject.sdk.spec.Event; import org.a2aproject.sdk.spec.ExtendedAgentCardNotConfiguredError; import org.a2aproject.sdk.spec.ExtensionSupportRequiredError; +import org.a2aproject.sdk.spec.GetExtendedAgentCardParams; import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; import org.a2aproject.sdk.spec.InternalError; import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; @@ -1576,6 +1578,64 @@ public void testOnGetExtendedAgentCard() throws Exception { assertNull(response.getResult()); } + @Test + public void testExtendedAgentCardWithRouterKnownTenant() throws Exception { + AgentCard cardWithExtCapability = AgentCard.builder(CARD) + .capabilities(AgentCapabilities.builder().extendedAgentCard(true).build()).build(); + AgentCard tenantCard = AgentCard.builder(cardWithExtCapability).name("acme-card").build(); + AgentCardRouter router = tenant -> "acme".equals(tenant) ? tenantCard : cardWithExtCapability; + + JSONRPCHandler handler = new JSONRPCHandler(new FixedInstance<>(cardWithExtCapability), null, + requestHandler, internalExecutor, new FixedInstance<>(router)); + + GetExtendedAgentCardRequest request = GetExtendedAgentCardRequest.builder() + .id("1") + .params(new GetExtendedAgentCardParams("acme")) + .build(); + GetExtendedAgentCardResponse response = handler.onGetExtendedCardRequest(request, callContext); + + assertNull(response.getError()); + assertNotNull(response.getResult()); + assertEquals("acme-card", response.getResult().name()); + } + + @Test + public void testExtendedAgentCardWithRouterReturnsNull() throws Exception { + AgentCard cardWithExtCapability = AgentCard.builder(CARD) + .capabilities(AgentCapabilities.builder().extendedAgentCard(true).build()).build(); + AgentCardRouter router = tenant -> null; + + JSONRPCHandler handler = new JSONRPCHandler(new FixedInstance<>(cardWithExtCapability), null, + requestHandler, internalExecutor, new FixedInstance<>(router)); + + GetExtendedAgentCardRequest request = GetExtendedAgentCardRequest.builder() + .id("1") + .params(new GetExtendedAgentCardParams("acme")) + .build(); + GetExtendedAgentCardResponse response = handler.onGetExtendedCardRequest(request, callContext); + + assertInstanceOf(ExtendedAgentCardNotConfiguredError.class, response.getError()); + assertNull(response.getResult()); + } + + @Test + public void testExtendedAgentCardWithoutRouter() throws Exception { + AgentCard cardWithExtCapability = AgentCard.builder(CARD) + .capabilities(AgentCapabilities.builder().extendedAgentCard(true).build()).build(); + AgentCard extended = AgentCard.builder(cardWithExtCapability).description("extended").build(); + Instance extendedInstance = new FixedInstance<>(extended); + + JSONRPCHandler handler = new JSONRPCHandler( + new FixedInstance<>(cardWithExtCapability), extendedInstance, requestHandler, internalExecutor, null); + + GetExtendedAgentCardRequest request = new GetExtendedAgentCardRequest("1"); + GetExtendedAgentCardResponse response = handler.onGetExtendedCardRequest(request, callContext); + + assertNull(response.getError()); + assertNotNull(response.getResult()); + assertEquals("extended", response.getResult().description()); + } + @Test public void testStreamingDoesNotBlockMainThread() throws Exception { JSONRPCHandler handler = new JSONRPCHandler(CARD, requestHandler, internalExecutor); @@ -2039,7 +2099,7 @@ public void testListTasksEmptyResultIncludesAllFields() { void constructorDoesNotResolveAgentCardInstances() { Instance throwOnGet = TestInstances.throwOnGet(); - assertDoesNotThrow(() -> new JSONRPCHandler(throwOnGet, null, requestHandler, internalExecutor)); + assertDoesNotThrow(() -> new JSONRPCHandler(throwOnGet, null, requestHandler, internalExecutor, null)); } @Test @@ -2150,7 +2210,7 @@ public void testVersionNotSupportedErrorOnGetExtendedCard() throws Exception { Instance extendedInstance = new FixedInstance<>(extended); JSONRPCHandler handler = new JSONRPCHandler( - new FixedInstance<>(versionTestCard()), extendedInstance, requestHandler, internalExecutor); + new FixedInstance<>(versionTestCard()), extendedInstance, requestHandler, internalExecutor, null); GetExtendedAgentCardResponse response = handler.onGetExtendedCardRequest(new GetExtendedAgentCardRequest("1"), incompatibleVersionContext()); diff --git a/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java b/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java index 6ea53f765..282882448 100644 --- a/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java +++ b/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java @@ -18,6 +18,7 @@ import java.util.stream.Collectors; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Any; import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; @@ -39,6 +40,8 @@ import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.auth.TaskOperation; import org.a2aproject.sdk.server.extensions.A2AExtensions; +import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; +import org.a2aproject.sdk.server.util.CdiUtils; import org.a2aproject.sdk.server.requesthandlers.RequestHandler; import org.a2aproject.sdk.server.util.async.Internal; import org.a2aproject.sdk.server.version.A2AVersionValidator; @@ -68,6 +71,7 @@ import org.a2aproject.sdk.spec.TaskState; import org.a2aproject.sdk.spec.UnsupportedOperationError; import org.a2aproject.sdk.spec.util.ErrorDetail; +import org.a2aproject.sdk.spec.util.Utils; import org.jspecify.annotations.Nullable; /** @@ -133,6 +137,8 @@ public class RestHandler { private Executor executor; private final AtomicBoolean transportValidated = new AtomicBoolean(false); + private @Nullable AgentCardRouter agentCardRouter; + /** * No-args constructor for CDI proxy creation. * CDI requires a non-private constructor to create proxies for @ApplicationScoped beans. @@ -152,16 +158,19 @@ protected RestHandler() { * @param cacheMetadata the agent card caching metadata * @param requestHandler the handler for processing A2A requests * @param executor the executor for asynchronous operations + * @param agentCardRouterInstance optional agent card router instance for multitenancy */ @Inject public RestHandler(@PublicAgentCard Instance agentCardInstance, @ExtendedAgentCard Instance extendedAgentCard, - AgentCardCacheMetadata cacheMetadata, RequestHandler requestHandler, @Internal Executor executor) { + AgentCardCacheMetadata cacheMetadata, RequestHandler requestHandler, @Internal Executor executor, + @Any @Nullable Instance agentCardRouterInstance) { this.agentCardInstance = agentCardInstance; this.extendedAgentCard = extendedAgentCard; this.cacheMetadata = cacheMetadata; this.requestHandler = requestHandler; this.executor = executor; + this.agentCardRouter = CdiUtils.getIfResolvable(agentCardRouterInstance); } /** @@ -828,21 +837,30 @@ private static int mapErrorToHttpStatus(A2AError error) { * } * * @param context the server call context containing authentication and metadata - * @param tenant the tenant identifier + * @param tenant the tenant identifier, or {@code null} for the default tenant * @return the HTTP response containing the extended agent card * @throws ExtendedAgentCardNotConfiguredError if extended agent card is not available * @see #getAgentCard() * @see AgentCard */ - public HTTPRestResponse getExtendedAgentCard(ServerCallContext context, String tenant) { + public HTTPRestResponse getExtendedAgentCard(ServerCallContext context, @Nullable String tenant) { try { + Utils.validateTenant(tenant); if (!resolveAgentCard().capabilities().extendedAgentCard()) { throw new UnsupportedOperationError(); } + // Validate version before card lookup so version errors take precedence + validateVersionAndExtensions(context); + if (agentCardRouter != null) { + AgentCard card = agentCardRouter.resolveExtendedCard(tenant); + if (card == null) { + throw new ExtendedAgentCardNotConfiguredError(null, "Extended Card not configured", null); + } + return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(card)); + } if (extendedAgentCard == null || !extendedAgentCard.isResolvable()) { throw new ExtendedAgentCardNotConfiguredError(null, "Extended Card not configured", null); } - validateVersionAndExtensions(context); return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(extendedAgentCard.get())); } catch (A2AError e) { return createErrorResponse(e); @@ -891,7 +909,29 @@ private AgentCard resolveAgentCard() { } public HTTPRestResponse getAgentCard() { + return getAgentCard(null); + } + + /** + * Retrieves the public agent card, optionally for a specific tenant. + *

+ * When a tenant is specified and an {@link AgentCardRouter} is available, the router + * resolves a tenant-specific public card. Falls back to the default public card + * if no tenant-specific card is configured. + * + * @param tenant the tenant identifier, may be {@code null} + * @return the HTTP response containing the agent card + */ + public HTTPRestResponse getAgentCard(@Nullable String tenant) { try { + Utils.validateTenant(tenant); + if (agentCardRouter != null && tenant != null && !tenant.isBlank()) { + AgentCard card = agentCardRouter.resolvePublicCard(tenant); + if (card != null) { + return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(card), + cacheMetadata.getHttpHeadersMap()); + } + } return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(resolveAgentCard()), cacheMetadata.getHttpHeadersMap()); } catch (Throwable t) { diff --git a/transport/rest/src/test/java/org/a2aproject/sdk/transport/rest/handler/RestHandlerTest.java b/transport/rest/src/test/java/org/a2aproject/sdk/transport/rest/handler/RestHandlerTest.java index 8e1a1d1c9..44a2bff53 100644 --- a/transport/rest/src/test/java/org/a2aproject/sdk/transport/rest/handler/RestHandlerTest.java +++ b/transport/rest/src/test/java/org/a2aproject/sdk/transport/rest/handler/RestHandlerTest.java @@ -24,6 +24,7 @@ import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.auth.UnauthenticatedUser; import org.a2aproject.sdk.server.config.DefaultValuesConfigProvider; +import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; import org.a2aproject.sdk.server.requesthandlers.AbstractA2ARequestHandlerTest; import org.a2aproject.sdk.server.requesthandlers.RequestHandler; import org.a2aproject.sdk.spec.AgentCapabilities; @@ -1086,7 +1087,7 @@ void constructorDoesNotResolveAgentCardInstances() { Instance throwOnGet = TestInstances.throwOnGet(); Assertions.assertDoesNotThrow(() -> new RestHandler(throwOnGet, throwOnGet, - createCacheMetadata(), requestHandler, internalExecutor)); + createCacheMetadata(), requestHandler, internalExecutor, null)); } private static void assertProblemDetail(RestHandler.HTTPRestResponse response, @@ -1216,11 +1217,61 @@ public void testVersionNotSupportedErrorOnGetExtendedAgentCard() { Instance extendedInstance = new FixedInstance<>(extended); RestHandler handler = new RestHandler(new FixedInstance<>(card), extendedInstance, - createCacheMetadata(card), requestHandler, internalExecutor); + createCacheMetadata(card), requestHandler, internalExecutor, null); assertVersionRejected(handler.getExtendedAgentCard(incompatibleVersionContext(), "")); } + @Test + public void testExtendedAgentCardWithRouterKnownTenant() { + AgentCard cardWithExtCapability = AgentCard.builder(CARD) + .capabilities(AgentCapabilities.builder().extendedAgentCard(true).build()).build(); + AgentCard tenantCard = AgentCard.builder(cardWithExtCapability).name("acme-card").build(); + AgentCardRouter router = tenant -> "acme".equals(tenant) ? tenantCard : cardWithExtCapability; + + RestHandler handler = new RestHandler(new FixedInstance<>(cardWithExtCapability), null, + createCacheMetadata(cardWithExtCapability), requestHandler, internalExecutor, + new FixedInstance<>(router)); + + RestHandler.HTTPRestResponse response = handler.getExtendedAgentCard(callContext, "acme"); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals(APPLICATION_JSON, response.getContentType()); + Assertions.assertTrue(response.getBody().contains("acme-card")); + } + + @Test + public void testExtendedAgentCardWithRouterReturnsNull() { + AgentCard cardWithExtCapability = AgentCard.builder(CARD) + .capabilities(AgentCapabilities.builder().extendedAgentCard(true).build()).build(); + AgentCardRouter router = tenant -> null; + + RestHandler handler = new RestHandler(new FixedInstance<>(cardWithExtCapability), null, + createCacheMetadata(cardWithExtCapability), requestHandler, internalExecutor, + new FixedInstance<>(router)); + + RestHandler.HTTPRestResponse response = handler.getExtendedAgentCard(callContext, "acme"); + + assertProblemDetail(response, 400, + "EXTENDED_AGENT_CARD_NOT_CONFIGURED", "Extended Card not configured"); + } + + @Test + public void testExtendedAgentCardWithoutRouter() { + AgentCard cardWithExtCapability = AgentCard.builder(CARD) + .capabilities(AgentCapabilities.builder().extendedAgentCard(true).build()).build(); + AgentCard extended = AgentCard.builder(cardWithExtCapability).description("extended").build(); + Instance extendedInstance = new FixedInstance<>(extended); + + RestHandler handler = new RestHandler(new FixedInstance<>(cardWithExtCapability), extendedInstance, + createCacheMetadata(cardWithExtCapability), requestHandler, internalExecutor, null); + + RestHandler.HTTPRestResponse response = handler.getExtendedAgentCard(callContext, "acme"); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertTrue(response.getBody().contains("extended")); + } + @Test public void testVersionNotSupportedErrorOnSubscribeToTask() throws Exception { RestHandler handler = versionTestHandler();