Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,18 @@ name: CI
on:
push:
branches: [main]
paths:
- 'src/**'
- 'tests/**'
- '*.props'
- '*.slnx'
pull_request:
branches: [main]
paths:
- 'src/**'
- 'tests/**'
- '*.props'
- '*.slnx'

jobs:
build-and-test:
Expand Down
74 changes: 74 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
# Build
dotnet build

# Run all tests
dotnet test

# Run a single test project
dotnet test tests/AgentRegistry.Api.Tests/

# Run a specific test by name
dotnet test --filter "FullyQualifiedName~RegistrationTests.Register_WithValidRequest"

# Run the API (requires Postgres + Redis via user secrets)
dotnet run --project src/AgentRegistry.Api

# Add a migration after domain/EF model changes
dotnet ef migrations add <Name> -p src/AgentRegistry.Infrastructure -s src/AgentRegistry.Api

# Apply migrations
dotnet ef database update -p src/AgentRegistry.Infrastructure -s src/AgentRegistry.Api
```

## Architecture

**Four-layer clean architecture.** No layer may reference a layer above it.

```
Domain → Application → Infrastructure
→ Api (references Application, not Infrastructure directly)
```

- **`AgentRegistry.Domain`** — pure value objects and entities (`Agent`, `Endpoint`, `Capability`, `ApiKey`). No external dependencies. IDs are strongly-typed structs wrapping `Guid` (e.g. `AgentId`, `EndpointId`).
- **`AgentRegistry.Application`** — use-case logic in `AgentService`. Defines interfaces `IAgentRepository`, `ILivenessStore`, `IApiKeyService` that Infrastructure implements. Exceptions (`NotFoundException`, `ForbiddenException`) are declared here.
- **`AgentRegistry.Infrastructure`** — EF Core (Postgres via Npgsql) for agent/key persistence; Redis for endpoint liveness TTLs. `AgentRegistryDbContext` maps strongly-typed IDs via `ValueConverter`.
- **`AgentRegistry.Api`** — ASP.NET Core 10 minimal APIs. Composed in `Program.cs`. Protocol adapters live under `Protocols/` (A2A, MCP, ACP), each with a `*Mapper` class and endpoint registration.

## Key design patterns

**Liveness is split across two stores.** Agent identity and capabilities live in Postgres. Endpoint liveness is purely Redis TTL keys (`endpoint:liveness:{endpointId}`). Discovery queries Postgres first, then does a single batched Redis check to filter live endpoints. The two liveness models are:
- `Ephemeral` — TTL expires automatically; agent calls `POST /agents/{id}/endpoints/{eid}/renew` on each invocation.
- `Persistent` — agent calls `POST /agents/{id}/endpoints/{eid}/heartbeat`; grace period is 2.5× `heartbeatIntervalSeconds`.

**Protocol metadata round-trips as raw JSON.** Protocol-specific fields (A2A skill schemas, MCP tool descriptors, ACP content types) are stored in `Endpoint.ProtocolMetadata` as a JSONB column. Mappers serialize in and deserialize out — nothing protocol-specific reaches the domain model.

**Authentication uses a "Smart" policy scheme.** `X-Api-Key` header → `ApiKeyAuthenticationHandler` (validates against Postgres hash, sets `registry_scope` claim). `Authorization: Bearer` → standard `JwtBearer`. The registry does **not** issue JWTs; it validates tokens from an external IdP. Auth policies (`AdminOnly`, `AgentOrAdmin`) in `RegistryPolicies.cs` check `registry_scope` claim or `roles` claim.

**Central Package Management** is enabled (`Directory.Packages.props`). Add `<PackageVersion>` entries there; use `<PackageReference>` without `Version` in project files. `tests/Directory.Build.props` chains to the root and auto-includes `GitHubActionsTestLogger` + enables `UseMicrosoftTestingPlatformRunner` for all test projects.

## Testing

Integration tests use `WebApplicationFactory<Program>` (`AgentRegistryFactory`). Infrastructure dependencies are replaced with in-memory fakes:
- `InMemoryAgentRepository` — thread-safe `ConcurrentDictionary`
- `InMemoryLivenessStore` — in-memory equivalent of Redis TTL store
- `FakeApiKeyService` — exposes `FakeApiKeyService.AdminKey` and `FakeApiKeyService.AgentKey` constants

Use `factory.CreateAdminClient()` or `factory.CreateAgentClient()` to get pre-authenticated `HttpClient` instances. Call `factory.Reset()` in `Dispose()` to clear state between tests. Tests are `IClassFixture<AgentRegistryFactory>`.

Application-layer tests use `Rocks` (source-generator mocks) to mock `IAgentRepository` and `ILivenessStore`.

## Adding a new protocol adapter

1. Add a `Protocols/<Name>/` folder under `AgentRegistry.Api` with:
- `Models/` — request/response records matching the protocol's wire format
- `<Name>AgentManifestMapper.cs` (or equivalent) — bidirectional mapping to/from domain types using `Endpoint.ProtocolMetadata` for protocol-specific fields
- `<Name>Endpoints.cs` — minimal API route registrations with `.WithTags()`, `.WithSummary()`, `.WithDescription()`, `.Produces<T>()`, and `.ProducesProblem()` on every route
2. Call `Map<Name>Endpoints()` from `Program.cs`
3. Add integration tests under `tests/AgentRegistry.Api.Tests/Protocols/<Name>/`
85 changes: 76 additions & 9 deletions src/AgentRegistry.Api/Agents/AgentEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,87 @@ public static class AgentEndpoints
{
public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/agents").RequireAuthorization(RegistryPolicies.AgentOrAdmin);
var group = app.MapGroup("/agents")
.RequireAuthorization(RegistryPolicies.AgentOrAdmin)
.WithTags("Agents");

group.MapPost("/", RegisterAgent).WithName("RegisterAgent");
group.MapGet("/{id}", GetAgent).WithName("GetAgent");
group.MapPut("/{id}", UpdateAgent).WithName("UpdateAgent");
group.MapDelete("/{id}", DeregisterAgent).WithName("DeregisterAgent");
group.MapPost("/", RegisterAgent)
.WithName("RegisterAgent")
.WithSummary("Register an agent")
.WithDescription("Creates a new agent registration with the specified name, capabilities, and endpoints. The caller becomes the owner. Returns the created agent with its assigned ID.")
.Produces<AgentResponse>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status401Unauthorized);

group.MapGet("/{id}", GetAgent)
.WithName("GetAgent")
.WithSummary("Get an agent by ID")
.WithDescription("Returns the agent record including all endpoints and real-time liveness status from Redis. Returns 404 if the agent does not exist.")
.Produces<AgentResponse>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status404NotFound);

group.MapPut("/{id}", UpdateAgent)
.WithName("UpdateAgent")
.WithSummary("Update agent metadata")
.WithDescription("Updates the name, description, and labels of an existing agent. Only the owning principal may update an agent. Capabilities and endpoints are managed separately.")
.Produces<AgentResponse>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status404NotFound);

group.MapDelete("/{id}", DeregisterAgent)
.WithName("DeregisterAgent")
.WithSummary("Deregister an agent")
.WithDescription("Permanently removes the agent and all its endpoints from the registry. Liveness keys in Redis are also deleted. Only the owning principal may deregister.")
.Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status404NotFound);

// Endpoint management
group.MapPost("/{agentId}/endpoints", AddEndpoint).WithName("AddEndpoint");
group.MapDelete("/{agentId}/endpoints/{endpointId}", RemoveEndpoint).WithName("RemoveEndpoint");
group.MapPost("/{agentId}/endpoints", AddEndpoint)
.WithName("AddEndpoint")
.WithSummary("Add an endpoint to an agent")
.WithDescription("Adds a new endpoint to an existing agent, creating a liveness entry in Redis immediately. The endpoint's liveness model (Ephemeral or Persistent) determines how liveness is subsequently maintained.")
.Produces<EndpointResponse>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status404NotFound);

group.MapDelete("/{agentId}/endpoints/{endpointId}", RemoveEndpoint)
.WithName("RemoveEndpoint")
.WithSummary("Remove an endpoint from an agent")
.WithDescription("Removes an endpoint from the agent and deletes its Redis liveness key. The agent itself remains registered. Only the owning principal may remove endpoints.")
.Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status404NotFound);

// Liveness
group.MapPost("/{agentId}/endpoints/{endpointId}/heartbeat", Heartbeat).WithName("Heartbeat");
group.MapPost("/{agentId}/endpoints/{endpointId}/renew", Renew).WithName("RenewEndpoint");
group.MapPost("/{agentId}/endpoints/{endpointId}/heartbeat", Heartbeat)
.WithName("Heartbeat")
.WithSummary("Send a heartbeat for a Persistent endpoint")
.WithDescription("Resets the liveness TTL for a Persistent endpoint. Call this at an interval shorter than the endpoint's heartbeatIntervalSeconds to keep it marked live. Returns 400 if the endpoint uses the Ephemeral liveness model.")
.Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status404NotFound);

group.MapPost("/{agentId}/endpoints/{endpointId}/renew", Renew)
.WithName("RenewEndpoint")
.WithSummary("Renew the TTL for an Ephemeral endpoint")
.WithDescription("Extends the registration TTL for an Ephemeral endpoint back to its configured ttlSeconds. Call this on each invocation (e.g. at the start of a serverless function) to keep the endpoint discoverable. Returns 400 if the endpoint uses the Persistent liveness model.")
.Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status404NotFound);

return app;
}
Expand Down
16 changes: 15 additions & 1 deletion src/AgentRegistry.Api/Agents/DiscoveryEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,21 @@ public static IEndpointRouteBuilder MapDiscoveryEndpoints(this IEndpointRouteBui
// Individual agents may enforce their own auth on their endpoints.
var group = app.MapGroup("/discover");

group.MapGet("/agents", DiscoverAgents).WithName("DiscoverAgents");
group.MapGet("/agents", DiscoverAgents)
.WithName("DiscoverAgents")
.WithTags("Agents")
.WithSummary("Discover registered agents")
.WithDescription(
"Returns a paginated list of agents matching the given filters. By default only agents " +
"with at least one live endpoint in Redis are returned.\n\n" +
"**Filters**\n" +
"- `capability` — match agents that declare a capability with this exact name\n" +
"- `tags` — comma-separated list; agents must match all supplied tags\n" +
"- `protocol` — filter by protocol: `A2A`, `MCP`, `ACP`, or `Generic`\n" +
"- `transport` — filter by transport: `Http`, `AzureServiceBus`, or `Amqp`\n" +
"- `liveOnly` — when `false`, includes agents with no live endpoints (default: `true`)\n" +
"- `page` / `pageSize` — 1-based page number and page size (max 100, default 20)")
.Produces<PagedAgentResponse>(StatusCodes.Status200OK);

return app;
}
Expand Down
46 changes: 41 additions & 5 deletions src/AgentRegistry.Api/ApiKeys/ApiKeyEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,52 @@ public static class ApiKeyEndpoints
{
public static IEndpointRouteBuilder MapApiKeyEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api-keys");
var group = app.MapGroup("/api-keys").WithTags("API Keys");

// All key management is Admin-only.
group.MapPost("/", IssueKey).RequireAuthorization(RegistryPolicies.AdminOnly).WithName("IssueApiKey");
group.MapGet("/", ListKeys).RequireAuthorization(RegistryPolicies.AdminOnly).WithName("ListApiKeys");
group.MapDelete("/{keyId}", RevokeKey).RequireAuthorization(RegistryPolicies.AdminOnly).WithName("RevokeApiKey");
group.MapPost("/", IssueKey)
.RequireAuthorization(RegistryPolicies.AdminOnly)
.WithName("IssueApiKey")
.WithSummary("Issue a new API key")
.WithDescription("Creates a new API key for the authenticated owner. The raw key is returned exactly once in the response and cannot be retrieved again — store it immediately.")
.Produces<IssueApiKeyResponse>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status401Unauthorized);

group.MapGet("/", ListKeys)
.RequireAuthorization(RegistryPolicies.AdminOnly)
.WithName("ListApiKeys")
.WithSummary("List API keys")
.WithDescription("Returns all active and revoked API keys owned by the authenticated principal. Raw key values are never returned.")
.Produces<IEnumerable<ApiKeyResponse>>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status401Unauthorized);

group.MapDelete("/{keyId}", RevokeKey)
.RequireAuthorization(RegistryPolicies.AdminOnly)
.WithName("RevokeApiKey")
.WithSummary("Revoke an API key")
.WithDescription("Marks an API key as revoked. Revoked keys are immediately rejected at authentication. Only the owning principal may revoke their own keys.")
.Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status404NotFound);

// Bootstrap — no auth; dark unless Bootstrap:Token is set in configuration.
// Always issues an Admin-scoped key. Use once to create the first key, then remove the token from config.
group.MapPost("/bootstrap", Bootstrap).WithName("BootstrapApiKey");
group.MapPost("/bootstrap", Bootstrap)
.WithName("BootstrapApiKey")
.WithSummary("Issue the first Admin key (bootstrap)")
.WithDescription(
"Issues an Admin-scoped API key without requiring prior authentication. " +
"This endpoint is only active when `Bootstrap:Token` is set in configuration — it returns 404 otherwise.\n\n" +
"Supply the configured token in the `X-Bootstrap-Token` header. " +
"Use this once to obtain the first Admin key for a new deployment, " +
"then remove `Bootstrap:Token` from configuration to permanently disable the endpoint.\n\n" +
"The raw key is returned exactly once.")
.Produces<IssueApiKeyResponse>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status404NotFound);

return app;
}
Expand Down
19 changes: 16 additions & 3 deletions src/AgentRegistry.Api/Protocols/A2A/A2AEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,31 @@ public static IEndpointRouteBuilder MapA2AEndpoints(this IEndpointRouteBuilder a
// The registry's own agent card — describes the registry as an A2A agent.
app.MapGet("/.well-known/agent.json", GetRegistryCard)
.WithName("A2ARegistryCard")
.WithTags("A2A");
.WithTags("A2A")
.WithSummary("Registry's own A2A agent card")
.WithDescription("Returns the A2A agent card that describes the registry itself as an A2A-capable agent, following the A2A v1.0 RC well-known URL convention.")
.Produces<object>(StatusCodes.Status200OK);

var group = app.MapGroup("/a2a").WithTags("A2A");

// Per-agent card — public, no auth required.
group.MapGet("/agents/{id}", GetAgentCard)
.WithName("A2AGetAgentCard");
.WithName("A2AGetAgentCard")
.WithSummary("Get an A2A agent card")
.WithDescription("Returns the A2A-spec agent card for a registered agent. Skills are mapped from the agent's capabilities. Returns 404 if the agent does not exist or has no A2A endpoints.")
.Produces<object>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status404NotFound);

// A2A-native registration — submit an agent card to register.
group.MapPost("/agents", RegisterViaCard)
.RequireAuthorization(RegistryPolicies.AgentOrAdmin)
.WithName("A2ARegisterAgent");
.WithName("A2ARegisterAgent")
.WithSummary("Register an agent via A2A agent card")
.WithDescription("Registers an agent by submitting a native A2A agent card. Capabilities are mapped from the card's skills and endpoints from the card's service endpoints. Returns the stored card.")
.Produces<object>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status401Unauthorized);

return app;
}
Expand Down
Loading
Loading