diff --git a/internal/mcp/registryserver/server.go b/internal/mcp/registryserver/server.go index 8ca02f3f..5daf2862 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, 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"] @@ -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", @@ -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.", }}, }, diff --git a/internal/mcp/registryserver/server_integration_test.go b/internal/mcp/registryserver/server_integration_test.go index 1f78044b..42d21166 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) + }) + } +}