From f0c77c80b457ed14182f21a5f587fba10fff933a Mon Sep 17 00:00:00 2001 From: Fabian Gonzalez Date: Thu, 16 Jul 2026 14:50:30 -0400 Subject: [PATCH 1/9] support authnz on mcp server Signed-off-by: Fabian Gonzalez --- internal/mcp/registryserver/server.go | 113 +++++++++++++----- .../registryserver/server_integration_test.go | 2 +- internal/registry/registry_app.go | 26 ++-- 3 files changed, 98 insertions(+), 43 deletions(-) diff --git a/internal/mcp/registryserver/server.go b/internal/mcp/registryserver/server.go index df98aa5d5..8ca02f3f3 100644 --- a/internal/mcp/registryserver/server.go +++ b/internal/mcp/registryserver/server.go @@ -16,9 +16,21 @@ import ( "github.com/agentregistry-dev/agentregistry/internal/version" "github.com/agentregistry-dev/agentregistry/pkg/api/v1alpha1" pkgdb "github.com/agentregistry-dev/agentregistry/pkg/registry/database" + "github.com/agentregistry-dev/agentregistry/pkg/registry/resource" "github.com/agentregistry-dev/agentregistry/pkg/registry/v1alpha1store" ) +// Authorizer gates a read operation for a kind against the caller's session +// (carried on the context). ListFilter returns an ExtraWhere predicate + args +// that scope a list to what the caller may see. Both mirror the per-kind hooks +// the REST resource handlers consult (resource.Config.Authorize / +// resource.Config.ListFilter), so the bridge enforces the same RBAC. +// A nil hook for a kind means no restriction. +type Authorizer = func(ctx context.Context, in resource.AuthorizeInput) error + +// ListFilter is the per-kind list-scoping hook; see Authorizer. +type ListFilter = func(ctx context.Context, in resource.AuthorizeInput) (extraWhere string, extraArgs []any, err error) + const ( defaultPageLimit = 30 maxPageLimit = 100 @@ -31,7 +43,11 @@ const ( // // Tool names are preserved across builds (`list_servers` not // `list_mcpservers`) so saved Claude MCP configs keep working. -func NewServer(stores map[string]*v1alpha1store.Store) *mcp.Server { +func NewServer( + stores map[string]*v1alpha1store.Store, + authorizers map[string]Authorizer, + listFilters map[string]ListFilter, +) *mcp.Server { server := mcp.NewServer(&mcp.Implementation{ Name: "agentregistry-mcp", Version: version.Version, @@ -41,36 +57,44 @@ func NewServer(stores map[string]*v1alpha1store.Store) *mcp.Server { }) addKindTools(server, stores[v1alpha1.KindAgent], kindTools[*v1alpha1.Agent]{ - Kind: v1alpha1.KindAgent, - ListName: "list_agents", - GetName: "get_agent", - ListDesc: "List published agents as v1alpha1 envelopes with optional namespace, substring-name, and tag filters.", - GetDesc: "Fetch a published agent as a v1alpha1 envelope (defaults to the latest tag).", - NewObj: func() *v1alpha1.Agent { return &v1alpha1.Agent{} }, + Kind: v1alpha1.KindAgent, + ListName: "list_agents", + GetName: "get_agent", + ListDesc: "List published agents as v1alpha1 envelopes with optional namespace, substring-name, and tag filters.", + GetDesc: "Fetch a published agent as a v1alpha1 envelope (defaults to the latest tag).", + NewObj: func() *v1alpha1.Agent { return &v1alpha1.Agent{} }, + Authorize: authorizers[v1alpha1.KindAgent], + ListFilter: listFilters[v1alpha1.KindAgent], }) addKindTools(server, stores[v1alpha1.KindMCPServer], kindTools[*v1alpha1.MCPServer]{ - Kind: v1alpha1.KindMCPServer, - ListName: "list_servers", - GetName: "get_server", - ListDesc: "List published MCP servers as v1alpha1 envelopes with optional namespace, substring-name, and tag filters.", - GetDesc: "Fetch a published MCP server as a v1alpha1 envelope (defaults to the latest tag).", - NewObj: func() *v1alpha1.MCPServer { return &v1alpha1.MCPServer{} }, + Kind: v1alpha1.KindMCPServer, + ListName: "list_servers", + GetName: "get_server", + ListDesc: "List published MCP servers as v1alpha1 envelopes with optional namespace, substring-name, and tag filters.", + GetDesc: "Fetch a published MCP server as a v1alpha1 envelope (defaults to the latest tag).", + NewObj: func() *v1alpha1.MCPServer { return &v1alpha1.MCPServer{} }, + Authorize: authorizers[v1alpha1.KindMCPServer], + ListFilter: listFilters[v1alpha1.KindMCPServer], }) addKindTools(server, stores[v1alpha1.KindSkill], kindTools[*v1alpha1.Skill]{ - Kind: v1alpha1.KindSkill, - ListName: "list_skills", - GetName: "get_skill", - ListDesc: "List published skills as v1alpha1 envelopes with optional namespace, substring-name, and tag filters.", - GetDesc: "Fetch a published skill as a v1alpha1 envelope (defaults to the latest tag).", - NewObj: func() *v1alpha1.Skill { return &v1alpha1.Skill{} }, + Kind: v1alpha1.KindSkill, + ListName: "list_skills", + GetName: "get_skill", + ListDesc: "List published skills as v1alpha1 envelopes with optional namespace, substring-name, and tag filters.", + GetDesc: "Fetch a published skill as a v1alpha1 envelope (defaults to the latest tag).", + NewObj: func() *v1alpha1.Skill { return &v1alpha1.Skill{} }, + Authorize: authorizers[v1alpha1.KindSkill], + ListFilter: listFilters[v1alpha1.KindSkill], }) addKindTools(server, stores[v1alpha1.KindDeployment], kindTools[*v1alpha1.Deployment]{ - Kind: v1alpha1.KindDeployment, - ListName: "list_deployments", - GetName: "get_deployment", - ListDesc: "List deployments as v1alpha1 envelopes with optional namespace and substring-name filters.", - GetDesc: "Fetch a deployment as a v1alpha1 envelope by namespace/name.", - NewObj: func() *v1alpha1.Deployment { return &v1alpha1.Deployment{} }, + Kind: v1alpha1.KindDeployment, + ListName: "list_deployments", + GetName: "get_deployment", + ListDesc: "List deployments as v1alpha1 envelopes with optional namespace and substring-name filters.", + GetDesc: "Fetch a deployment as a v1alpha1 envelope by namespace/name.", + NewObj: func() *v1alpha1.Deployment { return &v1alpha1.Deployment{} }, + Authorize: authorizers[v1alpha1.KindDeployment], + ListFilter: listFilters[v1alpha1.KindDeployment], }) addMetaTools(server) addServerPrompts(server) @@ -89,6 +113,9 @@ type kindTools[T v1alpha1.Object] struct { ListDesc string GetDesc string NewObj func() T + // Authorize + ListFilter are per-kind RBAC hooks, nil when the build wires no authz for this kind. + Authorize Authorizer + ListFilter ListFilter } // addKindTools registers list_X + get_X MCP tools for a v1alpha1 kind. @@ -102,7 +129,7 @@ func addKindTools[T v1alpha1.Object](server *mcp.Server, store *v1alpha1store.St Name: cfg.ListName, Description: cfg.ListDesc, }, func(ctx context.Context, _ *mcp.CallToolRequest, args listInput) (*mcp.CallToolResult, listOutput[T], error) { - raws, next, err := runList(ctx, store, args) + raws, next, err := runList(ctx, store, cfg.Kind, cfg.Authorize, cfg.ListFilter, args) if err != nil { return nil, listOutput[T]{}, err } @@ -116,7 +143,7 @@ func addKindTools[T v1alpha1.Object](server *mcp.Server, store *v1alpha1store.St Name: cfg.GetName, Description: cfg.GetDesc, }, func(ctx context.Context, _ *mcp.CallToolRequest, args getByRefInput) (*mcp.CallToolResult, T, error) { - return getEnvelope(ctx, store, cfg.Kind, args, cfg.NewObj) + return getEnvelope(ctx, store, cfg.Kind, cfg.Authorize, args, cfg.NewObj) }) } @@ -174,9 +201,22 @@ func addMetaTools(server *mcp.Server) { // Internal glue — generic list + get helpers shared across kinds. // ----------------------------------------------------------------------------- -func runList(ctx context.Context, store *v1alpha1store.Store, args listInput) ([]*v1alpha1.RawObject, string, error) { +func runList( + ctx context.Context, + store *v1alpha1store.Store, + kind string, + authorize Authorizer, + listFilter ListFilter, + args listInput, +) ([]*v1alpha1.RawObject, string, error) { + namespace := strings.TrimSpace(args.Namespace) + if authorize != nil { + if err := authorize(ctx, resource.AuthorizeInput{Verb: "list", Kind: kind, Namespace: namespace}); err != nil { + return nil, "", err + } + } opts := v1alpha1store.ListOpts{ - Namespace: strings.TrimSpace(args.Namespace), + Namespace: namespace, Limit: clampLimit(args.Limit), Cursor: args.Cursor, } @@ -184,6 +224,14 @@ func runList(ctx context.Context, store *v1alpha1store.Store, args listInput) ([ if strings.EqualFold(tag, "latest") { opts.LatestOnly = true } + if listFilter != nil { + extraWhere, extraArgs, err := listFilter(ctx, resource.AuthorizeInput{Verb: "list", Kind: kind, Namespace: namespace}) + if err != nil { + return nil, "", err + } + opts.ExtraWhere = extraWhere + opts.ExtraArgs = extraArgs + } raws, next, err := store.List(ctx, opts) if err != nil { return nil, "", fmt.Errorf("list: %w", err) @@ -219,6 +267,7 @@ func getEnvelope[T v1alpha1.Object]( ctx context.Context, store *v1alpha1store.Store, kind string, + authorize Authorizer, args getByRefInput, newObj func() T, ) (*mcp.CallToolResult, T, error) { @@ -231,6 +280,12 @@ func getEnvelope[T v1alpha1.Object]( namespace = v1alpha1.DefaultNamespace } tag := strings.TrimSpace(args.Tag) + if authorize != nil { + if err := authorize(ctx, resource.AuthorizeInput{Verb: "get", Kind: kind, Namespace: namespace, Name: args.Name, Tag: tag}); err != nil { + var zero T + return nil, zero, err + } + } var ( raw *v1alpha1.RawObject diff --git a/internal/mcp/registryserver/server_integration_test.go b/internal/mcp/registryserver/server_integration_test.go index 451647295..5a928ba3b 100644 --- a/internal/mcp/registryserver/server_integration_test.go +++ b/internal/mcp/registryserver/server_integration_test.go @@ -47,7 +47,7 @@ func TestMCPListServers_HappyPath(t *testing.T) { require.NoError(t, err, "seed server") // Wire up MCP server + client over in-memory transports. - server := NewServer(stores) + server := NewServer(stores, nil, nil) clientTransport, serverTransport := mcp.NewInMemoryTransports() serverSession, err := server.Connect(ctx, serverTransport, nil) diff --git a/internal/registry/registry_app.go b/internal/registry/registry_app.go index cfe061cea..821142c02 100644 --- a/internal/registry/registry_app.go +++ b/internal/registry/registry_app.go @@ -161,7 +161,8 @@ func App(ctx context.Context, opts ...types.AppOptions) error { } }() - routeOpts := buildRouteOptions(options, stores, deploymentAdapters, crudPerKindHooks(options)) + perKindHooks := crudPerKindHooks(options) + routeOpts := buildRouteOptions(options, stores, deploymentAdapters, perKindHooks) // Initialize HTTP server baseServer, err := api.NewServer(cfg, metrics, versionInfo, options.UIHandler, authnProvider, routeOpts) @@ -180,7 +181,7 @@ func App(ctx context.Context, opts ...types.AppOptions) error { options.OnHTTPServerCreated(server) } - mcpHTTPServer := startMCPServer(cfg, stores, authnProvider) + mcpHTTPServer := startMCPServer(cfg, stores, authnProvider, perKindHooks) // Start server in a goroutine so it doesn't block signal handling go func() { @@ -504,11 +505,12 @@ func startMCPServer( cfg *config.Config, stores map[string]*v1alpha1store.Store, authnProvider auth.AuthnProvider, + hooks crud.PerKindHooks, ) *http.Server { if cfg.MCPPort <= 0 { return nil } - mcpServer := mcpregistry.NewServer(stores) + mcpServer := mcpregistry.NewServer(stores, hooks.Authorizers, hooks.ListFilters) var handler http.Handler = mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server { return mcpServer }, &mcp.StreamableHTTPOptions{}) @@ -540,23 +542,21 @@ func startMCPServer( } // mcpAuthnMiddleware uses the AuthnProvider to attach a session to the -// request context on successful authentication. On auth error or missing -// session, the request continues with an unauthenticated context — the -// AuthzProvider downstream decides whether the request is allowed (the -// OSS default `PublicAuthzProvider` permits read-only access; downstream -// authz can reject). Failing-open here is intentional so the MCP bridge -// works for anonymous `list_servers` / `get_server` traffic while still -// letting authenticated callers pick up privileged operations. +// request context on successful authentication, so downstream tool calls run +// their per-kind authorizer + list-filter against the caller's identity. A +// request without a valid credential is rejected with 401. This middleware is +// only installed when an AuthnProvider is configured; a build with no provider +// serves the bridge unauthenticated. func mcpAuthnMiddleware(authn auth.AuthnProvider) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() session, err := authn.Authenticate(ctx, r.Header.Get, r.URL.Query()) if err == nil && session != nil { - ctx = auth.AuthSessionTo(ctx, session) - r = r.WithContext(ctx) + next.ServeHTTP(w, r.WithContext(auth.AuthSessionTo(ctx, session))) + return } - next.ServeHTTP(w, r) + http.Error(w, "Unauthorized", http.StatusUnauthorized) }) } } From 93c2823979ff2d268a0a0d45aa904c676d4cb9a8 Mon Sep 17 00:00:00 2001 From: Fabian Gonzalez Date: Thu, 16 Jul 2026 17:03:32 -0400 Subject: [PATCH 2/9] expose www + well-known for mcp client auth Signed-off-by: Fabian Gonzalez --- internal/registry/registry_app.go | 29 ++++++++++++++++++++--------- pkg/types/types.go | 12 ++++++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/internal/registry/registry_app.go b/internal/registry/registry_app.go index 821142c02..d1ae4f0ae 100644 --- a/internal/registry/registry_app.go +++ b/internal/registry/registry_app.go @@ -16,7 +16,9 @@ import ( "time" "github.com/jackc/pgx/v5/pgxpool" + mcpauth "github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/oauthex" mcpregistry "github.com/agentregistry-dev/agentregistry/internal/mcp/registryserver" "github.com/agentregistry-dev/agentregistry/internal/registry/api" @@ -181,7 +183,7 @@ func App(ctx context.Context, opts ...types.AppOptions) error { options.OnHTTPServerCreated(server) } - mcpHTTPServer := startMCPServer(cfg, stores, authnProvider, perKindHooks) + mcpHTTPServer := startMCPServer(cfg, stores, authnProvider, perKindHooks, options.MCPProtectedResourceMetadata, options.MCPResourceMetadataURL) // Start server in a goroutine so it doesn't block signal handling go func() { @@ -506,6 +508,8 @@ func startMCPServer( stores map[string]*v1alpha1store.Store, authnProvider auth.AuthnProvider, hooks crud.PerKindHooks, + resourceMetadata *oauthex.ProtectedResourceMetadata, + resourceMetadataURL string, ) *http.Server { if cfg.MCPPort <= 0 { return nil @@ -515,7 +519,7 @@ func startMCPServer( return mcpServer }, &mcp.StreamableHTTPOptions{}) if authnProvider != nil { - handler = mcpAuthnMiddleware(authnProvider)(handler) + handler = mcpAuthnMiddleware(authnProvider, resourceMetadataURL)(handler) } // Mount the MCP handler under a mux that reserves /healthz for a 200-OK liveness probe. // The bridge listener otherwise has no plain-HTTP endpoint that returns 200 (a bare GET @@ -524,6 +528,11 @@ func startMCPServer( mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + // RFC 9728 discovery for MCP clients, served only when a downstream build supplies the metadata. + if resourceMetadata != nil { + mux.Handle("/.well-known/oauth-protected-resource", mcpauth.ProtectedResourceMetadataHandler(resourceMetadata)) + mux.Handle("/.well-known/oauth-protected-resource/", mcpauth.ProtectedResourceMetadataHandler(resourceMetadata)) + } mux.Handle("/", handler) addr := ":" + strconv.Itoa(int(cfg.MCPPort)) srv := &http.Server{ @@ -541,13 +550,12 @@ func startMCPServer( return srv } -// mcpAuthnMiddleware uses the AuthnProvider to attach a session to the -// request context on successful authentication, so downstream tool calls run -// their per-kind authorizer + list-filter against the caller's identity. A -// request without a valid credential is rejected with 401. This middleware is -// only installed when an AuthnProvider is configured; a build with no provider -// serves the bridge unauthenticated. -func mcpAuthnMiddleware(authn auth.AuthnProvider) func(http.Handler) http.Handler { +// mcpAuthnMiddleware validates the bearer via the AuthnProvider and attaches the +// session so the tools' authz hooks run against the caller; missing or invalid +// credentials get 401. When resourceMetadataURL is set, the 401 also carries the +// RFC 9728 WWW-Authenticate challenge for client discovery. Installed only when +// an AuthnProvider is configured. +func mcpAuthnMiddleware(authn auth.AuthnProvider, resourceMetadataURL string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -556,6 +564,9 @@ func mcpAuthnMiddleware(authn auth.AuthnProvider) func(http.Handler) http.Handle next.ServeHTTP(w, r.WithContext(auth.AuthSessionTo(ctx, session))) return } + if resourceMetadataURL != "" { + w.Header().Set("WWW-Authenticate", fmt.Sprintf("Bearer resource_metadata=%q", resourceMetadataURL)) + } http.Error(w, "Unauthorized", http.StatusUnauthorized) }) } diff --git a/pkg/types/types.go b/pkg/types/types.go index bf7a92403..5272695f1 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -18,6 +18,8 @@ import ( "github.com/danielgtaylor/huma/v2" + "github.com/modelcontextprotocol/go-sdk/oauthex" + v0 "github.com/agentregistry-dev/agentregistry/pkg/api/v0" "github.com/agentregistry-dev/agentregistry/pkg/api/v1alpha1" "github.com/agentregistry-dev/agentregistry/pkg/registry/auth" @@ -349,6 +351,16 @@ type AppOptions struct { // AuthzProvider is an optional authorization provider. AuthzProvider auth.AuthzProvider + // MCPProtectedResourceMetadata, when non-nil, makes the MCP bridge serve + // RFC 9728 protected-resource metadata at the well-known path. Nil disables + // OAuth discovery only, although the bridge stays fail-closed and RBAC-enforced. + MCPProtectedResourceMetadata *oauthex.ProtectedResourceMetadata + + // MCPResourceMetadataURL is the external URL of that metadata document, + // emitted as the resource_metadata parameter of the bridge's 401 + // WWW-Authenticate challenge. Empty omits the hint. + MCPResourceMetadataURL string + // Auditor receives audit events from the v1alpha1 store layer // (e.g. ResourceTagCreated on Upsert creates). The default OSS // behavior is a no-op; downstream builds plug in a real audit sink. From 07c1c1271ea3bab32f20f0d34bc5879a79f36d42 Mon Sep 17 00:00:00 2001 From: Fabian Gonzalez Date: Thu, 16 Jul 2026 17:42:52 -0400 Subject: [PATCH 3/9] add authnprovider for mcp server (different aud for validation) Signed-off-by: Fabian Gonzalez --- internal/registry/registry_app.go | 8 +++++++- pkg/types/types.go | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/registry/registry_app.go b/internal/registry/registry_app.go index d1ae4f0ae..41158b0dc 100644 --- a/internal/registry/registry_app.go +++ b/internal/registry/registry_app.go @@ -183,7 +183,13 @@ func App(ctx context.Context, opts ...types.AppOptions) error { options.OnHTTPServerCreated(server) } - mcpHTTPServer := startMCPServer(cfg, stores, authnProvider, perKindHooks, options.MCPProtectedResourceMetadata, options.MCPResourceMetadataURL) + // The bridge may use a dedicated authn provider (e.g. one that adds MCP + // audience validation) without affecting server traffic. + mcpAuthnProvider := options.MCPAuthnProvider + if mcpAuthnProvider == nil { + mcpAuthnProvider = authnProvider + } + mcpHTTPServer := startMCPServer(cfg, stores, mcpAuthnProvider, perKindHooks, options.MCPProtectedResourceMetadata, options.MCPResourceMetadataURL) // Start server in a goroutine so it doesn't block signal handling go func() { diff --git a/pkg/types/types.go b/pkg/types/types.go index 5272695f1..03ba4f395 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -361,6 +361,11 @@ type AppOptions struct { // WWW-Authenticate challenge. Empty omits the hint. MCPResourceMetadataURL string + // MCPAuthnProvider optionally overrides AuthnProvider for the MCP bridge + // only, letting a build apply bridge-specific validation (e.g. audience + // binding to the MCP resource). Nil falls back to AuthnProvider. + MCPAuthnProvider auth.AuthnProvider + // Auditor receives audit events from the v1alpha1 store layer // (e.g. ResourceTagCreated on Upsert creates). The default OSS // behavior is a no-op; downstream builds plug in a real audit sink. From 748e9c96dec5e101826f1281bdf050e72bd6ad3c Mon Sep 17 00:00:00 2001 From: Fabian Gonzalez Date: Thu, 16 Jul 2026 17:43:36 -0400 Subject: [PATCH 4/9] make verify lint Signed-off-by: Fabian Gonzalez --- pkg/types/types.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/types/types.go b/pkg/types/types.go index 03ba4f395..ce25813e0 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -17,7 +17,6 @@ import ( "net/http" "github.com/danielgtaylor/huma/v2" - "github.com/modelcontextprotocol/go-sdk/oauthex" v0 "github.com/agentregistry-dev/agentregistry/pkg/api/v0" From 6dde946f7e0bb8281607b9e6d7ffcd80f3e6d760 Mon Sep 17 00:00:00 2001 From: Fabian Gonzalez Date: Mon, 20 Jul 2026 13:00:59 -0400 Subject: [PATCH 5/9] add tests Signed-off-by: Fabian Gonzalez --- .../registryserver/server_integration_test.go | 111 +++++++++++++++ internal/registry/registry_app.go | 29 ++-- internal/registry/registry_app_test.go | 129 ++++++++++++++++++ 3 files changed, 256 insertions(+), 13 deletions(-) diff --git a/internal/mcp/registryserver/server_integration_test.go b/internal/mcp/registryserver/server_integration_test.go index 5a928ba3b..ef17b8852 100644 --- a/internal/mcp/registryserver/server_integration_test.go +++ b/internal/mcp/registryserver/server_integration_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/require" "github.com/agentregistry-dev/agentregistry/pkg/api/v1alpha1" + "github.com/agentregistry-dev/agentregistry/pkg/registry/resource" "github.com/agentregistry-dev/agentregistry/pkg/registry/v1alpha1store" ) @@ -105,3 +106,113 @@ func TestMCPListServers_HappyPath(t *testing.T) { assert.Equal(t, serverName, gotOne.Metadata.Name) assert.Equal(t, "Echo test server", gotOne.Spec.Description) } + +// seedMCPServer publishes one MCPServer so the authz-seam tests have a row to +// include/exclude, and returns its namespace/name. +func seedMCPServer(ctx context.Context, t *testing.T, stores map[string]*v1alpha1store.Store) (namespace, name string) { + t.Helper() + namespace, name = "default", "echo" + _, err := stores[v1alpha1.KindMCPServer].Upsert(ctx, &v1alpha1.MCPServer{ + Metadata: v1alpha1.ObjectMeta{Namespace: namespace, Name: name}, + Spec: v1alpha1.MCPServerSpec{ + Description: "Echo test server", + Source: &v1alpha1.MCPServerSource{ + Package: &v1alpha1.MCPPackage{ + Origin: v1alpha1.MCPPackageOrigin{ + Type: v1alpha1.MCPPackageOriginTypeOCI, + Identifier: "ghcr.io/example/echo:1.0.0", + OCI: &v1alpha1.MCPPackageOriginOCI{ServerName: "echo"}, + }, + Transport: v1alpha1.MCPTransport{Type: "stdio"}, + }, + }, + }, + }) + require.NoError(t, err, "seed server") + return namespace, name +} + +// TestRunList_Authz exercises the list read path's RBAC seam directly with fake +// per-kind hooks: denial short-circuits, and Extra{Where+Args} reach the SQL query. +func TestRunList_Authz(t *testing.T) { + ctx := context.Background() + pool := v1alpha1store.NewTestPool(t) + stores := v1alpha1store.NewStores(pool, v1alpha1store.TestSchemaRegistry()) + _, name := seedMCPServer(ctx, t, stores) + store := stores[v1alpha1.KindMCPServer] + + t.Run("authorizer denial is returned", func(t *testing.T) { + denyAuthz := func(context.Context, resource.AuthorizeInput) error { return errors.New("denied") } + rows, _, err := runList(ctx, store, v1alpha1.KindMCPServer, denyAuthz, nil, listInput{}) + require.Error(t, err) + assert.Nil(t, rows) + }) + + t.Run("list filter Extra{Where+Args} exclude non-matching rows", func(t *testing.T) { + filter := func(context.Context, resource.AuthorizeInput) (string, []any, error) { + return "name = $1", []any{"does-not-exist"}, nil + } + rows, _, err := runList(ctx, store, v1alpha1.KindMCPServer, nil, filter, listInput{}) + require.NoError(t, err) + assert.Empty(t, rows, "predicate should exclude the seeded row") + }) + + t.Run("list filter ExtraWhere+ExtraArgs include matching rows", func(t *testing.T) { + filter := func(context.Context, resource.AuthorizeInput) (string, []any, error) { + return "name = $1", []any{name}, nil + } + rows, _, err := runList(ctx, store, v1alpha1.KindMCPServer, nil, filter, listInput{}) + require.NoError(t, err) + assert.Len(t, rows, 1, "predicate with the seeded name should include it") + }) + + t.Run("nil hooks read the store unscoped", func(t *testing.T) { + rows, _, err := runList(ctx, store, v1alpha1.KindMCPServer, nil, nil, listInput{}) + require.NoError(t, err) + assert.Len(t, rows, 1, "no hooks: full catalogue (OSS default)") + }) + + t.Run("hooks receive the list AuthorizeInput", func(t *testing.T) { + var gotAuth, gotFilter resource.AuthorizeInput + authz := func(_ context.Context, in resource.AuthorizeInput) error { gotAuth = in; return nil } + filter := func(_ context.Context, in resource.AuthorizeInput) (string, []any, error) { gotFilter = in; return "", nil, nil } + _, _, err := runList(ctx, store, v1alpha1.KindMCPServer, authz, filter, listInput{Namespace: "default"}) + require.NoError(t, err) + want := resource.AuthorizeInput{Verb: "list", Kind: v1alpha1.KindMCPServer, Namespace: "default"} + assert.Equal(t, want, gotAuth) + assert.Equal(t, want, gotFilter) + }) +} + +// TestGetEnvelope_Authz exercises the get read path's RBAC seam: denial +// short-circuits before the fetch, a nil authorizer returns the object, and the +// authorizer receives the get AuthorizeInput. +func TestGetEnvelope_Authz(t *testing.T) { + ctx := context.Background() + pool := v1alpha1store.NewTestPool(t) + stores := v1alpha1store.NewStores(pool, v1alpha1store.TestSchemaRegistry()) + ns, name := seedMCPServer(ctx, t, stores) + store := stores[v1alpha1.KindMCPServer] + newObj := func() *v1alpha1.MCPServer { return &v1alpha1.MCPServer{} } + + t.Run("authorizer denial is returned", func(t *testing.T) { + denyAuthz := func(context.Context, resource.AuthorizeInput) error { return errors.New("denied") } + _, _, err := getEnvelope(ctx, store, v1alpha1.KindMCPServer, denyAuthz, getByRefInput{Namespace: ns, Name: name}, newObj) + require.Error(t, err) + }) + + t.Run("nil authorizer returns the object", func(t *testing.T) { + _, obj, err := getEnvelope(ctx, store, v1alpha1.KindMCPServer, nil, getByRefInput{Namespace: ns, Name: name}, newObj) + require.NoError(t, err) + require.NotNil(t, obj) + assert.Equal(t, name, obj.Metadata.Name) + }) + + t.Run("authorizer receives the get AuthorizeInput", func(t *testing.T) { + var got resource.AuthorizeInput + authz := func(_ context.Context, in resource.AuthorizeInput) error { got = in; return nil } + _, _, err := getEnvelope(ctx, store, v1alpha1.KindMCPServer, authz, getByRefInput{Namespace: ns, Name: name}, newObj) + require.NoError(t, err) + assert.Equal(t, resource.AuthorizeInput{Verb: "get", Kind: v1alpha1.KindMCPServer, Namespace: ns, Name: name}, got) + }) +} diff --git a/internal/registry/registry_app.go b/internal/registry/registry_app.go index 41158b0dc..dc1ab063b 100644 --- a/internal/registry/registry_app.go +++ b/internal/registry/registry_app.go @@ -527,19 +527,7 @@ func startMCPServer( if authnProvider != nil { handler = mcpAuthnMiddleware(authnProvider, resourceMetadataURL)(handler) } - // Mount the MCP handler under a mux that reserves /healthz for a 200-OK liveness probe. - // The bridge listener otherwise has no plain-HTTP endpoint that returns 200 (a bare GET - // yields 400). - mux := http.NewServeMux() - mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - }) - // RFC 9728 discovery for MCP clients, served only when a downstream build supplies the metadata. - if resourceMetadata != nil { - mux.Handle("/.well-known/oauth-protected-resource", mcpauth.ProtectedResourceMetadataHandler(resourceMetadata)) - mux.Handle("/.well-known/oauth-protected-resource/", mcpauth.ProtectedResourceMetadataHandler(resourceMetadata)) - } - mux.Handle("/", handler) + mux := buildMCPMux(handler, resourceMetadata) addr := ":" + strconv.Itoa(int(cfg.MCPPort)) srv := &http.Server{ Addr: addr, @@ -556,6 +544,21 @@ func startMCPServer( return srv } +// buildMCPMux assembles the registry MCP server's HTTP routes: a /healthz liveness +// probe, plus protected-resource-metadata discovery served only when supplied the metadata. +func buildMCPMux(handler http.Handler, resourceMetadata *oauthex.ProtectedResourceMetadata) *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + if resourceMetadata != nil { + mux.Handle("/.well-known/oauth-protected-resource", mcpauth.ProtectedResourceMetadataHandler(resourceMetadata)) + mux.Handle("/.well-known/oauth-protected-resource/", mcpauth.ProtectedResourceMetadataHandler(resourceMetadata)) + } + mux.Handle("/", handler) + return mux +} + // mcpAuthnMiddleware validates the bearer via the AuthnProvider and attaches the // session so the tools' authz hooks run against the caller; missing or invalid // credentials get 401. When resourceMetadataURL is set, the 401 also carries the diff --git a/internal/registry/registry_app_test.go b/internal/registry/registry_app_test.go index 8bf618ec2..181e35c9e 100644 --- a/internal/registry/registry_app_test.go +++ b/internal/registry/registry_app_test.go @@ -1,12 +1,21 @@ package registry import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" "testing" "time" + "github.com/modelcontextprotocol/go-sdk/oauthex" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/agentregistry-dev/agentregistry/internal/registry/config" + "github.com/agentregistry-dev/agentregistry/pkg/registry/auth" pkgdb "github.com/agentregistry-dev/agentregistry/pkg/registry/database" ) @@ -74,3 +83,123 @@ func TestResolveExtraStoreSchemaPanicsOnInvalidSchema(t *testing.T) { }() resolveExtraStoreSchema("BadSchema.widgets", oss) } + +// fakeSession is a minimal auth.Session for exercising the bridge middleware. +type fakeSession struct{} + +func (fakeSession) Principal() auth.Principal { return auth.Principal{} } + +// fakeAuthnProvider returns a fixed (session, err) so tests can drive the +// middleware's accept/reject branches without a real token or IdP. +type fakeAuthnProvider struct { + session auth.Session + err error +} + +func (f fakeAuthnProvider) Authenticate(context.Context, func(string) string, url.Values) (auth.Session, error) { + return f.session, f.err +} + +func TestMCPAuthnMiddleware(t *testing.T) { + const metaURL = "https://host.example/.well-known/oauth-protected-resource/mcp" + + newNext := func(ran, sawSession *bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *ran = true + _, ok := auth.AuthSessionFrom(r.Context()) + *sawSession = ok + w.WriteHeader(http.StatusTeapot) + }) + } + + t.Run("valid token passes through and attaches the session", func(t *testing.T) { + var ran, sawSession bool + h := mcpAuthnMiddleware(fakeAuthnProvider{session: fakeSession{}}, metaURL)(newNext(&ran, &sawSession)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", nil)) + assert.Equal(t, http.StatusTeapot, rec.Code) + assert.True(t, ran, "next handler should run") + assert.True(t, sawSession, "authenticated session must be on the request context") + assert.Empty(t, rec.Header().Get("WWW-Authenticate")) + }) + + t.Run("authentication error is rejected with 401", func(t *testing.T) { + var ran, sawSession bool + h := mcpAuthnMiddleware(fakeAuthnProvider{err: errors.New("bad token")}, metaURL)(newNext(&ran, &sawSession)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", nil)) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.False(t, ran, "next handler must not run when authn fails") + }) + + t.Run("nil session without error is rejected with 401", func(t *testing.T) { + var ran, sawSession bool + h := mcpAuthnMiddleware(fakeAuthnProvider{}, metaURL)(newNext(&ran, &sawSession)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", nil)) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.False(t, ran) + }) + + t.Run("401 carries the MCP server challenge when a metadata url is set", func(t *testing.T) { + var ran, sawSession bool + h := mcpAuthnMiddleware(fakeAuthnProvider{err: errors.New("unauthenticated")}, metaURL)(newNext(&ran, &sawSession)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", nil)) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, `Bearer resource_metadata="`+metaURL+`"`, rec.Header().Get("WWW-Authenticate")) + }) + + t.Run("401 omits the MCP server challenge when no metadata url is set", func(t *testing.T) { + var ran, sawSession bool + h := mcpAuthnMiddleware(fakeAuthnProvider{err: errors.New("no")}, "")(newNext(&ran, &sawSession)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", nil)) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Empty(t, rec.Header().Get("WWW-Authenticate")) + }) +} + +func TestBuildMCPMux(t *testing.T) { + // catchAll marks a request that fell through to the MCP handler. + const catchAll = http.StatusTeapot + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(catchAll) }) + meta := &oauthex.ProtectedResourceMetadata{ + Resource: "https://host.example/mcp", + AuthorizationServers: []string{"https://issuer.example"}, + } + + t.Run("/healthz returns 200", func(t *testing.T) { + rec := httptest.NewRecorder() + buildMCPMux(handler, meta).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + assert.Equal(t, http.StatusOK, rec.Code) + }) + + t.Run("serves protected-resource metadata (exact and trailing-slash) when supplied", func(t *testing.T) { + mux := buildMCPMux(handler, meta) + for _, path := range []string{ + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/", + } { + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + require.Equal(t, http.StatusOK, rec.Code, path) + var got oauthex.ProtectedResourceMetadata + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got), path) + assert.Equal(t, meta.Resource, got.Resource, path) + assert.Equal(t, meta.AuthorizationServers, got.AuthorizationServers, path) + } + }) + + t.Run("no metadata route when metadata is nil (falls through to the MCP handler)", func(t *testing.T) { + rec := httptest.NewRecorder() + buildMCPMux(handler, nil).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-protected-resource", nil)) + assert.Equal(t, catchAll, rec.Code, "nil metadata must not mount a discovery route") + }) + + t.Run("catch-all routes to the MCP handler", func(t *testing.T) { + rec := httptest.NewRecorder() + buildMCPMux(handler, meta).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", nil)) + assert.Equal(t, catchAll, rec.Code) + }) +} From 82e2883ed9fd49fd79d014f0861195ca3fccab7e Mon Sep 17 00:00:00 2001 From: Fabian Gonzalez Date: Mon, 20 Jul 2026 13:03:40 -0400 Subject: [PATCH 6/9] make verify lint Signed-off-by: Fabian Gonzalez --- internal/mcp/registryserver/server_integration_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/mcp/registryserver/server_integration_test.go b/internal/mcp/registryserver/server_integration_test.go index ef17b8852..1f78044b0 100644 --- a/internal/mcp/registryserver/server_integration_test.go +++ b/internal/mcp/registryserver/server_integration_test.go @@ -175,7 +175,10 @@ func TestRunList_Authz(t *testing.T) { t.Run("hooks receive the list AuthorizeInput", func(t *testing.T) { var gotAuth, gotFilter resource.AuthorizeInput authz := func(_ context.Context, in resource.AuthorizeInput) error { gotAuth = in; return nil } - filter := func(_ context.Context, in resource.AuthorizeInput) (string, []any, error) { gotFilter = in; return "", nil, nil } + filter := func(_ context.Context, in resource.AuthorizeInput) (string, []any, error) { + gotFilter = in + return "", nil, nil + } _, _, err := runList(ctx, store, v1alpha1.KindMCPServer, authz, filter, listInput{Namespace: "default"}) require.NoError(t, err) want := resource.AuthorizeInput{Verb: "list", Kind: v1alpha1.KindMCPServer, Namespace: "default"} From d8a694182df51b407c008e1a221fbbfb60289b3f Mon Sep 17 00:00:00 2001 From: Fabian Gonzalez Date: Wed, 22 Jul 2026 12:35:24 -0400 Subject: [PATCH 7/9] add missing tools to mcp bridge Signed-off-by: Fabian Gonzalez --- internal/mcp/registryserver/server.go | 50 ++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/internal/mcp/registryserver/server.go b/internal/mcp/registryserver/server.go index 8ca02f3f3..4431300f8 100644 --- a/internal/mcp/registryserver/server.go +++ b/internal/mcp/registryserver/server.go @@ -86,6 +86,36 @@ func NewServer( Authorize: authorizers[v1alpha1.KindSkill], ListFilter: listFilters[v1alpha1.KindSkill], }) + addKindTools(server, stores[v1alpha1.KindPrompt], kindTools[*v1alpha1.Prompt]{ + Kind: v1alpha1.KindPrompt, + ListName: "list_prompts", + GetName: "get_prompt", + ListDesc: "List published prompts as v1alpha1 envelopes with optional namespace, substring-name, and tag filters.", + GetDesc: "Fetch a published prompt as a v1alpha1 envelope (defaults to the latest tag).", + NewObj: func() *v1alpha1.Prompt { return &v1alpha1.Prompt{} }, + Authorize: authorizers[v1alpha1.KindPrompt], + ListFilter: listFilters[v1alpha1.KindPrompt], + }) + addKindTools(server, stores[v1alpha1.KindModel], kindTools[*v1alpha1.Model]{ + Kind: v1alpha1.KindModel, + ListName: "list_models", + GetName: "get_model", + ListDesc: "List published models as v1alpha1 envelopes with optional namespace, substring-name, and tag filters.", + GetDesc: "Fetch a published model as a v1alpha1 envelope (defaults to the latest tag).", + NewObj: func() *v1alpha1.Model { return &v1alpha1.Model{} }, + Authorize: authorizers[v1alpha1.KindModel], + ListFilter: listFilters[v1alpha1.KindModel], + }) + addKindTools(server, stores[v1alpha1.KindPlugin], kindTools[*v1alpha1.Plugin]{ + Kind: v1alpha1.KindPlugin, + ListName: "list_plugins", + GetName: "get_plugin", + ListDesc: "List published plugins as v1alpha1 envelopes with optional namespace, substring-name, and tag filters.", + GetDesc: "Fetch a published plugin as a v1alpha1 envelope (defaults to the latest tag).", + NewObj: func() *v1alpha1.Plugin { return &v1alpha1.Plugin{} }, + Authorize: authorizers[v1alpha1.KindPlugin], + ListFilter: listFilters[v1alpha1.KindPlugin], + }) addKindTools(server, stores[v1alpha1.KindDeployment], kindTools[*v1alpha1.Deployment]{ Kind: v1alpha1.KindDeployment, ListName: "list_deployments", @@ -96,6 +126,16 @@ func NewServer( Authorize: authorizers[v1alpha1.KindDeployment], ListFilter: listFilters[v1alpha1.KindDeployment], }) + addKindTools(server, stores[v1alpha1.KindRuntime], kindTools[*v1alpha1.Runtime]{ + Kind: v1alpha1.KindRuntime, + ListName: "list_runtimes", + GetName: "get_runtime", + ListDesc: "List runtimes as v1alpha1 envelopes with optional namespace and substring-name filters.", + GetDesc: "Fetch a runtime as a v1alpha1 envelope by namespace/name.", + NewObj: func() *v1alpha1.Runtime { return &v1alpha1.Runtime{} }, + Authorize: authorizers[v1alpha1.KindRuntime], + ListFilter: listFilters[v1alpha1.KindRuntime], + }) addMetaTools(server) addServerPrompts(server) @@ -327,10 +367,10 @@ func clampLimit(limit int) int { func addServerPrompts(server *mcp.Server) { server.AddPrompt(&mcp.Prompt{ Name: "search_registry", - Description: "Search the agent registry for MCP servers, agents, skills, or deployments by keyword", + Description: "Search the agent registry for MCP servers, agents, skills, prompts, plugins, deployments, or models by keyword", Arguments: []*mcp.PromptArgument{ {Name: "query", Description: "Search term or keyword", Required: true}, - {Name: "type", Description: "Resource type to search: servers, agents, skills, or deployments (default: all)"}, + {Name: "type", Description: "Resource type to search: servers, agents, skills, prompts, plugins, deployments, or models (default: all)"}, }, }, func(_ context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { query := req.Params.Arguments["query"] @@ -340,7 +380,7 @@ func addServerPrompts(server *mcp.Server) { if resourceType != "" { instruction += " (filter to " + resourceType + " only)" } - instruction += ". Use the appropriate list tool (list_servers, list_agents, list_skills, list_deployments) with the search parameter. Summarize what you find including names, descriptions, and tags." + instruction += ". Use the appropriate list tool (list_servers, list_agents, list_skills, list_prompts, list_plugins, list_deployments, list_models) with the search parameter. Summarize what you find including names, descriptions, and tags." return &mcp.GetPromptResult{ Description: "Search the registry for resources matching a query", @@ -359,8 +399,8 @@ func addServerPrompts(server *mcp.Server) { Messages: []*mcp.PromptMessage{ {Role: "user", Content: &mcp.TextContent{ Text: "Give me an overview of what's available in the agent registry. " + - "Use list_servers, list_agents, and list_skills to see what's published. " + - "Also check list_deployments to see what's currently deployed. " + + "Use list_servers, list_agents, list_skills, list_prompts, list_plugins, and list_models to see what's published. " + + "Use list_deployments to see what's currently deployed. And use list_runtimes to view available runtimes for deployment. " + "Summarize the results in a clear table format showing name, description, and tag for each resource type.", }}, }, From 2f9c2d5a82101bffd4366f782ef779318a773caa Mon Sep 17 00:00:00 2001 From: Fabian Gonzalez Date: Wed, 22 Jul 2026 13:29:39 -0400 Subject: [PATCH 8/9] add mcp bridge tool tests Signed-off-by: Fabian Gonzalez --- .../registryserver/server_integration_test.go | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) diff --git a/internal/mcp/registryserver/server_integration_test.go b/internal/mcp/registryserver/server_integration_test.go index 1f78044b0..42d21166b 100644 --- a/internal/mcp/registryserver/server_integration_test.go +++ b/internal/mcp/registryserver/server_integration_test.go @@ -219,3 +219,208 @@ func TestGetEnvelope_Authz(t *testing.T) { assert.Equal(t, resource.AuthorizeInput{Verb: "get", Kind: v1alpha1.KindMCPServer, Namespace: ns, Name: name}, got) }) } + +// envelopeMeta decodes just the identity of a v1alpha1 envelope, so one helper +// can assert every kind's list_X/get_X output without a typed decode per kind. +type envelopeMeta struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata v1alpha1.ObjectMeta `json:"metadata"` +} + +type listEnvelope struct { + Items []envelopeMeta `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` + Count int `json:"count"` +} + +func decodeStructured(t *testing.T, structured any, out any) { + t.Helper() + raw, err := json.Marshal(structured) + require.NoError(t, err, "marshal structured output") + require.NoError(t, json.Unmarshal(raw, out), "unmarshal structured output") +} + +// TestMCPCatalogTools checks that every registered v1alpha1 kind's list_X/get_X tools +// are wired correctly. +func TestMCPCatalogTools(t *testing.T) { + ctx := context.Background() + pool := v1alpha1store.NewTestPool(t) + stores := v1alpha1store.NewStores(pool, v1alpha1store.TestSchemaRegistry()) + + const namespace = "default" + taggedTag := v1alpha1store.DefaultTag() + + cases := []struct { + kind string + listTool string + getTool string + name string + expectedTag string // DefaultTag for tagged artifacts; "" for mutable-object kinds. + seed func(t *testing.T, name string) + }{ + { + kind: v1alpha1.KindAgent, listTool: "list_agents", getTool: "get_agent", + name: "test-agent", expectedTag: taggedTag, + seed: func(t *testing.T, name string) { + _, err := stores[v1alpha1.KindAgent].Upsert(ctx, &v1alpha1.Agent{ + Metadata: v1alpha1.ObjectMeta{Namespace: namespace, Name: name}, + Spec: v1alpha1.AgentSpec{Description: "Test agent"}, + }) + require.NoError(t, err, "seed agent") + }, + }, + { + kind: v1alpha1.KindMCPServer, listTool: "list_servers", getTool: "get_server", + name: "test-server", expectedTag: taggedTag, + seed: func(t *testing.T, name string) { + _, err := stores[v1alpha1.KindMCPServer].Upsert(ctx, &v1alpha1.MCPServer{ + Metadata: v1alpha1.ObjectMeta{Namespace: namespace, Name: name}, + Spec: v1alpha1.MCPServerSpec{ + Description: "Test server", + Source: &v1alpha1.MCPServerSource{ + Package: &v1alpha1.MCPPackage{ + Origin: v1alpha1.MCPPackageOrigin{ + Type: v1alpha1.MCPPackageOriginTypeOCI, + Identifier: "ghcr.io/example/echo:1.0.0", + OCI: &v1alpha1.MCPPackageOriginOCI{ServerName: "echo"}, + }, + Transport: v1alpha1.MCPTransport{Type: "stdio"}, + }, + }, + }, + }) + require.NoError(t, err, "seed server") + }, + }, + { + kind: v1alpha1.KindSkill, listTool: "list_skills", getTool: "get_skill", + name: "test-skill", expectedTag: taggedTag, + seed: func(t *testing.T, name string) { + _, err := stores[v1alpha1.KindSkill].Upsert(ctx, &v1alpha1.Skill{ + Metadata: v1alpha1.ObjectMeta{Namespace: namespace, Name: name}, + Spec: v1alpha1.SkillSpec{Description: "Test skill"}, + }) + require.NoError(t, err, "seed skill") + }, + }, + { + kind: v1alpha1.KindPrompt, listTool: "list_prompts", getTool: "get_prompt", + name: "test-prompt", expectedTag: taggedTag, + seed: func(t *testing.T, name string) { + _, err := stores[v1alpha1.KindPrompt].Upsert(ctx, &v1alpha1.Prompt{ + Metadata: v1alpha1.ObjectMeta{Namespace: namespace, Name: name}, + Spec: v1alpha1.PromptSpec{Description: "Test prompt", Content: "hello"}, + }) + require.NoError(t, err, "seed prompt") + }, + }, + { + kind: v1alpha1.KindModel, listTool: "list_models", getTool: "get_model", + name: "test-model", expectedTag: taggedTag, + seed: func(t *testing.T, name string) { + _, err := stores[v1alpha1.KindModel].Upsert(ctx, &v1alpha1.Model{ + Metadata: v1alpha1.ObjectMeta{Namespace: namespace, Name: name}, + Spec: v1alpha1.ModelSpec{ + Description: "Test model", + Provider: v1alpha1.ModelProviderBedrock, + Model: "us.anthropic.claude-opus-4-8", + }, + }) + require.NoError(t, err, "seed model") + }, + }, + { + kind: v1alpha1.KindPlugin, listTool: "list_plugins", getTool: "get_plugin", + name: "test-plugin", expectedTag: taggedTag, + seed: func(t *testing.T, name string) { + _, err := stores[v1alpha1.KindPlugin].Upsert(ctx, &v1alpha1.Plugin{ + Metadata: v1alpha1.ObjectMeta{Namespace: namespace, Name: name}, + Spec: v1alpha1.PluginSpec{Description: "Test plugin"}, + }) + require.NoError(t, err, "seed plugin") + }, + }, + { + kind: v1alpha1.KindDeployment, listTool: "list_deployments", getTool: "get_deployment", + name: "test-deployment", expectedTag: "", + seed: func(t *testing.T, name string) { + _, err := stores[v1alpha1.KindDeployment].Upsert(ctx, &v1alpha1.Deployment{ + Metadata: v1alpha1.ObjectMeta{Namespace: namespace, Name: name}, + Spec: v1alpha1.DeploymentSpec{ + TargetRef: v1alpha1.ResourceRef{Kind: v1alpha1.KindAgent, Name: "test-agent"}, + RuntimeRef: v1alpha1.ResourceRef{Kind: v1alpha1.KindRuntime, Name: "test-runtime"}, + }, + }) + require.NoError(t, err, "seed deployment") + }, + }, + { + kind: v1alpha1.KindRuntime, listTool: "list_runtimes", getTool: "get_runtime", + name: "test-runtime", expectedTag: "", + seed: func(t *testing.T, name string) { + _, err := stores[v1alpha1.KindRuntime].Upsert(ctx, &v1alpha1.Runtime{ + Metadata: v1alpha1.ObjectMeta{Namespace: namespace, Name: name}, + Spec: v1alpha1.RuntimeSpec{Type: "docker"}, + }) + require.NoError(t, err, "seed runtime") + }, + }, + } + + server := NewServer(stores, nil, nil) + clientTransport, serverTransport := mcp.NewInMemoryTransports() + + serverSession, err := server.Connect(ctx, serverTransport, nil) + require.NoError(t, err, "connect MCP server") + defer func() { + err := serverSession.Wait() + if err != nil && !errors.Is(err, io.ErrClosedPipe) && !errors.Is(err, io.EOF) { + require.NoError(t, err) + } + }() + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + clientSession, err := client.Connect(ctx, clientTransport, nil) + require.NoError(t, err, "connect MCP client") + defer func() { _ = clientSession.Close() }() + + for _, tc := range cases { + t.Run(tc.kind, func(t *testing.T) { + tc.seed(t, tc.name) + + // list_X returns the seeded resource as a v1alpha1 envelope. + // Filter by the unique seeded name via search so the assertion holds for + // kinds that bootstrap default rows (e.g. Runtime seeds "local" and + // "kubernetes-default"). This also technically exercises the search filter. + listRes, err := clientSession.CallTool(ctx, &mcp.CallToolParams{ + Name: tc.listTool, + Arguments: map[string]any{"namespace": namespace, "search": tc.name, "limit": 10}, + }) + require.NoError(t, err, "call %s", tc.listTool) + require.NotNil(t, listRes.StructuredContent, "%s structured output present", tc.listTool) + + var list listEnvelope + decodeStructured(t, listRes.StructuredContent, &list) + require.Len(t, list.Items, 1, "%s returns the one seeded resource", tc.listTool) + got := list.Items[0] + assert.Equal(t, v1alpha1.GroupVersion, got.APIVersion) + assert.Equal(t, tc.kind, got.Kind) + assert.Equal(t, tc.name, got.Metadata.Name) + assert.Equal(t, tc.expectedTag, got.Metadata.Tag) + + // get_X fetches the same resource by name. + getRes, err := clientSession.CallTool(ctx, &mcp.CallToolParams{ + Name: tc.getTool, + Arguments: map[string]any{"namespace": namespace, "name": tc.name}, + }) + require.NoError(t, err, "call %s", tc.getTool) + require.NotNil(t, getRes.StructuredContent, "%s structured output present", tc.getTool) + + var one envelopeMeta + decodeStructured(t, getRes.StructuredContent, &one) + assert.Equal(t, tc.kind, one.Kind) + assert.Equal(t, tc.name, one.Metadata.Name) + }) + } +} From 93a5ec31407d98f9d74002ff6f0997c1e7eee2ea Mon Sep 17 00:00:00 2001 From: Fabian Gonzalez Date: Wed, 22 Jul 2026 16:13:39 -0400 Subject: [PATCH 9/9] add missing tool to mcp bridge tool description/instruction Signed-off-by: Fabian Gonzalez --- internal/mcp/registryserver/server.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/mcp/registryserver/server.go b/internal/mcp/registryserver/server.go index 4431300f8..5daf2862f 100644 --- a/internal/mcp/registryserver/server.go +++ b/internal/mcp/registryserver/server.go @@ -367,10 +367,10 @@ func clampLimit(limit int) int { func addServerPrompts(server *mcp.Server) { server.AddPrompt(&mcp.Prompt{ Name: "search_registry", - Description: "Search the agent registry for MCP servers, agents, skills, prompts, plugins, deployments, or models by keyword", + Description: "Search the agent registry for MCP servers, agents, skills, prompts, plugins, deployments, runtimes, or models by keyword", Arguments: []*mcp.PromptArgument{ {Name: "query", Description: "Search term or keyword", Required: true}, - {Name: "type", Description: "Resource type to search: servers, agents, skills, prompts, plugins, deployments, or models (default: all)"}, + {Name: "type", Description: "Resource type to search: servers, agents, skills, prompts, plugins, deployments, runtimes, or models (default: all)"}, }, }, func(_ context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { query := req.Params.Arguments["query"] @@ -380,7 +380,7 @@ func addServerPrompts(server *mcp.Server) { if resourceType != "" { instruction += " (filter to " + resourceType + " only)" } - instruction += ". Use the appropriate list tool (list_servers, list_agents, list_skills, list_prompts, list_plugins, list_deployments, list_models) with the search parameter. Summarize what you find including names, descriptions, and tags." + instruction += ". Use the appropriate list tool (list_servers, list_agents, list_skills, list_prompts, list_plugins, list_deployments, list_runtimes, list_models) with the search parameter. Summarize what you find including names, descriptions, and tags." return &mcp.GetPromptResult{ Description: "Search the registry for resources matching a query",