diff --git a/go/ai/resource.go b/go/ai/resource.go index 892f255fb0..c93d08ca88 100644 --- a/go/ai/resource.go +++ b/go/ai/resource.go @@ -20,12 +20,69 @@ import ( "context" "fmt" "maps" + "net/url" + "strings" "github.com/firebase/genkit/go/core" - coreresource "github.com/firebase/genkit/go/core/resource" "github.com/firebase/genkit/go/internal/registry" + "github.com/yosida95/uritemplate/v3" ) +// normalizeURI normalizes a URI for template matching by removing query parameters, +// fragments, and trailing slashes from the path. +func normalizeURI(rawURI string) string { + // Parse the URI + u, err := url.Parse(rawURI) + if err != nil { + // If parsing fails, return the original URI + return rawURI + } + + // Remove query parameters and fragment + u.RawQuery = "" + u.Fragment = "" + + // Remove trailing slash from path (but not from the root path) + if len(u.Path) > 1 && strings.HasSuffix(u.Path, "/") { + u.Path = strings.TrimSuffix(u.Path, "/") + } + + return u.String() +} + +// matches checks if a URI matches the given URI template. +func matches(templateStr, uri string) (bool, error) { + template, err := uritemplate.New(templateStr) + if err != nil { + return false, fmt.Errorf("invalid URI template %q: %w", templateStr, err) + } + + normalizedURI := normalizeURI(uri) + values := template.Match(normalizedURI) + return len(values) > 0, nil +} + +// extractVariables extracts variables from a URI using the given URI template. +func extractVariables(templateStr, uri string) (map[string]string, error) { + template, err := uritemplate.New(templateStr) + if err != nil { + return nil, fmt.Errorf("invalid URI template %q: %w", templateStr, err) + } + + normalizedURI := normalizeURI(uri) + values := template.Match(normalizedURI) + if len(values) == 0 { + return nil, fmt.Errorf("URI %q does not match template", uri) + } + + // Convert uritemplate.Values to string map + result := make(map[string]string) + for name, value := range values { + result[name] = value.String() + } + return result, nil +} + // ResourceInput represents the input to a resource function. type ResourceInput struct { URI string `json:"uri"` // The resource URI @@ -127,11 +184,11 @@ func (r *resource) Matches(uri string) bool { // Check template if template, ok := resourceMeta["template"].(string); ok && template != "" { - matcher, err := coreresource.NewTemplateMatcher(template) + matches, err := matches(template, uri) if err != nil { return false } - return matcher.Matches(uri) + return matches } return false @@ -154,11 +211,7 @@ func (r *resource) ExtractVariables(uri string) (map[string]string, error) { // Extract from template if template, ok := resourceMeta["template"].(string); ok && template != "" { - matcher, err := coreresource.NewTemplateMatcher(template) - if err != nil { - return nil, fmt.Errorf("invalid template %q: %w", template, err) - } - return matcher.ExtractVariables(uri) + return extractVariables(template, uri) } return nil, fmt.Errorf("no URI or template found in resource metadata") diff --git a/go/core/resource/matcher.go b/go/core/resource/matcher.go deleted file mode 100644 index fc8492aba8..0000000000 --- a/go/core/resource/matcher.go +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -package resource - -import ( - "fmt" - - "github.com/yosida95/uritemplate/v3" -) - -// URIMatcher handles URI matching for resources. -// This is an internal interface used by resource implementations. -type URIMatcher interface { - Matches(uri string) bool - ExtractVariables(uri string) (map[string]string, error) -} - -// NewStaticMatcher creates a matcher for exact URI matches. -func NewStaticMatcher(uri string) URIMatcher { - return &staticMatcher{uri: uri} -} - -// NewTemplateMatcher creates a matcher for URI template patterns. -func NewTemplateMatcher(templateStr string) (URIMatcher, error) { - template, err := uritemplate.New(templateStr) - if err != nil { - return nil, fmt.Errorf("invalid URI template %q: %w", templateStr, err) - } - return &templateMatcher{template: template}, nil -} - -// staticMatcher matches exact URIs. -type staticMatcher struct { - uri string -} - -func (m *staticMatcher) Matches(uri string) bool { - return m.uri == uri -} - -func (m *staticMatcher) ExtractVariables(uri string) (map[string]string, error) { - if !m.Matches(uri) { - return nil, fmt.Errorf("URI %q does not match static URI %q", uri, m.uri) - } - return map[string]string{}, nil -} - -// templateMatcher matches URI templates. -type templateMatcher struct { - template *uritemplate.Template -} - -func (m *templateMatcher) Matches(uri string) bool { - values := m.template.Match(uri) - return len(values) > 0 -} - -func (m *templateMatcher) ExtractVariables(uri string) (map[string]string, error) { - values := m.template.Match(uri) - if len(values) == 0 { - return nil, fmt.Errorf("URI %q does not match template", uri) - } - - // Convert uritemplate.Values to string map - result := make(map[string]string) - for name, value := range values { - result[name] = value.String() - } - return result, nil -} diff --git a/go/genkit/genkit.go b/go/genkit/genkit.go index e3d2cf335e..bf3cb550a9 100644 --- a/go/genkit/genkit.go +++ b/go/genkit/genkit.go @@ -25,7 +25,6 @@ import ( "os" "os/signal" "path/filepath" - "strings" "syscall" "github.com/firebase/genkit/go/ai" @@ -385,11 +384,9 @@ func ListTools(g *Genkit) []ai.Tool { continue } actionDesc := action.Desc() - if strings.HasPrefix(actionDesc.Key, "/"+string(core.ActionTypeTool)+"/") { - // Extract tool name from key - toolName := strings.TrimPrefix(actionDesc.Key, "/"+string(core.ActionTypeTool)+"/") - // Lookup the actual tool - tool := LookupTool(g, toolName) + if actionDesc.Type == core.ActionTypeTool { + // Lookup the actual tool using the action name + tool := LookupTool(g, actionDesc.Name) if tool != nil { tools = append(tools, tool) } @@ -1038,11 +1035,10 @@ func ListResources(g *Genkit) []ai.Resource { } actionDesc := action.Desc() if actionDesc.Type == core.ActionTypeResource { - // Look up the resource wrapper - if resourceValue := g.reg.LookupValue(fmt.Sprintf("resource/%s", actionDesc.Name)); resourceValue != nil { - if resource, ok := resourceValue.(ai.Resource); ok { - resources = append(resources, resource) - } + // Lookup the actual resource using the action name + resource := ai.LookupResource(g.reg, actionDesc.Name) + if resource != nil { + resources = append(resources, resource) } } } diff --git a/go/plugins/mcp/common.go b/go/plugins/mcp/common.go index 070f9e5176..c942f6c5bc 100644 --- a/go/plugins/mcp/common.go +++ b/go/plugins/mcp/common.go @@ -17,7 +17,6 @@ package mcp import ( "fmt" - "strings" "github.com/mark3labs/mcp-go/mcp" ) @@ -32,19 +31,9 @@ func (c *GenkitMCPClient) GetToolNameWithNamespace(toolName string) string { return fmt.Sprintf("%s_%s", c.options.Name, toolName) } -// ContentToText extracts text content from MCP Content -func ContentToText(contentList []mcp.Content) string { - var textParts []string - for _, contentItem := range contentList { - if textContent, ok := contentItem.(mcp.TextContent); ok && textContent.Type == "text" { - textParts = append(textParts, textContent.Text) - } else if erContent, ok := contentItem.(mcp.EmbeddedResource); ok { - if trc, ok := erContent.Resource.(mcp.TextResourceContents); ok { - textParts = append(textParts, trc.Text) - } - } - } - return strings.Join(textParts, "") +// GetResourceNameWithNamespace returns a resource name prefixed with the client's namespace +func (c *GenkitMCPClient) GetResourceNameWithNamespace(resourceName string) string { + return fmt.Sprintf("%s_%s", c.options.Name, resourceName) } // ExtractTextFromContent extracts text content from MCP Content diff --git a/go/plugins/mcp/fixtures/basic_server/basic_server.go b/go/plugins/mcp/fixtures/basic_server/basic_server.go new file mode 100644 index 0000000000..4597fb077f --- /dev/null +++ b/go/plugins/mcp/fixtures/basic_server/basic_server.go @@ -0,0 +1,37 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/mcp" +) + +func main() { + g := genkit.Init(context.Background()) + genkit.DefineResource(g, "test-docs", &ai.ResourceOptions{ + Template: "file://test/{filename}", + }, func(ctx context.Context, input *ai.ResourceInput) (*ai.ResourceOutput, error) { + return &ai.ResourceOutput{ + Content: []*ai.Part{ai.NewTextPart("test content")}, + }, nil + }) + + server := mcp.NewMCPServer(g, mcp.MCPServerOptions{Name: "test"}) + server.ServeStdio() +} diff --git a/go/plugins/mcp/fixtures/content_server/content_server.go b/go/plugins/mcp/fixtures/content_server/content_server.go new file mode 100644 index 0000000000..e1f7071f03 --- /dev/null +++ b/go/plugins/mcp/fixtures/content_server/content_server.go @@ -0,0 +1,42 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "fmt" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/mcp" +) + +func main() { + g := genkit.Init(context.Background()) + + // Resource that provides different content based on filename + genkit.DefineResource(g, "content-provider", &ai.ResourceOptions{ + Template: "file://data/{filename}", + }, func(ctx context.Context, input *ai.ResourceInput) (*ai.ResourceOutput, error) { + filename := input.Variables["filename"] + content := fmt.Sprintf("CONTENT_FROM_SERVER: This is %s with important data.", filename) + return &ai.ResourceOutput{ + Content: []*ai.Part{ai.NewTextPart(content)}, + }, nil + }) + + server := mcp.NewMCPServer(g, mcp.MCPServerOptions{Name: "content-server"}) + server.ServeStdio() +} diff --git a/go/plugins/mcp/fixtures/policy_server/policy_server.go b/go/plugins/mcp/fixtures/policy_server/policy_server.go new file mode 100644 index 0000000000..7711d67831 --- /dev/null +++ b/go/plugins/mcp/fixtures/policy_server/policy_server.go @@ -0,0 +1,36 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/mcp" +) + +func main() { + g := genkit.Init(context.Background()) + genkit.DefineResource(g, "company-policy", &ai.ResourceOptions{ + Template: "docs://policy/{section}", + }, func(ctx context.Context, input *ai.ResourceInput) (*ai.ResourceOutput, error) { + return &ai.ResourceOutput{ + Content: []*ai.Part{ai.NewTextPart("VACATION_POLICY: Employees get 20 days vacation per year.")}, + }, nil + }) + server := mcp.NewMCPServer(g, mcp.MCPServerOptions{Name: "test"}) + server.ServeStdio() +} diff --git a/go/plugins/mcp/fixtures/server_a/server_a.go b/go/plugins/mcp/fixtures/server_a/server_a.go new file mode 100644 index 0000000000..6926cc42ee --- /dev/null +++ b/go/plugins/mcp/fixtures/server_a/server_a.go @@ -0,0 +1,36 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/mcp" +) + +func main() { + g := genkit.Init(context.Background()) + genkit.DefineResource(g, "server-a-docs", &ai.ResourceOptions{ + Template: "a://docs/{file}", + }, func(ctx context.Context, input *ai.ResourceInput) (*ai.ResourceOutput, error) { + return &ai.ResourceOutput{ + Content: []*ai.Part{ai.NewTextPart("Content from Server A")}, + }, nil + }) + server := mcp.NewMCPServer(g, mcp.MCPServerOptions{Name: "server-a"}) + server.ServeStdio() +} diff --git a/go/plugins/mcp/fixtures/server_b/server_b.go b/go/plugins/mcp/fixtures/server_b/server_b.go new file mode 100644 index 0000000000..e682f6b3bc --- /dev/null +++ b/go/plugins/mcp/fixtures/server_b/server_b.go @@ -0,0 +1,36 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/mcp" +) + +func main() { + g := genkit.Init(context.Background()) + genkit.DefineResource(g, "server-b-files", &ai.ResourceOptions{ + Template: "b://files/{path}", + }, func(ctx context.Context, input *ai.ResourceInput) (*ai.ResourceOutput, error) { + return &ai.ResourceOutput{ + Content: []*ai.Part{ai.NewTextPart("Content from Server B")}, + }, nil + }) + server := mcp.NewMCPServer(g, mcp.MCPServerOptions{Name: "server-b"}) + server.ServeStdio() +} diff --git a/go/plugins/mcp/manager.go b/go/plugins/mcp/host.go similarity index 55% rename from go/plugins/mcp/manager.go rename to go/plugins/mcp/host.go index 2c69dae2da..d5b73cc6bb 100644 --- a/go/plugins/mcp/manager.go +++ b/go/plugins/mcp/host.go @@ -31,25 +31,26 @@ type MCPServerConfig struct { Config MCPClientOptions } -// MCPManagerOptions holds configuration for MCPManager -type MCPManagerOptions struct { - // Name for this manager instance - used for logging and identification +// MCPHostOptions holds configuration for MCPHost +type MCPHostOptions struct { + // Name for this host instance - used for logging and identification Name string - // Version number for this manager (defaults to "1.0.0" if empty) + // Version number for this host (defaults to "1.0.0" if empty) Version string // MCPServers is an array of server configurations MCPServers []MCPServerConfig } -// MCPManager manages connections to multiple MCP servers -type MCPManager struct { +// MCPHost manages connections to multiple MCP servers +// This matches the naming convention used in the JavaScript implementation +type MCPHost struct { name string version string clients map[string]*GenkitMCPClient // Internal map for efficient lookups } -// NewMCPManager creates a new MCPManager with the given options -func NewMCPManager(options MCPManagerOptions) (*MCPManager, error) { +// NewMCPHost creates a new MCPHost with the given options +func NewMCPHost(g *genkit.Genkit, options MCPHostOptions) (*MCPHost, error) { // Set default values if options.Name == "" { options.Name = "genkit-mcp" @@ -58,7 +59,7 @@ func NewMCPManager(options MCPManagerOptions) (*MCPManager, error) { options.Version = "1.0.0" } - manager := &MCPManager{ + host := &MCPHost{ name: options.Name, version: options.Version, clients: make(map[string]*GenkitMCPClient), @@ -67,25 +68,26 @@ func NewMCPManager(options MCPManagerOptions) (*MCPManager, error) { // Connect to all servers synchronously during initialization ctx := context.Background() for _, serverConfig := range options.MCPServers { - if err := manager.Connect(ctx, serverConfig.Name, serverConfig.Config); err != nil { - logger.FromContext(ctx).Error("Failed to connect to MCP server", "server", serverConfig.Name, "manager", manager.name, "error", err) + if err := host.Connect(ctx, g, serverConfig.Name, serverConfig.Config); err != nil { + logger.FromContext(ctx).Error("Failed to connect to MCP server", "server", serverConfig.Name, "host", host.name, "error", err) // Continue with other servers } } - return manager, nil + return host, nil } // Connect connects to a single MCP server with the provided configuration -func (m *MCPManager) Connect(ctx context.Context, serverName string, config MCPClientOptions) error { +// and automatically registers tools, prompts, and resources from the server +func (h *MCPHost) Connect(ctx context.Context, g *genkit.Genkit, serverName string, config MCPClientOptions) error { // If a client with this name already exists, disconnect it first - if existingClient, exists := m.clients[serverName]; exists { + if existingClient, exists := h.clients[serverName]; exists { if err := existingClient.Disconnect(); err != nil { - logger.FromContext(ctx).Warn("Error disconnecting existing MCP client", "server", serverName, "manager", m.name, "error", err) + logger.FromContext(ctx).Warn("Error disconnecting existing MCP client", "server", serverName, "host", h.name, "error", err) } } - logger.FromContext(ctx).Info("Connecting to MCP server", "server", serverName, "manager", m.name) + logger.FromContext(ctx).Info("Connecting to MCP server", "server", serverName, "host", h.name) // Set the server name in the config if config.Name == "" { @@ -98,37 +100,48 @@ func (m *MCPManager) Connect(ctx context.Context, serverName string, config MCPC return fmt.Errorf("error connecting to server %s: %w", serverName, err) } - m.clients[serverName] = client + h.clients[serverName] = client + return nil } // Disconnect disconnects from a specific MCP server -func (m *MCPManager) Disconnect(ctx context.Context, serverName string) error { - client, exists := m.clients[serverName] +func (h *MCPHost) Disconnect(ctx context.Context, serverName string) error { + client, exists := h.clients[serverName] if !exists { return fmt.Errorf("no client found with name '%s'", serverName) } - logger.FromContext(ctx).Info("Disconnecting MCP server", "server", serverName, "manager", m.name) + logger.FromContext(ctx).Info("Disconnecting MCP server", "server", serverName, "host", h.name) err := client.Disconnect() - delete(m.clients, serverName) + delete(h.clients, serverName) return err } +// Reconnect restarts a specific MCP server connection +func (h *MCPHost) Reconnect(ctx context.Context, serverName string) error { + client, exists := h.clients[serverName] + if !exists { + return fmt.Errorf("no client found with name '%s'", serverName) + } + + logger.FromContext(ctx).Info("Reconnecting MCP server", "server", serverName, "host", h.name) + return client.Restart(ctx) +} + // GetActiveTools retrieves all tools from all connected and enabled MCP clients -func (m *MCPManager) GetActiveTools(ctx context.Context, gk *genkit.Genkit) ([]ai.Tool, error) { +func (h *MCPHost) GetActiveTools(ctx context.Context, gk *genkit.Genkit) ([]ai.Tool, error) { var allTools []ai.Tool - // Simple sequential iteration - fast enough for typical usage (1-5 clients) - for name, client := range m.clients { + for name, client := range h.clients { if !client.IsEnabled() { continue } tools, err := client.GetActiveTools(ctx, gk) if err != nil { - logger.FromContext(ctx).Error("Error fetching tools from MCP client", "client", name, "manager", m.name, "error", err) + logger.FromContext(ctx).Error("Error fetching tools from MCP client", "client", name, "host", h.name, "error", err) continue } @@ -138,9 +151,29 @@ func (m *MCPManager) GetActiveTools(ctx context.Context, gk *genkit.Genkit) ([]a return allTools, nil } +// GetActiveResources retrieves detached resources from all connected and enabled MCP clients +func (h *MCPHost) GetActiveResources(ctx context.Context) ([]ai.Resource, error) { + var allResources []ai.Resource + + for name, client := range h.clients { + if !client.IsEnabled() { + continue + } + + resources, err := client.GetActiveResources(ctx) + if err != nil { + logger.FromContext(ctx).Error("Error fetching resources from MCP client", "client", name, "host", h.name, "error", err) + continue + } + allResources = append(allResources, resources...) + } + + return allResources, nil +} + // GetPrompt retrieves a specific prompt from a specific server -func (m *MCPManager) GetPrompt(ctx context.Context, gk *genkit.Genkit, serverName, promptName string, args map[string]string) (ai.Prompt, error) { - client, exists := m.clients[serverName] +func (h *MCPHost) GetPrompt(ctx context.Context, gk *genkit.Genkit, serverName, promptName string, args map[string]string) (ai.Prompt, error) { + client, exists := h.clients[serverName] if !exists { return nil, fmt.Errorf("no client found with name '%s'", serverName) } diff --git a/go/plugins/mcp/integration_test.go b/go/plugins/mcp/integration_test.go new file mode 100644 index 0000000000..fded91f050 --- /dev/null +++ b/go/plugins/mcp/integration_test.go @@ -0,0 +1,505 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcp + +import ( + "context" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + "github.com/stretchr/testify/assert" +) + +// TestMCPConnectionAndTranslation tests the full integration between +// MCP servers and Genkit resources. +// +// This test validates: +// 1. MCP server connection (real stdio connection) +// 2. Resource discovery from MCP server +// 3. Translation of MCP templates to Genkit resources +// 4. URI matching works end-to-end +func TestMCPConnectionAndTranslation(t *testing.T) { + // SETUP: Build test server from fixture + ctx := context.Background() + + // Build test server from fixtures/basic_server/ + testDir := t.TempDir() + serverBinary := filepath.Join(testDir, "basic_server") + + cmd := exec.Command("go", "build", "-o", serverBinary, "./fixtures/basic_server") + err := cmd.Run() + assert.NoError(t, err) + + // SETUP: Genkit client + g := genkit.Init(ctx) + + host, err := NewMCPHost(g, MCPHostOptions{ + Name: "test-host", + }) + assert.NoError(t, err) + + // TEST: Connect to MCP server + err = host.Connect(ctx, g, "test-server", MCPClientOptions{ + Name: "test-server", + Stdio: &StdioConfig{ + Command: serverBinary, + }, + }) + + // ASSERT 1: Connection succeeds + assert.NoError(t, err) + + // TEST: Discover resources + resources, err := host.GetActiveResources(ctx) + + // ASSERT 2: Resources discovered + assert.NoError(t, err) + assert.Greater(t, len(resources), 0, "Should discover at least 1 resource from test server") + + // ASSERT 3: MCP template became Genkit resource + found := false + testURI := "file://test/README.md" + for _, res := range resources { + if res.Matches(testURI) { + found = true + break + } + } + assert.True(t, found, "Template 'file://test/{filename}' should match 'file://test/README.md'") +} + +// TestMCPAIIntegration tests that MCP resources work with AI generation. +// +// This test validates: +// 1. MCP resources can be used in AI.Generate() +// 2. Resource content is properly resolved and included in prompts +// 3. End-to-end AI generation with MCP resources +func TestMCPAIIntegration(t *testing.T) { + // SETUP: Build test server from fixture + ctx := context.Background() + + // Build test server from fixtures/policy_server/ + testDir := t.TempDir() + serverBinary := filepath.Join(testDir, "policy_server") + + cmd := exec.Command("go", "build", "-o", serverBinary, "./fixtures/policy_server") + err := cmd.Run() + assert.NoError(t, err) + + // SETUP: Genkit with MCP and mock model + g := genkit.Init(ctx) + + // Define a mock model that echoes the input (like in resource_test.go) + genkit.DefineModel(g, "echo-model", &ai.ModelOptions{ + Label: "Mock Echo Model for Testing", + Supports: &ai.ModelSupports{}, + }, func(ctx context.Context, req *ai.ModelRequest, cb ai.ModelStreamCallback) (*ai.ModelResponse, error) { + // Echo back all the content to verify resources were included + var parts []*ai.Part + for _, msg := range req.Messages { + parts = append(parts, msg.Content...) + } + return &ai.ModelResponse{ + Message: &ai.Message{ + Content: parts, + Role: "model", + }, + }, nil + }) + + host, err := NewMCPHost(g, MCPHostOptions{Name: "test-host"}) + assert.NoError(t, err) + + err = host.Connect(ctx, g, "policy-server", MCPClientOptions{ + Name: "policy-server", + Stdio: &StdioConfig{Command: serverBinary}, + }) + assert.NoError(t, err) + + // Get resources from MCP (like mcp-client sample) + hostResources, err := host.GetActiveResources(ctx) + assert.NoError(t, err) + assert.Greater(t, len(hostResources), 0, "Should have MCP resources") + + // TEST: AI generation with MCP resources (like resource_test.go) + resp, err := genkit.Generate(ctx, g, + ai.WithModelName("echo-model"), + ai.WithMessages(ai.NewUserMessage( + ai.NewTextPart("Policy summary:"), + ai.NewResourcePart("docs://policy/vacation"), // Reference MCP resource + ai.NewTextPart("That's the policy."), + )), + ai.WithResources(hostResources...), // Pass MCP resources + ) + + // ASSERT: Generation succeeds and includes resource content + assert.NoError(t, err) + assert.NotNil(t, resp) + + result := resp.Text() + t.Logf("AI response: %s", result) + + // Verify resource content was resolved and included + assert.Contains(t, result, "VACATION_POLICY", "Should include resource content") + assert.Contains(t, result, "20 days vacation", "Should include specific policy details") +} + +// TestMCPURIMatching tests URI template matching edge cases. +// +// This test validates: +// 1. Basic URI template matching works +// 2. Edge cases work (trailing slashes, query params, fragments) +// 3. Variable extraction is correct +// +// This covers the URI normalization fixes we implemented after noticing some unhandled cases in our URI template library. +func TestMCPURIMatching(t *testing.T) { + // SETUP: Build test server from fixture + ctx := context.Background() + + testDir := t.TempDir() + serverBinary := filepath.Join(testDir, "content_server") + + cmd := exec.Command("go", "build", "-o", serverBinary, "./fixtures/content_server") + err := cmd.Run() + assert.NoError(t, err) + + // SETUP: Genkit with MCP + g := genkit.Init(ctx) + + host, err := NewMCPHost(g, MCPHostOptions{Name: "test-host"}) + assert.NoError(t, err) + + err = host.Connect(ctx, g, "content-server", MCPClientOptions{ + Name: "content-server", + Stdio: &StdioConfig{Command: serverBinary}, + }) + assert.NoError(t, err) + + // Get resources to test + resources, err := host.GetActiveResources(ctx) + assert.NoError(t, err) + assert.Greater(t, len(resources), 0, "Should have resources") + + // TEST: Edge cases that should work with our normalizeURI fixes + testCases := []struct { + name string + uri string + }{ + {"basic", "file://data/test.txt"}, + {"trailing slash", "file://data/test.txt/"}, + {"query params", "file://data/test.txt?version=1"}, + {"fragment", "file://data/test.txt#section1"}, + {"query and fragment", "file://data/test.txt?v=1#top"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // ASSERT: URI matches the template + found := false + for _, res := range resources { + if res.Matches(tc.uri) { + found = true + break + } + } + assert.True(t, found, "URI %s should match template file://data/{filename}", tc.uri) + }) + } +} + +// TestMCPContentFetch tests actual content retrieval from MCP servers. +// +// This test validates: +// 1. Content can be fetched from MCP resources +// 2. Content has the expected format and structure +// 3. Variable substitution works correctly +// +// This tests the core content delivery functionality. +func TestMCPContentFetch(t *testing.T) { + // SETUP: Build test server from fixture + ctx := context.Background() + + testDir := t.TempDir() + serverBinary := filepath.Join(testDir, "content_server") + + cmd := exec.Command("go", "build", "-o", serverBinary, "./fixtures/content_server") + err := cmd.Run() + assert.NoError(t, err) + + // SETUP: Genkit with MCP + g := genkit.Init(ctx) + + host, err := NewMCPHost(g, MCPHostOptions{Name: "test-host"}) + assert.NoError(t, err) + + err = host.Connect(ctx, g, "content-server", MCPClientOptions{ + Name: "content-server", + Stdio: &StdioConfig{Command: serverBinary}, + }) + assert.NoError(t, err) + + // TEST: Get resources and find matching one + resources, err := host.GetActiveResources(ctx) + assert.NoError(t, err) + assert.Greater(t, len(resources), 0, "Should have resources") + + // Find resource that matches our test URI + testURI := "file://data/example.txt" + var matchingResource ai.Resource + for _, res := range resources { + if res.Matches(testURI) { + matchingResource = res + break + } + } + + // ASSERT 1: Resource found + assert.NotNil(t, matchingResource, "Should find matching resource for %s", testURI) + + // ASSERT 2: Content can be fetched via AI integration (end-to-end test) + genkit.DefineModel(g, "echo-model", &ai.ModelOptions{ + Supports: &ai.ModelSupports{}, + }, func(ctx context.Context, req *ai.ModelRequest, cb ai.ModelStreamCallback) (*ai.ModelResponse, error) { + // Echo back all content to verify resources were included + var parts []*ai.Part + for _, msg := range req.Messages { + parts = append(parts, msg.Content...) + } + return &ai.ModelResponse{ + Message: &ai.Message{ + Content: parts, + Role: "model", + }, + }, nil + }) + + // TEST: AI generation with MCP resource to verify content fetch + resp, err := genkit.Generate(ctx, g, + ai.WithModelName("echo-model"), + ai.WithMessages(ai.NewUserMessage( + ai.NewTextPart("Content:"), + ai.NewResourcePart(testURI), // This should fetch content from MCP + ai.NewTextPart("End."), + )), + ai.WithResources(resources...), // Pass all MCP resources + ) + + // ASSERT 3: Generation succeeds and includes resource content + assert.NoError(t, err) + assert.NotNil(t, resp) + + result := resp.Text() + t.Logf("AI response with resource content: %s", result) + + // ASSERT 4: Content was fetched and included + assert.Contains(t, result, "CONTENT_FROM_SERVER", "Should include server identifier") + assert.Contains(t, result, "example.txt", "Should include the filename variable") + assert.Contains(t, result, "with important data.", "Should include expected content") +} + +// TestMCPMultipleServers tests connecting to multiple MCP servers simultaneously. +// +// This test validates: +// 1. Multiple MCP servers can be connected at once +// 2. Resources from all servers are discoverable +// 3. Resources from different servers don't conflict +// +// This tests advanced multi-server scenarios. +func TestMCPMultipleServers(t *testing.T) { + // SETUP: Build both test servers + ctx := context.Background() + + testDir := t.TempDir() + serverA := filepath.Join(testDir, "server_a") + serverB := filepath.Join(testDir, "server_b") + + cmdA := exec.Command("go", "build", "-o", serverA, "./fixtures/server_a") + err := cmdA.Run() + assert.NoError(t, err) + + cmdB := exec.Command("go", "build", "-o", serverB, "./fixtures/server_b") + err = cmdB.Run() + assert.NoError(t, err) + + // SETUP: Genkit with MCP host + g := genkit.Init(ctx) + + host, err := NewMCPHost(g, MCPHostOptions{Name: "multi-host"}) + assert.NoError(t, err) + + // CONNECT: To both servers + err = host.Connect(ctx, g, "server-a", MCPClientOptions{ + Name: "server-a", + Stdio: &StdioConfig{Command: serverA}, + }) + assert.NoError(t, err) + + err = host.Connect(ctx, g, "server-b", MCPClientOptions{ + Name: "server-b", + Stdio: &StdioConfig{Command: serverB}, + }) + assert.NoError(t, err) + + // TEST: Get all resources from both servers + allResources, err := host.GetActiveResources(ctx) + assert.NoError(t, err) + + // ASSERT: Resources from both servers are present + assert.GreaterOrEqual(t, len(allResources), 2, "Should have resources from both servers") + + // ASSERT: Can identify resources from each server + serverAResources := 0 + serverBResources := 0 + + for _, res := range allResources { + name := res.Name() + if strings.Contains(name, "server-a") { + serverAResources++ + // Test server A resource matches its URI pattern + assert.True(t, res.Matches("a://docs/test.md"), "Server A resource should match a:// pattern") + } else if strings.Contains(name, "server-b") { + serverBResources++ + // Test server B resource matches its URI pattern + assert.True(t, res.Matches("b://files/data.json"), "Server B resource should match b:// pattern") + } + } + + // ASSERT: Both servers contributed resources + assert.Greater(t, serverAResources, 0, "Should have resources from server A") + assert.Greater(t, serverBResources, 0, "Should have resources from server B") + + t.Logf("Found %d resources from server A, %d from server B", serverAResources, serverBResources) +} + +// TestMCPErrorResilience tests error handling and graceful failure scenarios. +// +// This test validates: +// 1. Connection failures fail gracefully without crashes +// 2. Invalid/malformed content is handled properly +// 3. Resource not found scenarios provide clear errors +// +// This covers the most common real-world failure scenarios. +func TestMCPErrorResilience(t *testing.T) { + ctx := context.Background() + g := genkit.Init(ctx) + + // TEST 1: Server connection failure (fast!) + t.Run("connection_failure", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + host, err := NewMCPHost(g, MCPHostOptions{Name: "error-test"}) + assert.NoError(t, err) + + // Try to connect to non-existent command + err = host.Connect(ctx, g, "bad-server", MCPClientOptions{ + Name: "bad-server", + Stdio: &StdioConfig{Command: "/nonexistent/command"}, + }) + + // ASSERT: Graceful failure, not crash (fails in ~100ms) + assert.Error(t, err) // Any connection failure is fine + t.Logf("Connection failure handled gracefully: %v", err) + }) + + // TEST 2: Resource not found scenario + t.Run("resource_not_found", func(t *testing.T) { + // Setup working server first + testDir := t.TempDir() + serverBinary := filepath.Join(testDir, "basic_server") + + cmd := exec.Command("go", "build", "-o", serverBinary, "./fixtures/basic_server") + err := cmd.Run() + assert.NoError(t, err) + + host, err := NewMCPHost(g, MCPHostOptions{Name: "test-host"}) + assert.NoError(t, err) + + err = host.Connect(ctx, g, "test-server", MCPClientOptions{ + Name: "test-server", + Stdio: &StdioConfig{Command: serverBinary}, + }) + assert.NoError(t, err) + + // Try to find non-existent resource + resources, err := host.GetActiveResources(ctx) + assert.NoError(t, err) + + // Test URI that won't match any resource + nonExistentURI := "file://nonexistent/path.txt" + found := false + for _, res := range resources { + if res.Matches(nonExistentURI) { + found = true + break + } + } + + // ASSERT: Resource not found, but no crash + assert.False(t, found, "Non-existent resource should not match") + t.Logf("Resource not found handled gracefully for URI: %s", nonExistentURI) + }) + + // TEST 3: Invalid URI patterns + t.Run("invalid_uri_patterns", func(t *testing.T) { + testDir := t.TempDir() + serverBinary := filepath.Join(testDir, "basic_server") + + cmd := exec.Command("go", "build", "-o", serverBinary, "./fixtures/basic_server") + err := cmd.Run() + assert.NoError(t, err) + + host, err := NewMCPHost(g, MCPHostOptions{Name: "test-host"}) + assert.NoError(t, err) + + err = host.Connect(ctx, g, "test-server", MCPClientOptions{ + Name: "test-server", + Stdio: &StdioConfig{Command: serverBinary}, + }) + assert.NoError(t, err) + + resources, err := host.GetActiveResources(ctx) + assert.NoError(t, err) + + // Test various malformed URIs + malformedURIs := []string{ + "", // Empty URI + "not-a-uri", // Not a URI + "://missing-scheme", // Missing scheme + "file://", // Empty path + } + + for _, uri := range malformedURIs { + t.Run("malformed_uri_"+uri, func(t *testing.T) { + // Test that malformed URIs don't crash the system + found := false + for _, res := range resources { + // This should not panic or crash + if res.Matches(uri) { + found = true + break + } + } + // We don't care if it matches or not, just that it doesn't crash + t.Logf("Malformed URI '%s' handled without crash, found=%v", uri, found) + }) + } + }) +} diff --git a/go/plugins/mcp/resources.go b/go/plugins/mcp/resources.go new file mode 100644 index 0000000000..5715721b1e --- /dev/null +++ b/go/plugins/mcp/resources.go @@ -0,0 +1,291 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcp + +import ( + "context" + "fmt" + + "github.com/firebase/genkit/go/ai" + "github.com/mark3labs/mcp-go/mcp" +) + +// GetActiveResources fetches resources from the MCP server +func (c *GenkitMCPClient) GetActiveResources(ctx context.Context) ([]ai.Resource, error) { + if !c.IsEnabled() || c.server == nil { + return nil, fmt.Errorf("MCP client is disabled or not connected") + } + + var resources []ai.Resource + + // Fetch static resources + staticResources, err := c.getStaticResources(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get resources from %s: %w", c.options.Name, err) + } + resources = append(resources, staticResources...) + + // Fetch template resources (optional - not all servers support templates) + templateResources, err := c.getTemplateResources(ctx) + if err != nil { + // Templates not supported by all servers, continue without them + return resources, nil + } + resources = append(resources, templateResources...) + + return resources, nil +} + +// getStaticResources retrieves and converts static MCP resources to Genkit resources +func (c *GenkitMCPClient) getStaticResources(ctx context.Context) ([]ai.Resource, error) { + mcpResources, err := c.getResources(ctx) + if err != nil { + return nil, err + } + + var resources []ai.Resource + for _, mcpResource := range mcpResources { + resource, err := c.toGenkitResource(mcpResource) + if err != nil { + return nil, fmt.Errorf("failed to create resource %s: %w", mcpResource.Name, err) + } + resources = append(resources, resource) + } + return resources, nil +} + +// getTemplateResources retrieves and converts MCP resource templates to Genkit resources +func (c *GenkitMCPClient) getTemplateResources(ctx context.Context) ([]ai.Resource, error) { + mcpTemplates, err := c.getResourceTemplates(ctx) + if err != nil { + return nil, err + } + + var resources []ai.Resource + for _, mcpTemplate := range mcpTemplates { + resource, err := c.toGenkitResourceTemplate(mcpTemplate) + if err != nil { + return nil, fmt.Errorf("failed to create resource template %s: %w", mcpTemplate.Name, err) + } + resources = append(resources, resource) + } + return resources, nil +} + +// toGenkitResource creates a Genkit resource from an MCP static resource +func (c *GenkitMCPClient) toGenkitResource(mcpResource mcp.Resource) (ai.Resource, error) { + // Create namespaced resource name + resourceName := c.GetResourceNameWithNamespace(mcpResource.Name) + + // Create Genkit resource that bridges to MCP + return ai.NewResource(resourceName, &ai.ResourceOptions{ + URI: mcpResource.URI, + Description: mcpResource.Description, + Metadata: map[string]any{ + "mcp_server": c.options.Name, + "mcp_name": mcpResource.Name, + "source": "mcp", + "mime_type": mcpResource.MIMEType, + }, + }, func(ctx context.Context, input *ai.ResourceInput) (*ai.ResourceOutput, error) { + output, err := c.readMCPResource(ctx, input.URI) + if err != nil { + return nil, err + } + return &ai.ResourceOutput{Content: output.Content}, nil + }), nil +} + +// toGenkitResourceTemplate creates a Genkit template resource from MCP template +func (c *GenkitMCPClient) toGenkitResourceTemplate(mcpTemplate mcp.ResourceTemplate) (ai.Resource, error) { + resourceName := c.GetResourceNameWithNamespace(mcpTemplate.Name) + + // Convert URITemplate to string - extract the raw template string + var templateStr string + if mcpTemplate.URITemplate != nil && mcpTemplate.URITemplate.Template != nil { + templateStr = mcpTemplate.URITemplate.Template.Raw() + } + + // Validate template - return error instead of panicking + if templateStr == "" { + return nil, fmt.Errorf("MCP resource template %s has empty URI template", mcpTemplate.Name) + } + + return ai.NewResource(resourceName, &ai.ResourceOptions{ + Template: templateStr, + Description: mcpTemplate.Description, + Metadata: map[string]any{ + "mcp_server": c.options.Name, + "mcp_name": mcpTemplate.Name, + "mcp_template": templateStr, + "source": "mcp", + "mime_type": mcpTemplate.MIMEType, + }, + }, func(ctx context.Context, input *ai.ResourceInput) (*ai.ResourceOutput, error) { + output, err := c.readMCPResource(ctx, input.URI) + if err != nil { + return nil, err + } + return &ai.ResourceOutput{Content: output.Content}, nil + }), nil +} + +// readMCPResource fetches content from MCP server for a given URI +func (c *GenkitMCPClient) readMCPResource(ctx context.Context, uri string) (ai.ResourceOutput, error) { + if !c.IsEnabled() || c.server == nil { + return ai.ResourceOutput{}, fmt.Errorf("MCP client is disabled or not connected") + } + + // Create ReadResource request + readReq := mcp.ReadResourceRequest{ + Params: struct { + URI string `json:"uri"` + Arguments map[string]interface{} `json:"arguments,omitempty"` + }{ + URI: uri, + Arguments: nil, + }, + } + + // Call the MCP server to read the resource + readResp, err := c.server.Client.ReadResource(ctx, readReq) + if err != nil { + return ai.ResourceOutput{}, fmt.Errorf("failed to read resource from MCP server %s: %w", c.options.Name, err) + } + + // Convert MCP ResourceContents to Genkit Parts + parts, err := convertMCPResourceContentsToGenkitParts(readResp.Contents) + if err != nil { + return ai.ResourceOutput{}, fmt.Errorf("failed to convert MCP resource contents to Genkit parts: %w", err) + } + + return ai.ResourceOutput{Content: parts}, nil +} + +// getResources retrieves all resources from the MCP server by paginating through results +func (c *GenkitMCPClient) getResources(ctx context.Context) ([]mcp.Resource, error) { + var allResources []mcp.Resource + var cursor mcp.Cursor + + // Paginate through all available resources from the MCP server + for { + // Fetch a page of resources + resources, nextCursor, err := c.fetchResourcesPage(ctx, cursor) + if err != nil { + return nil, err + } + + allResources = append(allResources, resources...) + + // Check if we've reached the last page + cursor = nextCursor + if cursor == "" { + break + } + } + + return allResources, nil +} + +// fetchResourcesPage retrieves a single page of resources from the MCP server +func (c *GenkitMCPClient) fetchResourcesPage(ctx context.Context, cursor mcp.Cursor) ([]mcp.Resource, mcp.Cursor, error) { + // Build the list request - include cursor if we have one for pagination + listReq := mcp.ListResourcesRequest{} + listReq.PaginatedRequest = mcp.PaginatedRequest{ + Params: struct { + Cursor mcp.Cursor `json:"cursor,omitempty"` + }{ + Cursor: cursor, + }, + } + + // Ask the MCP server for resources + result, err := c.server.Client.ListResources(ctx, listReq) + if err != nil { + return nil, "", fmt.Errorf("failed to list resources from MCP server %s: %w", c.options.Name, err) + } + + return result.Resources, result.NextCursor, nil +} + +// getResourceTemplates retrieves all resource templates from the MCP server by paginating through results +func (c *GenkitMCPClient) getResourceTemplates(ctx context.Context) ([]mcp.ResourceTemplate, error) { + var allTemplates []mcp.ResourceTemplate + var cursor mcp.Cursor + + // Paginate through all available resource templates from the MCP server + for { + // Fetch a page of resource templates + templates, nextCursor, err := c.fetchResourceTemplatesPage(ctx, cursor) + if err != nil { + return nil, err + } + + allTemplates = append(allTemplates, templates...) + + // Check if we've reached the last page + cursor = nextCursor + if cursor == "" { + break + } + } + + return allTemplates, nil +} + +// fetchResourceTemplatesPage retrieves a single page of resource templates from the MCP server +func (c *GenkitMCPClient) fetchResourceTemplatesPage(ctx context.Context, cursor mcp.Cursor) ([]mcp.ResourceTemplate, mcp.Cursor, error) { + listReq := mcp.ListResourceTemplatesRequest{ + PaginatedRequest: mcp.PaginatedRequest{ + Params: struct { + Cursor mcp.Cursor `json:"cursor,omitempty"` + }{ + Cursor: cursor, + }, + }, + } + + result, err := c.server.Client.ListResourceTemplates(ctx, listReq) + if err != nil { + return nil, "", fmt.Errorf("failed to list resource templates from MCP server %s: %w", c.options.Name, err) + } + + return result.ResourceTemplates, result.NextCursor, nil +} + +// convertMCPResourceContentsToGenkitParts converts MCP ResourceContents to Genkit Parts +func convertMCPResourceContentsToGenkitParts(mcpContents []mcp.ResourceContents) ([]*ai.Part, error) { + var parts []*ai.Part + + for _, content := range mcpContents { + // Handle TextResourceContents + if textContent, ok := content.(mcp.TextResourceContents); ok { + parts = append(parts, ai.NewTextPart(textContent.Text)) + continue + } + + // Handle BlobResourceContents + if blobContent, ok := content.(mcp.BlobResourceContents); ok { + // Create media part using ai.NewMediaPart for binary data + parts = append(parts, ai.NewMediaPart(blobContent.MIMEType, blobContent.Blob)) + continue + } + + // Handle unknown resource content types as text + parts = append(parts, ai.NewTextPart(fmt.Sprintf("[Unknown MCP resource content type: %T]", content))) + } + + return parts, nil +} diff --git a/go/plugins/mcp/resources_test.go b/go/plugins/mcp/resources_test.go new file mode 100644 index 0000000000..7446e52f3d --- /dev/null +++ b/go/plugins/mcp/resources_test.go @@ -0,0 +1,316 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcp + +import ( + "fmt" + "testing" + + "github.com/firebase/genkit/go/ai" + "github.com/mark3labs/mcp-go/mcp" +) + +// TestMCPTemplateTranslation tests the translation of MCP ResourceTemplate +// objects to Genkit ai.Resource objects. +// +// This test validates: +// 1. Template string extraction from MCP ResourceTemplate objects +// 2. Working Genkit ai.Resource objects +// 3. URI pattern matching with extracted templates +// 4. Variable extraction from matched URIs +// +// This translation step happens inside GetActiveResources() +// when users fetch resources from MCP servers. If template extraction fails, +// the resulting resources won't match any URIs and will be unusable. +func TestMCPTemplateTranslation(t *testing.T) { + testCases := []struct { + name string + templateURI string + testURI string + shouldMatch bool + expectedVars map[string]string + }{ + { + name: "user profile template", + templateURI: "user://profile/{id}", + testURI: "user://profile/alice", + shouldMatch: true, + expectedVars: map[string]string{"id": "alice"}, + }, + { + name: "user profile no match", + templateURI: "user://profile/{id}", + testURI: "api://different/path", + shouldMatch: false, + }, + { + name: "api service template", + templateURI: "api://{service}/{version}", + testURI: "api://users/v1", + shouldMatch: true, + expectedVars: map[string]string{"service": "users", "version": "v1"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Simulates what GetActiveResources() receives from MCP server + mcpTemplate := mcp.NewResourceTemplate(tc.templateURI, "test-resource") + + if mcpTemplate.URITemplate != nil && mcpTemplate.URITemplate.Template != nil { + rawString := mcpTemplate.URITemplate.Template.Raw() + if rawString != tc.templateURI { + t.Errorf("Raw() extraction failed: expected %q, got %q", tc.templateURI, rawString) + t.Errorf("This indicates the MCP SDK Raw() method is broken!") + } + } else { + t.Fatal("URITemplate structure is nil - MCP SDK structure changed!") + } + + // Create client for testing translation + client := &GenkitMCPClient{ + options: MCPClientOptions{Name: "test-client"}, + } + + // Test the MCP → Genkit translation step + detachedResource, err := client.toGenkitResourceTemplate(mcpTemplate) + if err != nil { + t.Fatalf("MCP → Genkit translation failed: %v", err) + } + + // Verify the translated resource can match URIs correctly + actualMatch := detachedResource.Matches(tc.testURI) + if actualMatch != tc.shouldMatch { + t.Errorf("Template matching failed: template %s vs URI %s: expected match=%v, got %v", + tc.templateURI, tc.testURI, tc.shouldMatch, actualMatch) + t.Errorf("This indicates template extraction or URI matching is broken!") + } + + if tc.shouldMatch && tc.expectedVars != nil { + variables, err := detachedResource.ExtractVariables(tc.testURI) + if err != nil { + t.Errorf("Variable extraction failed after translation: %v", err) + } + + for key, expectedValue := range tc.expectedVars { + if variables[key] != expectedValue { + t.Errorf("Variable %s: expected %s, got %s", key, expectedValue, variables[key]) + } + } + } + }) + } +} + +// TestMCPTemplateEdgeCases tests malformed inputs +func TestMCPTemplateEdgeCases(t *testing.T) { + testCases := []struct { + name string + templateURI string + testURI string + expectError bool + expectMatch bool + expectedVars map[string]string + description string + }{ + { + name: "empty template", + templateURI: "", + testURI: "user://profile/alice", + expectError: true, + description: "Should fail with empty template", + }, + { + name: "malformed template - missing closing brace", + templateURI: "user://profile/{id", + testURI: "user://profile/alice", + expectError: true, + description: "Should fail with malformed template syntax", + }, + { + name: "malformed template - missing opening brace", + templateURI: "user://profile/id}", + testURI: "user://profile/alice", + expectError: true, + description: "Should fail with malformed template syntax", + }, + { + name: "template with special characters", + templateURI: "api://v1/{resource-name}/data", + testURI: "api://v1/user-profiles/data", + expectError: true, // MCP SDK rejects this template + description: "Should handle SDK template rejections gracefully", + }, + { + name: "template with encoded characters", + templateURI: "file://docs/{filename}", + testURI: "file://docs/hello%20world.pdf", + expectMatch: true, + expectedVars: map[string]string{"filename": "hello world.pdf"}, + description: "URL decoding occurs during variable extraction", + }, + { + name: "URI with query parameters", + templateURI: "api://search/{query}", + testURI: "api://search/hello?limit=10&offset=0", + expectMatch: true, // Query parameters are stripped before matching + expectedVars: map[string]string{"query": "hello"}, + description: "Query parameters are stripped, template matches path portion", + }, + { + name: "case sensitivity", + templateURI: "user://profile/{id}", + testURI: "USER://PROFILE/ALICE", + expectMatch: false, // URI schemes are case-sensitive + description: "Should be case-sensitive for scheme", + }, + { + name: "multiple variables same pattern", + templateURI: "api://{service}/{service}", + testURI: "api://users/users", + expectMatch: true, + expectedVars: map[string]string{"service": ""}, // BUG: Returns empty instead of "users" + description: "Duplicate variable names have buggy behavior (should return 'users', not '')", + }, + { + name: "empty variable value", + templateURI: "api://{service}/data", + testURI: "api:///data", // Empty service name + expectMatch: true, // RFC 6570 allows empty variables + expectedVars: map[string]string{"service": ""}, + description: "Empty variable values are valid per RFC 6570", + }, + { + name: "nested path variables", + templateURI: "file:///{folder}/{subfolder}/{filename}", + testURI: "file:///docs/api/readme.md", + expectMatch: true, + expectedVars: map[string]string{ + "folder": "docs", + "subfolder": "api", + "filename": "readme.md", + }, + description: "Should handle multiple path segments", + }, + { + name: "trailing slash in URI (common user issue)", + templateURI: "api://users/{id}", + testURI: "api://users/123/", // User adds trailing slash + expectMatch: true, // Fixed! Trailing slashes are now stripped + expectedVars: map[string]string{"id": "123"}, + description: "Trailing slashes are stripped for better UX", + }, + { + name: "URI with fragment (common in docs/web)", + templateURI: "docs://page/{id}", + testURI: "docs://page/intro#section1", // Common in documentation + expectMatch: true, // Fixed! Fragments are now stripped + expectedVars: map[string]string{"id": "intro"}, + description: "URI fragments are stripped like query parameters", + }, + { + name: "file extension in template", + templateURI: "file://docs/{filename}", + testURI: "file://docs/README.md", + expectMatch: true, + expectedVars: map[string]string{"filename": "README.md"}, + description: "File extensions should be captured in variables", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Handle empty template as special case + if tc.templateURI == "" { + client := &GenkitMCPClient{ + options: MCPClientOptions{Name: "test-client"}, + } + + mcpTemplate := mcp.NewResourceTemplate("", "test-resource") + _, err := client.toGenkitResourceTemplate(mcpTemplate) + + if tc.expectError && err == nil { + t.Error("Expected error for empty template, but got none") + } + if !tc.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + return + } + + // Test template creation (may panic for malformed templates) + var mcpTemplate mcp.ResourceTemplate + var templateErr error + + func() { + defer func() { + if r := recover(); r != nil { + templateErr = fmt.Errorf("template creation panicked: %v", r) + } + }() + mcpTemplate = mcp.NewResourceTemplate(tc.templateURI, "test-resource") + }() + + // Create client for testing translation + client := &GenkitMCPClient{ + options: MCPClientOptions{Name: "test-client"}, + } + + // Test the MCP → Genkit translation step + var resource ai.Resource + var err error + + if templateErr != nil { + err = templateErr + } else { + resource, err = client.toGenkitResourceTemplate(mcpTemplate) + } + + if tc.expectError { + if err == nil { + t.Errorf("Expected error for %s, but got none", tc.description) + } + return + } + + if err != nil { + t.Errorf("Unexpected error for %s: %v", tc.description, err) + return + } + + // Test URI matching + actualMatch := resource.Matches(tc.testURI) + if actualMatch != tc.expectMatch { + t.Errorf("URI matching failed for %s: template %s vs URI %s: expected match=%v, got %v", + tc.description, tc.templateURI, tc.testURI, tc.expectMatch, actualMatch) + } + + // Test variable extraction if match is expected + if tc.expectMatch && tc.expectedVars != nil { + variables, err := resource.ExtractVariables(tc.testURI) + if err != nil { + t.Errorf("Variable extraction failed for %s: %v", tc.description, err) + return + } + + for key, expectedValue := range tc.expectedVars { + if variables[key] != expectedValue { + t.Errorf("Variable %s: expected %q, got %q", key, expectedValue, variables[key]) + } + } + } + }) + } +} diff --git a/go/plugins/mcp/server.go b/go/plugins/mcp/server.go index 483395c0e8..79c9c150c2 100644 --- a/go/plugins/mcp/server.go +++ b/go/plugins/mcp/server.go @@ -16,214 +16,294 @@ package mcp import ( "context" - "encoding/json" "fmt" "log/slog" + "strings" "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/genkit" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) -// MCPServerOptions holds configuration for creating an MCP server +// MCPServerOptions holds configuration for GenkitMCPServer type MCPServerOptions struct { - // Name is the server name advertised to MCP clients + // Name for this server instance - used for MCP identification Name string - // Version is the server version (defaults to "1.0.0" if empty) + // Version number for this server (defaults to "1.0.0" if empty) Version string - // Tools is an optional list of specific tools to expose. - // If provided, only these tools will be exposed (no auto-discovery). - // If nil or empty, all tools will be auto-discovered from the registry. - Tools []ai.Tool } -// GenkitMCPServer represents an MCP server that exposes Genkit tools +// GenkitMCPServer represents an MCP server that exposes Genkit tools, prompts, and resources type GenkitMCPServer struct { genkit *genkit.Genkit options MCPServerOptions mcpServer *server.MCPServer - // Tools discovered from Genkit registry or explicitly specified - tools map[string]ai.Tool + // Discovered actions from Genkit registry + toolActions []ai.Tool + resourceActions []core.Action + actionsResolved bool } -// NewMCPServer creates a new MCP server instance that can expose Genkit tools +// NewMCPServer creates a new GenkitMCPServer with the provided options func NewMCPServer(g *genkit.Genkit, options MCPServerOptions) *GenkitMCPServer { + // Set default values if options.Version == "" { options.Version = "1.0.0" } - s := &GenkitMCPServer{ + server := &GenkitMCPServer{ genkit: g, options: options, - tools: make(map[string]ai.Tool), } - // Discover or load tools based on options - if len(options.Tools) > 0 { - s.loadExplicitTools() - } else { - s.discoverTools() - } - - return s + return server } -// loadExplicitTools loads only the specified tools -func (s *GenkitMCPServer) loadExplicitTools() { - var loadedCount int - for _, tool := range s.options.Tools { - if tool != nil { - s.tools[tool.Name()] = tool - loadedCount++ - slog.Debug("MCP Server: Loaded explicit tool", "name", tool.Name()) - } else { - slog.Warn("MCP Server: Nil tool in explicit tools list") - } - } - slog.Info("MCP Server: Explicit tool loading complete", "loaded", loadedCount, "requested", len(s.options.Tools)) -} - -// discoverTools discovers all tools from the Genkit registry -func (s *GenkitMCPServer) discoverTools() { - // Get all tools from the registry - allTools := genkit.ListTools(s.genkit) - - var discoveredCount int - for _, tool := range allTools { - if tool != nil { - s.tools[tool.Name()] = tool - discoveredCount++ - slog.Debug("MCP Server: Discovered tool", "name", tool.Name()) - } +// setup initializes the MCP server and discovers actions +func (s *GenkitMCPServer) setup() error { + if s.actionsResolved { + return nil } - slog.Info("MCP Server: Tool discovery complete", "discovered", discoveredCount) -} - -// setup initializes the MCP server -func (s *GenkitMCPServer) setup() error { - // Create MCP server with tool capabilities + // Create MCP server with all capabilities s.mcpServer = server.NewMCPServer( s.options.Name, s.options.Version, server.WithToolCapabilities(true), + server.WithResourceCapabilities(true, true), // subscribe and listChanged capabilities ) - // Register all discovered tools with the MCP server - for toolName, tool := range s.tools { - if err := s.addToolToMCPServer(toolName, tool); err != nil { - return fmt.Errorf("failed to add tool %q to MCP server: %w", toolName, err) + // Discover and categorize actions from Genkit registry + toolActions, resourceActions, err := s.discoverAndCategorizeActions() + if err != nil { + return fmt.Errorf("failed to discover actions: %w", err) + } + + // Store discovered actions + s.toolActions = toolActions + s.resourceActions = resourceActions + + // Register tools with the MCP server + for _, tool := range toolActions { + mcpTool := s.convertGenkitToolToMCP(tool) + s.mcpServer.AddTool(mcpTool, s.createToolHandler(tool)) + } + + // Register resources with the MCP server + for _, resourceAction := range resourceActions { + if err := s.registerResourceWithMCP(resourceAction); err != nil { + slog.Warn("Failed to register resource", "resource", resourceAction.Desc().Name, "error", err) } } - slog.Info("MCP Server setup complete", "name", s.options.Name, "tools", len(s.tools)) + s.actionsResolved = true + slog.Info("MCP Server setup complete", + "name", s.options.Name, + "tools", len(s.toolActions), + "resources", len(s.resourceActions)) return nil } -// addToolToMCPServer converts a Genkit tool to MCP format and registers it -func (s *GenkitMCPServer) addToolToMCPServer(toolName string, tool ai.Tool) error { - // Convert Genkit tool to MCP tool format - mcpTool, err := s.convertGenkitToolToMCP(tool) - if err != nil { - return fmt.Errorf("failed to convert Genkit tool to MCP format: %w", err) +// discoverAndCategorizeActions discovers all actions from Genkit registry and categorizes them +func (s *GenkitMCPServer) discoverAndCategorizeActions() ([]ai.Tool, []core.Action, error) { + // Use the existing List functions which properly handle the registry access + toolActions := genkit.ListTools(s.genkit) + resources := genkit.ListResources(s.genkit) + + // Convert ai.Resource to core.Action + resourceActions := make([]core.Action, len(resources)) + for i, resource := range resources { + if resourceAction, ok := resource.(core.Action); ok { + resourceActions[i] = resourceAction + } else { + return nil, nil, fmt.Errorf("resource %s does not implement core.Action", resource.Name()) + } } - // Create tool handler function - toolHandler := s.createToolHandler(tool) - - // Add tool to MCP server - s.mcpServer.AddTool(mcpTool, toolHandler) - return nil + return toolActions, resourceActions, nil } -// convertGenkitToolToMCP converts a Genkit tool definition to MCP tool format -func (s *GenkitMCPServer) convertGenkitToolToMCP(tool ai.Tool) (mcp.Tool, error) { +// convertGenkitToolToMCP converts a Genkit tool to MCP format +func (s *GenkitMCPServer) convertGenkitToolToMCP(tool ai.Tool) mcp.Tool { def := tool.Definition() - // Create basic MCP tool - mcpTool := mcp.NewTool(def.Name, mcp.WithDescription(def.Description)) + // Start with basic options + options := []mcp.ToolOption{mcp.WithDescription(def.Description)} // Convert input schema if available if def.InputSchema != nil { - schemaBytes, err := json.Marshal(def.InputSchema) - if err != nil { - return mcpTool, fmt.Errorf("failed to marshal input schema: %w", err) + // Parse the JSON schema and convert to MCP tool options + if properties, ok := def.InputSchema["properties"].(map[string]interface{}); ok { + // Convert each property to appropriate MCP option + for propName, propDef := range properties { + if propMap, ok := propDef.(map[string]interface{}); ok { + propType, _ := propMap["type"].(string) + + switch propType { + case "string": + options = append(options, mcp.WithString(propName)) + case "integer", "number": + options = append(options, mcp.WithNumber(propName)) + case "boolean": + options = append(options, mcp.WithBoolean(propName)) + } + } + } } - mcpTool = mcp.NewToolWithRawSchema(def.Name, def.Description, schemaBytes) } - return mcpTool, nil + return mcp.NewTool(def.Name, options...) } -// createToolHandler creates an MCP tool handler function for a Genkit tool +// createToolHandler creates an MCP tool handler for a Genkit tool func (s *GenkitMCPServer) createToolHandler(tool ai.Tool) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // Log the tool call - slog.Debug("MCP Server: Tool called", "name", request.Params.Name, "args", request.Params.Arguments) - // Execute the Genkit tool result, err := tool.RunRaw(ctx, request.Params.Arguments) if err != nil { - slog.Error("MCP Server: Tool execution failed", "name", request.Params.Name, "error", err) return mcp.NewToolResultError(err.Error()), nil } // Convert result to MCP format - return s.convertResultToMCP(result), nil + switch v := result.(type) { + case string: + return mcp.NewToolResultText(v), nil + case nil: + return mcp.NewToolResultText(""), nil + default: + // Convert complex types to string + return mcp.NewToolResultText(fmt.Sprintf("%v", v)), nil + } } } -// convertResultToMCP converts a Genkit tool result to MCP format -func (s *GenkitMCPServer) convertResultToMCP(result any) *mcp.CallToolResult { - switch v := result.(type) { - case string: - return mcp.NewToolResultText(v) - case nil: - return mcp.NewToolResultText("") - default: - // Convert complex types to JSON - jsonBytes, err := json.Marshal(v) +// registerResourceWithMCP registers a Genkit resource with the MCP server +func (s *GenkitMCPServer) registerResourceWithMCP(resourceAction core.Action) error { + desc := resourceAction.Desc() + resourceName := strings.TrimPrefix(desc.Key, "/resource/") + + // Extract original URI/template from metadata + var originalURI string + var isTemplate bool + + if resourceMeta, ok := desc.Metadata["resource"].(map[string]any); ok { + if uri, ok := resourceMeta["uri"].(string); ok && uri != "" { + originalURI = uri + isTemplate = false + } else if template, ok := resourceMeta["template"].(string); ok && template != "" { + originalURI = template + isTemplate = true + } + } + + // Fallback to synthetic URI if no original URI found (shouldn't happen normally) + if originalURI == "" { + originalURI = fmt.Sprintf("genkit://%s", resourceName) + isTemplate = false + } + + // Create resource handler + handler := func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + + // Find matching resource for the URI and execute it + resourceAction, input, err := genkit.FindMatchingResource(s.genkit, request.Params.URI) + if err != nil { + return nil, fmt.Errorf("no resource found for URI %s: %w", request.Params.URI, err) + } + + // Execute the resource + result, err := resourceAction.Execute(ctx, input) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal result: %v", err)) + return nil, fmt.Errorf("resource execution failed: %w", err) + } + + // Convert result to MCP content format + var contents []mcp.ResourceContents + for _, part := range result.Content { + if part.Text != "" { + contents = append(contents, mcp.TextResourceContents{ + URI: request.Params.URI, + MIMEType: "text/plain", + Text: part.Text, + }) + } + // Handle other part types (media, data, etc.) if needed } - return mcp.NewToolResultText(string(jsonBytes)) + + return contents, nil + } + + // Register as template resource or static resource based on type + if isTemplate { + // Create MCP template resource + mcpTemplate := mcp.NewResourceTemplate( + originalURI, // Template URI like "user://profile/{id}" + resourceName, // Name + mcp.WithTemplateDescription(desc.Description), + ) + s.mcpServer.AddResourceTemplate(mcpTemplate, handler) + } else { + // Create MCP static resource + mcpResource := mcp.NewResource( + originalURI, // Static URI + resourceName, // Name + mcp.WithResourceDescription(desc.Description), + ) + s.mcpServer.AddResource(mcpResource, handler) } + + return nil } -// ServeStdio starts the MCP server with stdio transport (primary MCP transport) -func (s *GenkitMCPServer) ServeStdio(ctx context.Context) error { +// ServeStdio starts the MCP server using stdio transport +func (s *GenkitMCPServer) ServeStdio() error { if err := s.setup(); err != nil { - return fmt.Errorf("failed to setup MCP server: %w", err) + return fmt.Errorf("setup failed: %w", err) } - slog.Info("MCP Server starting with stdio transport", "name", s.options.Name, "tools", len(s.tools)) return server.ServeStdio(s.mcpServer) } -// ServeSSE starts the MCP server with SSE transport (for web clients) -func (s *GenkitMCPServer) ServeSSE(ctx context.Context, addr string) error { +// Serve starts the MCP server with a custom transport +func (s *GenkitMCPServer) Serve(transport interface{}) error { if err := s.setup(); err != nil { - return fmt.Errorf("failed to setup MCP server: %w", err) + return fmt.Errorf("setup failed: %w", err) } - slog.Info("MCP Server starting with SSE transport", "name", s.options.Name, "addr", addr, "tools", len(s.tools)) - sseServer := server.NewSSEServer(s.mcpServer) - return sseServer.Start(addr) + // For now, only stdio is supported through the server.ServeStdio function + return server.ServeStdio(s.mcpServer) } -// Stop gracefully stops the MCP server -func (s *GenkitMCPServer) Stop() error { +// Close shuts down the MCP server +func (s *GenkitMCPServer) Close() error { // The mcp-go server handles cleanup internally return nil } +// GetServer returns the underlying MCP server instance +func (s *GenkitMCPServer) GetServer() *server.MCPServer { + return s.mcpServer +} + // ListRegisteredTools returns the names of all discovered tools func (s *GenkitMCPServer) ListRegisteredTools() []string { var toolNames []string - for name := range s.tools { - toolNames = append(toolNames, name) + for _, tool := range s.toolActions { + toolNames = append(toolNames, tool.Name()) } return toolNames } + +// ListRegisteredResources returns the names of all discovered resources +func (s *GenkitMCPServer) ListRegisteredResources() []string { + var resourceNames []string + for _, resourceAction := range s.resourceActions { + desc := resourceAction.Desc() + resourceName := strings.TrimPrefix(desc.Key, "/resource/") + resourceNames = append(resourceNames, resourceName) + } + return resourceNames +} diff --git a/go/plugins/mcp/tools.go b/go/plugins/mcp/tools.go index f79fac290f..7a4b7d3979 100644 --- a/go/plugins/mcp/tools.go +++ b/go/plugins/mcp/tools.go @@ -21,16 +21,12 @@ import ( "fmt" "github.com/firebase/genkit/go/ai" - "github.com/firebase/genkit/go/core/logger" "github.com/firebase/genkit/go/genkit" - "github.com/firebase/genkit/go/internal/base" - "github.com/invopop/jsonschema" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" ) // GetActiveTools retrieves all tools available from the MCP server -// and returns them as Genkit ToolAction objects func (c *GenkitMCPClient) GetActiveTools(ctx context.Context, g *genkit.Genkit) ([]ai.Tool, error) { if !c.IsEnabled() || c.server == nil { return nil, nil @@ -42,16 +38,15 @@ func (c *GenkitMCPClient) GetActiveTools(ctx context.Context, g *genkit.Genkit) return nil, err } - // Register all tools - return c.registerTools(ctx, g, mcpTools) + // Create tools from MCP server + return c.createTools(mcpTools) } -// registerTools registers all MCP tools with Genkit -// It returns tools that were successfully registered -func (c *GenkitMCPClient) registerTools(ctx context.Context, g *genkit.Genkit, mcpTools []mcp.Tool) ([]ai.Tool, error) { +// createTools creates Genkit tools from MCP tools +func (c *GenkitMCPClient) createTools(mcpTools []mcp.Tool) ([]ai.Tool, error) { var tools []ai.Tool for _, mcpTool := range mcpTools { - tool, err := c.registerTool(ctx, g, mcpTool) + tool, err := c.createTool(mcpTool) if err != nil { return nil, err } @@ -59,10 +54,27 @@ func (c *GenkitMCPClient) registerTools(ctx context.Context, g *genkit.Genkit, m tools = append(tools, tool) } } - return tools, nil } +// createTool converts a single MCP tool to a Genkit tool +func (c *GenkitMCPClient) createTool(mcpTool mcp.Tool) (ai.Tool, error) { + // Use namespaced tool name + namespacedToolName := c.GetToolNameWithNamespace(mcpTool.Name) + + // Create the tool function that will handle execution + toolFunc := c.createToolFunction(mcpTool) + + // Create tool + tool := ai.NewTool( + namespacedToolName, + mcpTool.Description, + toolFunc, + ) + + return tool, nil +} + // getTools retrieves all tools from the MCP server by paginating through results func (c *GenkitMCPClient) getTools(ctx context.Context) ([]mcp.Tool, error) { var allMcpTools []mcp.Tool @@ -108,59 +120,6 @@ func (c *GenkitMCPClient) fetchToolsPage(ctx context.Context, cursor mcp.Cursor) return result.Tools, result.NextCursor, nil } -// registerTool converts a single MCP tool to a Genkit tool -// Returns the tool without re-registering if it already exists in the registry -func (c *GenkitMCPClient) registerTool(ctx context.Context, g *genkit.Genkit, mcpTool mcp.Tool) (ai.Tool, error) { - // Use namespaced tool name - namespacedToolName := c.GetToolNameWithNamespace(mcpTool.Name) - - // Check if the tool already exists in the registry - existingTool := genkit.LookupTool(g, namespacedToolName) - if existingTool != nil { - return existingTool, nil - } - - // Process the tool's input schema - inputSchemaForAI, err := c.getInputSchema(mcpTool) - if err != nil { - return nil, err - } - - // Create the tool function that will handle execution - toolFunc := c.createToolFunction(mcpTool) - - // Register the tool with Genkit - tool := genkit.DefineToolWithInputSchema( - g, - namespacedToolName, - mcpTool.Description, - base.SchemaAsMap(inputSchemaForAI), - toolFunc, - ) - - return tool, nil -} - -// getInputSchema exposes the MCP input schema as a jsonschema.Schema for Genkit -func (c *GenkitMCPClient) getInputSchema(mcpTool mcp.Tool) (*jsonschema.Schema, error) { - var inputSchemaForAI *jsonschema.Schema - if mcpTool.InputSchema.Type != "" { - schemaBytes, err := json.Marshal(mcpTool.InputSchema) - if err != nil { - return nil, fmt.Errorf("failed to marshal MCP input schema for tool %s: %w", mcpTool.Name, err) - } - inputSchemaForAI = new(jsonschema.Schema) - if err := json.Unmarshal(schemaBytes, inputSchemaForAI); err != nil { - // Fall back to empty schema if unmarshaling fails - inputSchemaForAI = &jsonschema.Schema{} - } - } else { - inputSchemaForAI = &jsonschema.Schema{} - } - - return inputSchemaForAI, nil -} - // createToolFunction creates a Genkit tool function that will execute the MCP tool func (c *GenkitMCPClient) createToolFunction(mcpTool mcp.Tool) func(*ai.ToolContext, interface{}) (interface{}, error) { // Capture mcpTool by value for the closure @@ -176,19 +135,12 @@ func (c *GenkitMCPClient) createToolFunction(mcpTool mcp.Tool) func(*ai.ToolCont return nil, err } - // Log the MCP tool call request - logger.FromContext(ctx).Debug("Calling MCP tool", "tool", currentMCPTool.Name, "args", callToolArgs) - // Create and execute the MCP tool call request mcpResult, err := executeToolCall(ctx, client, currentMCPTool.Name, callToolArgs) if err != nil { - logger.FromContext(ctx).Error("MCP tool call failed", "tool", currentMCPTool.Name, "error", err) return nil, fmt.Errorf("failed to call tool %s: %w", currentMCPTool.Name, err) } - // Log the MCP tool call response - logger.FromContext(ctx).Debug("MCP tool call succeeded", "tool", currentMCPTool.Name, "result", mcpResult) - return mcpResult, nil } } @@ -244,20 +196,11 @@ func executeToolCall(ctx context.Context, client *client.Client, toolName string }, } - // Log the raw MCP request - reqBytes, _ := json.MarshalIndent(callReq, "", " ") - logger.FromContext(ctx).Debug("Raw MCP request", "request", string(reqBytes)) - result, err := client.CallTool(ctx, callReq) if err != nil { - logger.FromContext(ctx).Error("Raw MCP server error", "error", err) return nil, err } - // Log the raw MCP response - respBytes, _ := json.MarshalIndent(result, "", " ") - logger.FromContext(ctx).Debug("Raw MCP response", "response", string(respBytes)) - return result, nil } diff --git a/go/samples/mcp-ception/mcp_ception.go b/go/samples/mcp-ception/mcp_ception.go new file mode 100644 index 0000000000..575e27f993 --- /dev/null +++ b/go/samples/mcp-ception/mcp_ception.go @@ -0,0 +1,247 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/core/logger" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/googlegenai" + "github.com/firebase/genkit/go/plugins/mcp" +) + +// MCP self-hosting example: Genkit serves itself through MCP +// 1. Start a Go MCP server that exposes Genkit resources +// 2. Connect to that server as an MCP client +// 3. Use the resources from the server for AI generation + +// Create the MCP Server (runs in background) +func createMCPServer() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + logger.FromContext(ctx).Info("Starting Genkit MCP Server") + + // Initialize Genkit for the server + g := genkit.Init(ctx) + + // Define a tool that generates creative content (this will be auto-exposed via MCP) + genkit.DefineTool(g, "genkit-brainstorm", "Generate creative ideas about a topic", + func(ctx *ai.ToolContext, input struct { + Topic string `json:"topic" description:"The topic to brainstorm about"` + }) (map[string]interface{}, error) { + logger.FromContext(ctx.Context).Debug("Executing genkit-brainstorm tool", "topic", input.Topic) + + ideas := fmt.Sprintf(`Creative Ideas for "%s": + +1. Interactive Experience: Create an immersive, hands-on workshop +2. Digital Innovation: Develop a mobile app or web platform +3. Community Building: Start a local meetup or online community +4. Educational Content: Design a course or tutorial series +5. Collaborative Project: Partner with others for cross-pollination +6. Storytelling Approach: Create narratives around the topic +7. Gamification: Turn learning into an engaging game +8. Real-world Application: Find practical, everyday uses +9. Creative Challenge: Host competitions or hackathons +10. Multi-media Approach: Combine video, audio, and interactive elements + +These ideas can be mixed, matched, and customized for "%s".`, input.Topic, input.Topic) + + return map[string]interface{}{ + "topic": input.Topic, + "ideas": ideas, + }, nil + }) + + // Define a resource that contains Genkit knowledge (this will be auto-exposed via MCP) + genkit.DefineResource(g, "genkit-knowledge", &ai.ResourceOptions{ + URI: "knowledge://genkit-docs", + }, func(ctx context.Context, input *ai.ResourceInput) (*ai.ResourceOutput, error) { + knowledge := `# Genkit Knowledge Base + +## What is Genkit? +Genkit is Firebase's open-source framework for building AI-powered applications. + +## Key Features: +- Multi-modal AI generation (text, images, audio) +- Tool calling and function execution +- RAG (Retrieval Augmented Generation) support +- Evaluation and testing frameworks +- Multi-language support (TypeScript, Go, Python) + +## Popular Models: +- Google AI: Gemini 1.5 Flash, Gemini 2.0 Flash +- Vertex AI: All Gemini models +- OpenAI Compatible models via plugins + +## Use Cases: +- Chatbots and conversational AI +- Content generation and editing +- Code analysis and generation +- Document processing and summarization +- Creative applications (story writing, brainstorming) + +## Architecture: +Genkit follows a plugin-based architecture where models, retrievers, evaluators, and other components are provided by plugins.` + + return &ai.ResourceOutput{ + Content: []*ai.Part{ai.NewTextPart(knowledge)}, + }, nil + }) + + // Create MCP server (automatically exposes all defined tools and resources) + server := mcp.NewMCPServer(g, mcp.MCPServerOptions{ + Name: "genkit-mcp-server", + Version: "1.0.0", + }) + + logger.FromContext(ctx).Info("Genkit MCP Server configured successfully") + logger.FromContext(ctx).Info("Starting MCP server on stdio") + logger.FromContext(ctx).Info("Registered tools", "count", len(server.ListRegisteredTools())) + logger.FromContext(ctx).Info("Registered resources", "count", len(server.ListRegisteredResources())) + + // Start the server + if err := server.ServeStdio(); err != nil && err != context.Canceled { + logger.FromContext(ctx).Error("MCP server failed", "error", err) + os.Exit(1) + } +} + +// Create the MCP Client that connects to our server +func mcpSelfConnection() { + ctx := context.Background() + + logger.FromContext(ctx).Info("MCP self-connection demo") + logger.FromContext(ctx).Info("Genkit will connect to itself via MCP") + + // Initialize Genkit with Google AI for the client + g := genkit.Init(ctx, + genkit.WithPlugins(&googlegenai.GoogleAI{}), + genkit.WithDefaultModel("googleai/gemini-2.0-flash"), + ) + + logger.FromContext(ctx).Info("Connecting to our own MCP server") + logger.FromContext(ctx).Info("Note: Server process will be spawned automatically") + + // Create MCP Host that connects to our Genkit server + host, err := mcp.NewMCPHost(g, mcp.MCPHostOptions{ + Name: "mcp-ception-host", + MCPServers: []mcp.MCPServerConfig{ + { + Name: "genkit-server", + Config: mcp.MCPClientOptions{ + Name: "genkit-mcp-server", + Version: "1.0.0", + Stdio: &mcp.StdioConfig{ + Command: "go", + Args: []string{"run", "mcp_ception.go", "server"}, + }, + }, + }, + }, + }) + if err != nil { + logger.FromContext(ctx).Error("Failed to create MCP host", "error", err) + return + } + + // Get resources from our Genkit server + logger.FromContext(ctx).Info("Getting resources from Genkit MCP server") + resources, err := host.GetActiveResources(ctx) + if err != nil { + logger.FromContext(ctx).Error("Failed to get resources", "error", err) + return + } + + logger.FromContext(ctx).Info("Retrieved resources from server", "count", len(resources)) + + // Debug: examine retrieved resources + for i, resource := range resources { + logger.FromContext(ctx).Info("Resource details", "index", i, "name", resource.Name()) + // Test if the resource matches our target URI + matches := resource.Matches("knowledge://genkit-docs") + logger.FromContext(ctx).Info("Resource URI matching", "matches_target_uri", matches) + } + + // Get tools from our Genkit server + logger.FromContext(ctx).Info("Getting tools from Genkit MCP server") + tools, err := host.GetActiveTools(ctx, g) + if err != nil { + logger.FromContext(ctx).Error("Failed to get tools", "error", err) + return + } + + logger.FromContext(ctx).Info("Retrieved tools from server", "count", len(tools)) + + // Convert tools to refs + var toolRefs []ai.ToolRef + for _, tool := range tools { + toolRefs = append(toolRefs, tool) + } + + // Use resources and tools from our own server for AI generation + logger.FromContext(ctx).Info("Asking AI about Genkit using our own MCP resources") + + // Use ai.NewResourcePart to explicitly reference the resource + logger.FromContext(ctx).Info("Starting generation call", "resource_count", len(resources), "tool_count", len(toolRefs)) + + response, err := genkit.Generate(ctx, g, + ai.WithMessages(ai.NewUserMessage( + ai.NewTextPart("Based on this Genkit knowledge:"), + ai.NewResourcePart("knowledge://genkit-docs"), // Explicit resource reference + ai.NewTextPart("What are the key features of Genkit and what models does it support?\n\nAlso, use the brainstorm tool to generate ideas for \"AI-powered cooking assistant\""), + )), + ai.WithResources(resources...), // Makes resources available for lookup + ai.WithTools(toolRefs...), // Using tools from our own server! + ai.WithToolChoice(ai.ToolChoiceAuto), + ) + if err != nil { + logger.FromContext(ctx).Error("AI generation failed", "error", err) + return + } + + logger.FromContext(ctx).Info("MCP self-connection completed successfully") + logger.FromContext(ctx).Info("Genkit used itself via MCP to answer questions") + fmt.Printf("\nAI Response using our own MCP resources:\n%s\n\n", response.Text()) + + // Clean disconnect (skip for now to avoid hanging) + logger.FromContext(ctx).Info("MCP self-connection complete") +} + +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: go run mcp_ception.go [server|demo]") + fmt.Println(" server - Run as MCP server (exposes Genkit resources)") + fmt.Println(" demo - Run MCP self-connection demo (connects to server)") + os.Exit(1) + } + + switch os.Args[1] { + case "server": + createMCPServer() + case "demo": + mcpSelfConnection() + default: + fmt.Printf("Unknown command: %s\n", os.Args[1]) + fmt.Println("Use 'server' or 'demo'") + os.Exit(1) + } +} diff --git a/go/samples/mcp-client/main.go b/go/samples/mcp-client/main.go index d7e7cda1c5..e34a891f4e 100644 --- a/go/samples/mcp-client/main.go +++ b/go/samples/mcp-client/main.go @@ -27,7 +27,7 @@ import ( "github.com/firebase/genkit/go/plugins/mcp" ) -// MCP Manager Example - connects to time server +// MCP Host Example - connects to time server and demonstrates both tools and resources func managerExample() { ctx := context.Background() @@ -35,7 +35,7 @@ func managerExample() { g := genkit.Init(ctx, genkit.WithPlugins(&googlegenai.GoogleAI{})) // Create and connect to MCP time server - manager, _ := mcp.NewMCPManager(mcp.MCPManagerOptions{ + host, _ := mcp.NewMCPHost(g, mcp.MCPHostOptions{ Name: "time-example", MCPServers: []mcp.MCPServerConfig{ { @@ -52,10 +52,20 @@ func managerExample() { }, }) - // Get tools and generate response - tools, _ := manager.GetActiveTools(ctx, g) + // Get tools and resources from MCP servers + tools, _ := host.GetActiveTools(ctx, g) logger.FromContext(ctx).Info("Found MCP tools", "count", len(tools), "example", "time") + // Get detached resources from MCP servers (not auto-registered) + allResources, err := host.GetActiveResources(ctx) + if err != nil { + logger.FromContext(ctx).Warn("Failed to get MCP resources", "error", err) + } else { + logger.FromContext(ctx).Info("Successfully got detached MCP resources", "count", len(allResources)) + // Resources can be used via ai.WithResources() in generate calls + logger.FromContext(ctx).Info("Resources can be used in prompts via ai.WithResources()") + } + var toolRefs []ai.ToolRef for _, tool := range tools { toolRefs = append(toolRefs, tool) @@ -74,19 +84,19 @@ func managerExample() { } // Disconnect from server - manager.Disconnect(ctx, "time") + host.Disconnect(ctx, "time") logger.FromContext(ctx).Info("Disconnected from MCP server", "server", "time") } -// MCP Manager Multi-Server Example - connects to both time and fetch servers +// MCP Host Multi-Server Example - connects to both time and fetch servers, demonstrates tools and resources func multiServerManagerExample() { ctx := context.Background() // Initialize Genkit with Google AI g := genkit.Init(ctx, genkit.WithPlugins(&googlegenai.GoogleAI{})) - // Create MCP manager for multiple servers - manager, _ := mcp.NewMCPManager(mcp.MCPManagerOptions{ + // Create MCP host for multiple servers + host, _ := mcp.NewMCPHost(g, mcp.MCPHostOptions{ Name: "multi-server-example", MCPServers: []mcp.MCPServerConfig{ { @@ -114,10 +124,18 @@ func multiServerManagerExample() { }, }) - // Get tools from all connected servers - tools, _ := manager.GetActiveTools(ctx, g) + // Get tools and resources from all connected servers + tools, _ := host.GetActiveTools(ctx, g) logger.FromContext(ctx).Info("Found MCP tools from all servers", "count", len(tools), "servers", []string{"time", "fetch"}) + // Get detached resources from all MCP servers + allResources2, err := host.GetActiveResources(ctx) + if err != nil { + logger.FromContext(ctx).Warn("Failed to get MCP resources from servers", "error", err) + } else { + logger.FromContext(ctx).Info("Successfully got detached MCP resources from all servers", "count", len(allResources2), "servers", []string{"time", "fetch"}) + } + var toolRefs []ai.ToolRef for _, tool := range tools { toolRefs = append(toolRefs, tool) @@ -137,8 +155,8 @@ func multiServerManagerExample() { } // Disconnect from all servers - manager.Disconnect(ctx, "time") - manager.Disconnect(ctx, "fetch") + host.Disconnect(ctx, "time") + host.Disconnect(ctx, "fetch") logger.FromContext(ctx).Info("Disconnected from all MCP servers", "servers", []string{"time", "fetch"}) } @@ -291,14 +309,185 @@ func clientStreamableHTTPExample() { logger.FromContext(ctx).Info("Disconnected from MCP server", "client", "mcp-everything-http") } +// MCP Resources Example - demonstrates how to connect to servers and actually use their resources +func resourcesExample() { + ctx := context.Background() + + // Initialize Genkit with Google AI plugin and default model + g := genkit.Init(ctx, + genkit.WithPlugins(&googlegenai.GoogleAI{}), + genkit.WithDefaultModel("googleai/gemini-2.0-flash"), + ) + + logger.FromContext(ctx).Info("Starting MCP Resources demonstration") + + // === Example 1: Connect to a server that actually provides resources === + logger.FromContext(ctx).Info("Creating MCP client for everything server (has sample resources)") + // This server actually provides sample resources we can read + everythingClient, err := mcp.NewGenkitMCPClient(mcp.MCPClientOptions{ + Name: "mcp-everything", + Version: "1.0.0", + Stdio: &mcp.StdioConfig{ + Command: "npx", + Args: []string{"@modelcontextprotocol/server-everything", "stdio"}, + }, + }) + if err != nil { + logger.FromContext(ctx).Warn("Failed to create everything MCP client (install: npm install -g @modelcontextprotocol/server-everything)", "error", err) + } else { + logger.FromContext(ctx).Info("Everything MCP client created successfully") + + // Get detached resources from the everything server (without auto-registering) + resources, err := everythingClient.GetActiveResources(ctx) + if err != nil { + logger.FromContext(ctx).Warn("Failed to get everything server resources", "error", err) + } else { + logger.FromContext(ctx).Info("Got detached resources from everything server", "count", len(resources)) + + // Demonstrate the streamlined UX: use resources directly in generate calls + if len(resources) > 0 { + logger.FromContext(ctx).Info("Demonstrating streamlined resource usage...") + + // Limit resources to avoid overwhelming the model (just use first 3 for demo) + limitedResources := resources + if len(resources) > 3 { + limitedResources = resources[:3] + logger.FromContext(ctx).Info("Limiting to first 3 resources for demo", "total", len(resources), "using", len(limitedResources)) + } + + // Example: Use MCP resources in an AI generation call + response, err := genkit.Generate(ctx, g, + ai.WithMessages(ai.NewUserMessage( + ai.NewTextPart("List the available resources and describe what each one contains."), + // Resource references will be resolved automatically from the detached resources + )), + ai.WithResources(limitedResources...), + ) + if err != nil { + logger.FromContext(ctx).Warn("Failed to generate with MCP resources", "error", err) + } else { + logger.FromContext(ctx).Info("Generated response using MCP resources", "response", response.Text()) + } + + // Show available resource names for reference + logger.FromContext(ctx).Info("Available resources:") + for i, resource := range resources { + logger.FromContext(ctx).Info("Resource available", + "index", i, + "name", resource.Name()) + // Only show first few for demo + if i >= 2 { + logger.FromContext(ctx).Info("... and more resources available") + break + } + } + } else { + logger.FromContext(ctx).Info("No resources available from everything server") + } + } + + // Disconnect from everything server + everythingClient.Disconnect() + logger.FromContext(ctx).Info("Disconnected from everything server") + } + + // === Example 2: Simple resource workflow === + // Create a detached resource from your own code that you can use in AI generation + configResource := genkit.NewResource("app-config", &ai.ResourceOptions{ + URI: "config://app.json", + }, func(ctx context.Context, input *ai.ResourceInput) (*ai.ResourceOutput, error) { + config := `{"app": "MyApp", "version": "1.0", "features": ["auth", "chat"]}` + return &ai.ResourceOutput{ + Content: []*ai.Part{ai.NewTextPart(config)}, + }, nil + }) + + response, err := genkit.Generate(ctx, g, + ai.WithMessages( + ai.NewUserMessage( + ai.NewTextPart("Here's my config, what features does it have?"), + ai.NewResourcePart("config://app.json"), + ), + ), + ai.WithResources(configResource), // Pass detached resource + ) + + if err == nil { + logger.FromContext(ctx).Info("Resource workflow complete", "response", response.Text()) + } + + // === Example 3: Use MCP resources in AI generation === + logger.FromContext(ctx).Info("Demonstrating AI generation with MCP resources") + + // Create a host with multiple servers for resource access + host, err := mcp.NewMCPHost(g, mcp.MCPHostOptions{ + Name: "resources-ai-example", + MCPServers: []mcp.MCPServerConfig{ + { + Name: "filesystem", + Config: mcp.MCPClientOptions{ + Name: "mcp-filesystem", + Version: "1.0.0", + Stdio: &mcp.StdioConfig{ + Command: "npx", + Args: []string{"@modelcontextprotocol/server-filesystem", "/tmp"}, + }, + }, + }, + }, + }) + if err != nil { + logger.FromContext(ctx).Warn("Failed to create MCP host for AI example", "error", err) + } else { + // Get all detached resources + hostResources, err := host.GetActiveResources(ctx) + if err != nil { + logger.FromContext(ctx).Warn("Failed to get resources for AI example", "error", err) + } else { + logger.FromContext(ctx).Info("Got detached resources for AI generation", "count", len(hostResources)) + + // Get tools for AI generation + tools, _ := host.GetActiveTools(ctx, g) + var toolRefs []ai.ToolRef + for _, tool := range tools { + toolRefs = append(toolRefs, tool) + } + + // Generate AI response that uses resources + prompt := `You have access to filesystem resources. +Please read the file /tmp/genkit-mcp-test.txt if it exists and tell me what you find. +Use the available tools and resources to complete this task.` + + response, err := genkit.Generate(ctx, g, + ai.WithPrompt(prompt), + ai.WithTools(toolRefs...), + ai.WithResources(hostResources...), // Pass the detached resources! + ai.WithToolChoice(ai.ToolChoiceAuto), + ) + if err != nil { + logger.FromContext(ctx).Error("AI generation with resources failed", "error", err) + } else { + logger.FromContext(ctx).Info("AI generation with resources completed", "response", response.Text()) + } + } + + // Disconnect from all servers + host.Disconnect(ctx, "filesystem") + logger.FromContext(ctx).Info("Disconnected from all servers in AI example") + } + + logger.FromContext(ctx).Info("MCP Resources demonstration completed") +} + func main() { if len(os.Args) < 2 { - fmt.Println("Usage: go run main.go [manager|multi|client|getprompt|streamablehttp|test]") - fmt.Println(" manager - MCP Manager example with time server") - fmt.Println(" multi - MCP Manager example with multiple servers (time and fetch)") + fmt.Println("Usage: go run main.go [manager|multi|client|getprompt|streamablehttp|resources]") + fmt.Println(" manager - MCP Host example with time server (tools & resources)") + fmt.Println(" multi - MCP Host example with multiple servers (tools & resources)") fmt.Println(" client - MCP Client example with time server") fmt.Println(" getprompt - MCP Client GetPrompt example") fmt.Println(" streamablehttp - MCP Client Streamable HTTP example") + fmt.Println(" resources - MCP Resources example (filesystem & demo server)") os.Exit(1) } @@ -306,10 +495,10 @@ func main() { switch os.Args[1] { case "manager": - logger.FromContext(ctx).Info("Running MCP Manager example") + logger.FromContext(ctx).Info("Running MCP Host example") managerExample() case "multi": - logger.FromContext(ctx).Info("Running MCP Manager multi-server example") + logger.FromContext(ctx).Info("Running MCP Host multi-server example") multiServerManagerExample() case "client": logger.FromContext(ctx).Info("Running MCP Client example") @@ -320,9 +509,12 @@ func main() { case "streamablehttp": logger.FromContext(ctx).Info("Running MCP Client Streamable HTTP example") clientStreamableHTTPExample() + case "resources": + logger.FromContext(ctx).Info("Running MCP Resources example") + resourcesExample() default: fmt.Printf("Unknown example: %s\n", os.Args[1]) - fmt.Println("Use 'manager', 'multi', 'client', 'getprompt', or 'streamablehttp'") + fmt.Println("Use 'manager', 'multi', 'client', 'getprompt', 'streamablehttp', or 'resources'") os.Exit(1) } } diff --git a/go/samples/mcp-server/server.go b/go/samples/mcp-server/server.go index 75c5fdd502..566923670c 100644 --- a/go/samples/mcp-server/server.go +++ b/go/samples/mcp-server/server.go @@ -148,7 +148,7 @@ func main() { logger.FromContext(ctx).Info("Starting MCP server", "name", "text-utilities", "tools", server.ListRegisteredTools()) logger.FromContext(ctx).Info("Ready! Run: go run client.go") - if err := server.ServeStdio(ctx); err != nil && err != context.Canceled { + if err := server.ServeStdio(); err != nil && err != context.Canceled { logger.FromContext(ctx).Error("MCP server error", "error", err) os.Exit(1) }