Skip to content
Open
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
50 changes: 45 additions & 5 deletions internal/mcp/registryserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)

Expand Down Expand Up @@ -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, 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, or deployments (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"]
Expand All @@ -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_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",
Expand All @@ -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.",
}},
},
Expand Down
205 changes: 205 additions & 0 deletions internal/mcp/registryserver/server_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
Loading