Maps {@code GET /.well-known/{tenant}/agent-card.json} to
+ * {@link RestHandler#getAgentCard(String)}.
+ *
+ *
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 extends Annotation> excludeQualifier,
+ @Nullable Class extends Annotation> 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 extends Annotation> 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();