Chapter 26

Stand Up an A2A Server With a JSON-RPC Binding

An A2A server publishes two things: an Agent Card and a protocol endpoint. In the companion project, Express wires in the official handlers, the task store, and our ReleaseReviewAgentExecutor, and the port is chosen dynamically - so tests are isolated and don't fight over one address.

The full server assembly fits in one function: bring up HTTP, build the card, wire in the card route and the JSON-RPC binding.

TypeScript
export async function startA2AServer(): Promise<RunningA2AServer> {
  const app = express();
  const httpServer = createServer(app);
  httpServer.listen(0, "127.0.0.1");
  await once(httpServer, "listening");

  const address = httpServer.address();
  if (!address || typeof address === "string") {
    await closeServer(httpServer);
    throw new Error("Unable to resolve A2A server address");
  }

  const baseUrl = `http://127.0.0.1:${address.port}`;
  const card = createReleaseAgentCard(baseUrl);
  const taskStore: TaskStore = new InMemoryTaskStore();
  const handler = new DefaultRequestHandler(card, taskStore, new ReleaseReviewAgentExecutor());

  app.get("/docs", (_request, response) => {
    response.type("text/plain").send("Release Review Agent 1.0.0");
  });
  app.use(`/${AGENT_CARD_PATH}`, agentCardHandler({ agentCardProvider: handler }));
  app.use(jsonRpcHandler({ requestHandler: handler, userBuilder: UserBuilder.noAuthentication }));

  return {
    baseUrl,
    card,
    close: () => closeServer(httpServer)
  };
}

And here is how the executor publishes events during the work - from working to completed with a finished artifact in between.

TypeScript
    eventBus.publish(AgentEvent.statusUpdate({
      taskId,
      contextId,
      status: {
        state: TaskState.TASK_STATE_WORKING,
        timestamp: new Date().toISOString(),
        message: agentMessage(`Reviewing ${reference.service}@${reference.version}.`, taskId, contextId)
      },
      metadata: undefined
    }));

    if (this.cancelledTasks.has(taskId)) {
      this.cancelledTasks.delete(taskId);
      return;
    }

    try {
      const review = reviewRelease(reference.service, reference.version);
      const artifact: Artifact = {
        artifactId: randomUUID(),
        name: "release-review.md",
        description: "Deterministic release-readiness report",
        parts: [
          {
            content: { $case: "text", value: reviewToMarkdown(review) },
            metadata: undefined,
            filename: "release-review.md",
            mediaType: "text/markdown"
          },
          {
            content: { $case: "data", value: review },
            metadata: undefined,
            filename: "release-review.json",
            mediaType: "application/json"
          }
        ],
        metadata: { decision: review.decision },
        extensions: []
      };

      eventBus.publish(AgentEvent.artifactUpdate({
        taskId,
        contextId,
        artifact,
        append: false,
        lastChunk: true,
        metadata: undefined
      }));
      eventBus.publish(AgentEvent.statusUpdate({
        taskId,
        contextId,
        status: {
          state: TaskState.TASK_STATE_COMPLETED,
          timestamp: new Date().toISOString(),
          message: undefined
        },
        metadata: undefined
      }));

The middleware order here is a matter of security, not style, and it's worth keeping strict.

  • Edge controls: TLS, body limits, request id, rate limit.
  • A public or controlled route for the Agent Card.
  • Authentication that builds a verified user/principal.
  • The A2A binding handler and capability validation.
  • The request handler, task store, executor.
  • Telemetry and redaction.

Two caveats separate the demo from production. The first is about authentication.

The demo deliberately has no authentication. UserBuilder.noAuthentication is needed only for a localhost integration test. A production Agent Card must declare a security scheme, and the middleware must build a verified principal before any access to tasks.

The second is that the binding is not part of the contract.

The binding can be replaced. A2A's data model and operations are separate from the JSON-RPC, REST, and gRPC bindings. Choose the one both sides support and check supportedInterfaces - the agent isn't tied to one transport.

The server responds - let's write a client that finds it by its card and sends a goal.

Links