diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a31cbaf..69cb319 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6071927 --- /dev/null +++ b/CLAUDE.md @@ -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 -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 `` entries there; use `` 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` (`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`. + +Application-layer tests use `Rocks` (source-generator mocks) to mock `IAgentRepository` and `ILivenessStore`. + +## Adding a new protocol adapter + +1. Add a `Protocols//` folder under `AgentRegistry.Api` with: + - `Models/` — request/response records matching the protocol's wire format + - `AgentManifestMapper.cs` (or equivalent) — bidirectional mapping to/from domain types using `Endpoint.ProtocolMetadata` for protocol-specific fields + - `Endpoints.cs` — minimal API route registrations with `.WithTags()`, `.WithSummary()`, `.WithDescription()`, `.Produces()`, and `.ProducesProblem()` on every route +2. Call `MapEndpoints()` from `Program.cs` +3. Add integration tests under `tests/AgentRegistry.Api.Tests/Protocols//` diff --git a/src/AgentRegistry.Api/Agents/AgentEndpoints.cs b/src/AgentRegistry.Api/Agents/AgentEndpoints.cs index 956a8d4..e8ecb86 100644 --- a/src/AgentRegistry.Api/Agents/AgentEndpoints.cs +++ b/src/AgentRegistry.Api/Agents/AgentEndpoints.cs @@ -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(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(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(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(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; } diff --git a/src/AgentRegistry.Api/Agents/DiscoveryEndpoints.cs b/src/AgentRegistry.Api/Agents/DiscoveryEndpoints.cs index a47f247..17a7d22 100644 --- a/src/AgentRegistry.Api/Agents/DiscoveryEndpoints.cs +++ b/src/AgentRegistry.Api/Agents/DiscoveryEndpoints.cs @@ -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(StatusCodes.Status200OK); return app; } diff --git a/src/AgentRegistry.Api/ApiKeys/ApiKeyEndpoints.cs b/src/AgentRegistry.Api/ApiKeys/ApiKeyEndpoints.cs index 3e1cb17..07f6112 100644 --- a/src/AgentRegistry.Api/ApiKeys/ApiKeyEndpoints.cs +++ b/src/AgentRegistry.Api/ApiKeys/ApiKeyEndpoints.cs @@ -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(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>(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(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status401Unauthorized) + .ProducesProblem(StatusCodes.Status404NotFound); return app; } diff --git a/src/AgentRegistry.Api/Protocols/A2A/A2AEndpoints.cs b/src/AgentRegistry.Api/Protocols/A2A/A2AEndpoints.cs index c81e64d..ac7c6a0 100644 --- a/src/AgentRegistry.Api/Protocols/A2A/A2AEndpoints.cs +++ b/src/AgentRegistry.Api/Protocols/A2A/A2AEndpoints.cs @@ -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(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(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(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status401Unauthorized); return app; } diff --git a/src/AgentRegistry.Api/Protocols/ACP/AcpEndpoints.cs b/src/AgentRegistry.Api/Protocols/ACP/AcpEndpoints.cs index d67639f..96e46a7 100644 --- a/src/AgentRegistry.Api/Protocols/ACP/AcpEndpoints.cs +++ b/src/AgentRegistry.Api/Protocols/ACP/AcpEndpoints.cs @@ -13,13 +13,36 @@ public static IEndpointRouteBuilder MapAcpEndpoints(this IEndpointRouteBuilder a var group = app.MapGroup("/acp").WithTags("ACP"); // Public discovery — mirrors the ACP /agents convention in the registry namespace. - group.MapGet("/agents/{id}", GetManifest).WithName("AcpGetAgentManifest"); - group.MapGet("/agents", ListManifests).WithName("AcpListAgents"); + group.MapGet("/agents/{id}", GetManifest) + .WithName("AcpGetAgentManifest") + .WithSummary("Get an ACP agent manifest") + .WithDescription("Returns the ACP 0.2.0 agent manifest for a registered agent, including MIME-typed content types, JSON Schema for inputs and outputs, and performance metrics. Returns 404 if the agent has no ACP endpoints.") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound); + + group.MapGet("/agents", ListManifests) + .WithName("AcpListAgents") + .WithSummary("List ACP agent manifests") + .WithDescription( + "Returns a paginated list of ACP agent manifests for registered agents.\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" + + "- `domain` — ACP domain filter (stored as a tag, merged with `tags`)\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(StatusCodes.Status200OK); // ACP-native registration — submit a manifest to register. group.MapPost("/agents", RegisterViaManifest) .RequireAuthorization(RegistryPolicies.AgentOrAdmin) - .WithName("AcpRegisterAgent"); + .WithName("AcpRegisterAgent") + .WithSummary("Register an agent via ACP manifest") + .WithDescription("Registers an agent by submitting a native ACP 0.2.0 manifest and the agent's endpoint URL. Capabilities are mapped from the manifest's metadata and all manifest fields round-trip through protocol metadata.") + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status401Unauthorized); return app; } diff --git a/src/AgentRegistry.Api/Protocols/MCP/McpEndpoints.cs b/src/AgentRegistry.Api/Protocols/MCP/McpEndpoints.cs index a660750..4533d32 100644 --- a/src/AgentRegistry.Api/Protocols/MCP/McpEndpoints.cs +++ b/src/AgentRegistry.Api/Protocols/MCP/McpEndpoints.cs @@ -13,13 +13,35 @@ public static IEndpointRouteBuilder MapMcpEndpoints(this IEndpointRouteBuilder a var group = app.MapGroup("/mcp").WithTags("MCP"); // Public discovery — no auth required. - group.MapGet("/servers/{id}", GetServerCard).WithName("McpGetServerCard"); - group.MapGet("/servers", ListServerCards).WithName("McpListServers"); + group.MapGet("/servers/{id}", GetServerCard) + .WithName("McpGetServerCard") + .WithSummary("Get an MCP server card") + .WithDescription("Returns the MCP server card for a registered server, including the Streamable HTTP endpoint URL and declared tools, resources, and prompts. Returns 404 if the agent has no MCP endpoints.") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound); + + group.MapGet("/servers", ListServerCards) + .WithName("McpListServers") + .WithSummary("List MCP server cards") + .WithDescription( + "Returns a paginated list of MCP server cards. Only servers with a Streamable HTTP endpoint are included.\n\n" + + "**Filters**\n" + + "- `capability` — match servers that declare a tool with this exact name\n" + + "- `tags` — comma-separated list; servers must match all supplied tags\n" + + "- `liveOnly` — when `false`, includes servers with no live endpoints (default: `true`)\n" + + "- `page` / `pageSize` — 1-based page number and page size (max 100, default 20)") + .Produces(StatusCodes.Status200OK); // MCP-native registration — submit a server card to register. group.MapPost("/servers", RegisterViaCard) .RequireAuthorization(RegistryPolicies.AgentOrAdmin) - .WithName("McpRegisterServer"); + .WithName("McpRegisterServer") + .WithSummary("Register an MCP server via server card") + .WithDescription("Registers an MCP server by submitting a native MCP server card. The card must include a Streamable HTTP endpoint in `endpoints.streamableHttp`. Tool, resource, and prompt descriptors round-trip through protocol metadata.") + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status401Unauthorized); return app; }