From 804b9c7bf9eb00a48364e9486b1db52fc5c92f21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 23:55:17 +0000 Subject: [PATCH 1/2] Initial plan From 7e0153a5f27d209542a6ab59a8e9d03b9db7c103 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:07:30 +0000 Subject: [PATCH 2/2] refactor: semantic function clustering - absorb dockerutil, reduce duplication, move outlier free functions - Absorb internal/dockerutil (single-function package) into internal/mcp - Moved ExpandEnvArgs to internal/mcp/dockerenv.go - Updated connection.go and connection_test.go; consolidated test cases - Deleted internal/dockerutil directory - Fix auth.TruncateSessionID duplication with strutil.Truncate - TruncateSessionID delegates to strutil.Truncate(sessionID, 8) - Move toDIFCTags/tagsToStrings from unified.go to internal/difc - New internal/difc/tags.go with exported StringsToTags/TagsToStrings - Added unit tests in internal/difc/tags_test.go - Move convertToCallToolResult/parseToolArguments from unified.go to internal/mcp - New internal/mcp/tool_result.go with ConvertToCallToolResult/ParseToolArguments - Updated unified.go and its test to use mcp.* versions - Uses logger.New('mcp:tool_result') for debug logging per project conventions Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- internal/auth/header.go | 6 +- internal/difc/tags.go | 25 ++++ internal/difc/tags_test.go | 89 +++++++++++ internal/dockerutil/env_test.go | 107 ------------- internal/mcp/connection.go | 3 +- internal/mcp/connection_test.go | 46 +++--- .../{dockerutil/env.go => mcp/dockerenv.go} | 16 +- internal/mcp/tool_result.go | 119 +++++++++++++++ internal/server/call_tool_result_test.go | 5 +- internal/server/unified.go | 141 +----------------- 10 files changed, 283 insertions(+), 274 deletions(-) create mode 100644 internal/difc/tags.go create mode 100644 internal/difc/tags_test.go delete mode 100644 internal/dockerutil/env_test.go rename internal/{dockerutil/env.go => mcp/dockerenv.go} (68%) create mode 100644 internal/mcp/tool_result.go diff --git a/internal/auth/header.go b/internal/auth/header.go index f6b0d122..4a0e32d1 100644 --- a/internal/auth/header.go +++ b/internal/auth/header.go @@ -40,6 +40,7 @@ import ( "github.com/github/gh-aw-mcpg/internal/logger" "github.com/github/gh-aw-mcpg/internal/logger/sanitize" + "github.com/github/gh-aw-mcpg/internal/strutil" ) var log = logger.New("auth:header") @@ -173,8 +174,5 @@ func TruncateSessionID(sessionID string) string { if sessionID == "" { return "(none)" } - if len(sessionID) <= 8 { - return sessionID - } - return sessionID[:8] + "..." + return strutil.Truncate(sessionID, 8) } diff --git a/internal/difc/tags.go b/internal/difc/tags.go new file mode 100644 index 00000000..596f573f --- /dev/null +++ b/internal/difc/tags.go @@ -0,0 +1,25 @@ +package difc + +import "strings" + +// StringsToTags converts a slice of strings to a slice of Tags, +// trimming whitespace and skipping empty values. +func StringsToTags(values []string) []Tag { + tags := make([]Tag, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + tags = append(tags, Tag(trimmed)) + } + } + return tags +} + +// TagsToStrings converts a slice of Tags to a slice of strings. +func TagsToStrings(tags []Tag) []string { + values := make([]string, 0, len(tags)) + for _, tag := range tags { + values = append(values, string(tag)) + } + return values +} diff --git a/internal/difc/tags_test.go b/internal/difc/tags_test.go new file mode 100644 index 00000000..a7694f9a --- /dev/null +++ b/internal/difc/tags_test.go @@ -0,0 +1,89 @@ +package difc + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStringsToTags(t *testing.T) { + tests := []struct { + name string + values []string + expected []Tag + }{ + { + name: "empty slice", + values: []string{}, + expected: []Tag{}, + }, + { + name: "nil slice", + values: nil, + expected: []Tag{}, + }, + { + name: "single value", + values: []string{"private:owner"}, + expected: []Tag{"private:owner"}, + }, + { + name: "multiple values", + values: []string{"private:owner", "private:owner/repo"}, + expected: []Tag{"private:owner", "private:owner/repo"}, + }, + { + name: "trims whitespace", + values: []string{" private:owner ", "\tprivate:repo\t"}, + expected: []Tag{"private:owner", "private:repo"}, + }, + { + name: "skips empty strings", + values: []string{"private:owner", "", " ", "private:repo"}, + expected: []Tag{"private:owner", "private:repo"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := StringsToTags(tt.values) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestTagsToStrings(t *testing.T) { + tests := []struct { + name string + tags []Tag + expected []string + }{ + { + name: "empty slice", + tags: []Tag{}, + expected: []string{}, + }, + { + name: "nil slice", + tags: nil, + expected: []string{}, + }, + { + name: "single tag", + tags: []Tag{"private:owner"}, + expected: []string{"private:owner"}, + }, + { + name: "multiple tags", + tags: []Tag{"private:owner", "private:owner/repo"}, + expected: []string{"private:owner", "private:owner/repo"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := TagsToStrings(tt.tags) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/dockerutil/env_test.go b/internal/dockerutil/env_test.go deleted file mode 100644 index 50cdc050..00000000 --- a/internal/dockerutil/env_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package dockerutil - -import ( - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestExpandEnvArgs(t *testing.T) { - tests := []struct { - name string - args []string - envVars map[string]string - expected []string - }{ - { - name: "no -e flags", - args: []string{"run", "--rm", "image"}, - envVars: map[string]string{}, - expected: []string{"run", "--rm", "image"}, - }, - { - name: "expand single env variable", - args: []string{"run", "-e", "VAR_NAME", "image"}, - envVars: map[string]string{"VAR_NAME": "value1"}, - expected: []string{"run", "-e", "VAR_NAME=value1", "image"}, - }, - { - name: "expand multiple env variables", - args: []string{"run", "-e", "VAR1", "-e", "VAR2", "image"}, - envVars: map[string]string{"VAR1": "value1", "VAR2": "value2"}, - expected: []string{"run", "-e", "VAR1=value1", "-e", "VAR2=value2", "image"}, - }, - { - name: "preserve existing key=value format", - args: []string{"run", "-e", "VAR=predefined", "image"}, - envVars: map[string]string{}, - expected: []string{"run", "-e", "VAR=predefined", "image"}, - }, - { - name: "mixed: expand and preserve", - args: []string{"run", "-e", "VAR1", "-e", "VAR2=fixed", "image"}, - envVars: map[string]string{"VAR1": "value1"}, - expected: []string{"run", "-e", "VAR1=value1", "-e", "VAR2=fixed", "image"}, - }, - { - name: "undefined env variable leaves arg unchanged", - args: []string{"run", "-e", "UNDEFINED_VAR", "image"}, - envVars: map[string]string{}, - expected: []string{"run", "-e", "UNDEFINED_VAR", "image"}, - }, - { - name: "empty env variable value expands to key=", - args: []string{"run", "-e", "EMPTY_VAR", "image"}, - envVars: map[string]string{"EMPTY_VAR": ""}, - expected: []string{"run", "-e", "EMPTY_VAR=", "image"}, - }, - { - name: "-e at end of args (no following arg)", - args: []string{"run", "image", "-e"}, - envVars: map[string]string{}, - expected: []string{"run", "image", "-e"}, - }, - { - name: "nil args returns empty slice", - args: nil, - envVars: map[string]string{}, - expected: []string{}, - }, - { - name: "empty args returns empty slice", - args: []string{}, - envVars: map[string]string{}, - expected: []string{}, - }, - { - name: "-e followed by empty string arg is not expanded", - args: []string{"run", "-e", "", "image"}, - envVars: map[string]string{}, - expected: []string{"run", "-e", "", "image"}, - }, - { - name: "value with equals sign in env var value", - args: []string{"run", "-e", "KEY_WITH_EQUALS", "image"}, - envVars: map[string]string{"KEY_WITH_EQUALS": "a=b=c"}, - expected: []string{"run", "-e", "KEY_WITH_EQUALS=a=b=c", "image"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - for k, v := range tt.envVars { - require.NoError(t, os.Setenv(k, v)) - } - t.Cleanup(func() { - for k := range tt.envVars { - os.Unsetenv(k) - } - }) - - result := ExpandEnvArgs(tt.args) - assert.Equal(t, tt.expected, result) - }) - } -} diff --git a/internal/mcp/connection.go b/internal/mcp/connection.go index 6a6d382a..e4a2c5c6 100644 --- a/internal/mcp/connection.go +++ b/internal/mcp/connection.go @@ -15,7 +15,6 @@ import ( "sync" "time" - "github.com/github/gh-aw-mcpg/internal/dockerutil" "github.com/github/gh-aw-mcpg/internal/logger" "github.com/github/gh-aw-mcpg/internal/logger/sanitize" sdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -131,7 +130,7 @@ func NewConnection(ctx context.Context, serverID, command string, args []string, // Expand Docker -e flags that reference environment variables // Docker's `-e VAR_NAME` expects VAR_NAME to be in the environment - expandedArgs := dockerutil.ExpandEnvArgs(args) + expandedArgs := ExpandEnvArgs(args) logConn.Printf("Expanded args for Docker env: %v", sanitize.SanitizeArgs(expandedArgs)) // Create command transport diff --git a/internal/mcp/connection_test.go b/internal/mcp/connection_test.go index 44e56be5..0c88c17b 100644 --- a/internal/mcp/connection_test.go +++ b/internal/mcp/connection_test.go @@ -10,7 +10,6 @@ import ( "strings" "testing" - "github.com/github/gh-aw-mcpg/internal/dockerutil" "github.com/github/gh-aw-mcpg/internal/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -202,13 +201,13 @@ func TestExpandDockerEnvArgs(t *testing.T) { expected: []string{"run", "-e", "VAR1=value1", "-e", "VAR2=fixed", "image"}, }, { - name: "undefined env variable", + name: "undefined env variable leaves arg unchanged", args: []string{"run", "-e", "UNDEFINED_VAR", "image"}, envVars: map[string]string{}, expected: []string{"run", "-e", "UNDEFINED_VAR", "image"}, }, { - name: "empty env variable value", + name: "empty env variable value expands to key=", args: []string{"run", "-e", "EMPTY_VAR", "image"}, envVars: map[string]string{"EMPTY_VAR": ""}, expected: []string{"run", "-e", "EMPTY_VAR=", "image"}, @@ -219,32 +218,45 @@ func TestExpandDockerEnvArgs(t *testing.T) { envVars: map[string]string{}, expected: []string{"run", "image", "-e"}, }, + { + name: "nil args returns empty slice", + args: nil, + envVars: map[string]string{}, + expected: []string{}, + }, + { + name: "empty args returns empty slice", + args: []string{}, + envVars: map[string]string{}, + expected: []string{}, + }, + { + name: "-e followed by empty string arg is not expanded", + args: []string{"run", "-e", "", "image"}, + envVars: map[string]string{}, + expected: []string{"run", "-e", "", "image"}, + }, + { + name: "value with equals sign in env var value", + args: []string{"run", "-e", "KEY_WITH_EQUALS", "image"}, + envVars: map[string]string{"KEY_WITH_EQUALS": "a=b=c"}, + expected: []string{"run", "-e", "KEY_WITH_EQUALS=a=b=c", "image"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Set up environment variables for test for k, v := range tt.envVars { - os.Setenv(k, v) + require.NoError(t, os.Setenv(k, v)) } - // Clean up after test t.Cleanup(func() { for k := range tt.envVars { os.Unsetenv(k) } }) - result := dockerutil.ExpandEnvArgs(tt.args) - - if len(result) != len(tt.expected) { - t.Fatalf("Expected %d args, got %d: %v", len(tt.expected), len(result), result) - } - - for i := range result { - if result[i] != tt.expected[i] { - t.Errorf("Arg %d: expected '%s', got '%s'", i, tt.expected[i], result[i]) - } - } + result := ExpandEnvArgs(tt.args) + assert.Equal(t, tt.expected, result) }) } } diff --git a/internal/dockerutil/env.go b/internal/mcp/dockerenv.go similarity index 68% rename from internal/dockerutil/env.go rename to internal/mcp/dockerenv.go index 448936de..ccfaebf1 100644 --- a/internal/dockerutil/env.go +++ b/internal/mcp/dockerenv.go @@ -1,4 +1,4 @@ -package dockerutil +package mcp import ( "fmt" @@ -8,12 +8,12 @@ import ( "github.com/github/gh-aw-mcpg/internal/logger" ) -var logDockerutil = logger.New("dockerutil:env") +var logDockerEnv = logger.New("mcp:dockerenv") -// ExpandEnvArgs expands Docker -e flags that reference environment variables -// Converts "-e VAR_NAME" to "-e VAR_NAME=value" by reading from the process environment +// ExpandEnvArgs expands Docker -e flags that reference environment variables. +// Converts "-e VAR_NAME" to "-e VAR_NAME=value" by reading from the process environment. func ExpandEnvArgs(args []string) []string { - logDockerutil.Printf("Expanding env args: input_count=%d", len(args)) + logDockerEnv.Printf("Expanding env args: input_count=%d", len(args)) result := make([]string, 0, len(args)) for i := 0; i < len(args); i++ { arg := args[i] @@ -25,17 +25,17 @@ func ExpandEnvArgs(args []string) []string { if len(nextArg) > 0 && !strings.Contains(nextArg, "=") { // Look up the variable in the environment if value, exists := os.LookupEnv(nextArg); exists { - logDockerutil.Printf("Expanding env var: name=%s", nextArg) + logDockerEnv.Printf("Expanding env var: name=%s", nextArg) result = append(result, "-e") result = append(result, fmt.Sprintf("%s=%s", nextArg, value)) i++ // Skip the next arg since we processed it continue } - logDockerutil.Printf("Env var not found in process environment: name=%s", nextArg) + logDockerEnv.Printf("Env var not found in process environment: name=%s", nextArg) } } result = append(result, arg) } - logDockerutil.Printf("Env args expansion complete: output_count=%d", len(result)) + logDockerEnv.Printf("Env args expansion complete: output_count=%d", len(result)) return result } diff --git a/internal/mcp/tool_result.go b/internal/mcp/tool_result.go new file mode 100644 index 00000000..055fe61a --- /dev/null +++ b/internal/mcp/tool_result.go @@ -0,0 +1,119 @@ +package mcp + +import ( + "encoding/json" + "fmt" + + "github.com/github/gh-aw-mcpg/internal/logger" + sdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var logToolResult = logger.New("mcp:tool_result") + +// ConvertToCallToolResult converts backend result data to SDK CallToolResult format. +// The backend returns a JSON object with a "content" field containing an array of content items. +func ConvertToCallToolResult(data interface{}) (*sdk.CallToolResult, error) { + // Try to marshal and unmarshal to get the structure + dataBytes, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal backend result: %w", err) + } + + // First, try to detect if the response is an array (some backends return arrays directly) + var rawArray []json.RawMessage + if err := json.Unmarshal(dataBytes, &rawArray); err == nil { + // It's an array - wrap it as a single text content item + logToolResult.Printf("Backend returned array with %d items, wrapping as text", len(rawArray)) + return &sdk.CallToolResult{ + Content: []sdk.Content{ + &sdk.TextContent{ + Text: string(dataBytes), + }, + }, + IsError: false, + }, nil + } + + // Check if response is an object with a "content" field (standard MCP format) + // We need to distinguish between: + // 1. {"content": []} - empty array, should preserve as 0 content items + // 2. {"content": [...]} - has items, process normally + // 3. {"some": "other"} - no content field, wrap as text + var hasContentField struct { + Content *json.RawMessage `json:"content"` + IsError bool `json:"isError,omitempty"` + } + + if err := json.Unmarshal(dataBytes, &hasContentField); err != nil || hasContentField.Content == nil { + // No "content" field or parse error - wrap raw response as text + logToolResult.Printf("No content field found, wrapping raw response as text") + return &sdk.CallToolResult{ + Content: []sdk.Content{ + &sdk.TextContent{ + Text: string(dataBytes), + }, + }, + IsError: false, + }, nil + } + + // Parse the backend result structure (standard MCP CallToolResult format) + var backendResult struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + } `json:"content"` + IsError bool `json:"isError,omitempty"` + } + + if err := json.Unmarshal(dataBytes, &backendResult); err != nil { + // If parsing fails, wrap the raw response as text content + logToolResult.Printf("Failed to parse as CallToolResult, wrapping raw response: %v", err) + return &sdk.CallToolResult{ + Content: []sdk.Content{ + &sdk.TextContent{ + Text: string(dataBytes), + }, + }, + IsError: false, + }, nil + } + + // Convert content items to SDK Content format + // Note: Empty content array is valid and should be preserved (0 items) + content := make([]sdk.Content, 0, len(backendResult.Content)) + for _, item := range backendResult.Content { + switch item.Type { + case "text": + content = append(content, &sdk.TextContent{ + Text: item.Text, + }) + default: + // For unknown types, try to preserve as text + logToolResult.Printf("Unknown content type '%s', treating as text", item.Type) + content = append(content, &sdk.TextContent{ + Text: item.Text, + }) + } + } + + return &sdk.CallToolResult{ + Content: content, + IsError: backendResult.IsError, + }, nil +} + +// ParseToolArguments extracts and unmarshals tool arguments from a CallToolRequest. +// Returns the parsed arguments as a map, or an error if parsing fails. +func ParseToolArguments(req *sdk.CallToolRequest) (map[string]interface{}, error) { + var toolArgs map[string]interface{} + if req.Params.Arguments != nil { + if err := json.Unmarshal(req.Params.Arguments, &toolArgs); err != nil { + return nil, fmt.Errorf("failed to parse arguments: %w", err) + } + } else { + // No arguments provided, use empty map + toolArgs = make(map[string]interface{}) + } + return toolArgs, nil +} diff --git a/internal/server/call_tool_result_test.go b/internal/server/call_tool_result_test.go index 0fda5da7..d4e3811d 100644 --- a/internal/server/call_tool_result_test.go +++ b/internal/server/call_tool_result_test.go @@ -4,6 +4,7 @@ import ( "fmt" "testing" + "github.com/github/gh-aw-mcpg/internal/mcp" sdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -98,7 +99,7 @@ func TestConvertToCallToolResult_VariousFormats(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := convertToCallToolResult(tt.input) + result, err := mcp.ConvertToCallToolResult(tt.input) if tt.expectError { assert.Error(t, err) @@ -138,7 +139,7 @@ func TestConvertToCallToolResult_NilCheck(t *testing.T) { }, } - result, err := convertToCallToolResult(backendResponse) + result, err := mcp.ConvertToCallToolResult(backendResponse) require.NoError(t, err, "Conversion should not error") diff --git a/internal/server/unified.go b/internal/server/unified.go index a7658c77..43cdcf0a 100644 --- a/internal/server/unified.go +++ b/internal/server/unified.go @@ -379,7 +379,7 @@ func (us *UnifiedServer) registerToolsFromBackend(serverID string) error { // Create the handler function handler := func(ctx context.Context, req *sdk.CallToolRequest, args interface{}) (*sdk.CallToolResult, interface{}, error) { // Extract arguments from the request params (not the args parameter which is SDK internal state) - toolArgs, err := parseToolArguments(req) + toolArgs, err := mcp.ParseToolArguments(req) if err != nil { logger.LogError("client", "Failed to unmarshal tool arguments, tool=%s, error=%v", toolNameCopy, err) return newErrorCallToolResult(err) @@ -489,7 +489,7 @@ func (us *UnifiedServer) registerSysTools() error { // Create sys_init handler sysInitHandler := func(ctx context.Context, req *sdk.CallToolRequest, args interface{}) (*sdk.CallToolResult, interface{}, error) { // Extract arguments from the request params - toolArgs, err := parseToolArguments(req) + toolArgs, err := mcp.ParseToolArguments(req) if err != nil { logger.LogError("client", "Failed to unmarshal sys_init arguments, error=%v", err) return newErrorCallToolResult(err) @@ -848,99 +848,6 @@ func (g *guardBackendCaller) CallTool(ctx context.Context, toolName string, args return executeBackendToolCall(g.ctx, g.server.launcher, g.serverID, sessionID.(string), toolName, args) } -// convertToCallToolResult converts backend result data to SDK CallToolResult format -// The backend returns a JSON object with a "content" field containing an array of content items -func convertToCallToolResult(data interface{}) (*sdk.CallToolResult, error) { - // Try to marshal and unmarshal to get the structure - dataBytes, err := json.Marshal(data) - if err != nil { - return nil, fmt.Errorf("failed to marshal backend result: %w", err) - } - - // First, try to detect if the response is an array (some backends return arrays directly) - var rawArray []json.RawMessage - if err := json.Unmarshal(dataBytes, &rawArray); err == nil { - // It's an array - wrap it as a single text content item - log.Printf("[convertToCallToolResult] Backend returned array with %d items, wrapping as text", len(rawArray)) - return &sdk.CallToolResult{ - Content: []sdk.Content{ - &sdk.TextContent{ - Text: string(dataBytes), - }, - }, - IsError: false, - }, nil - } - - // Check if response is an object with a "content" field (standard MCP format) - // We need to distinguish between: - // 1. {"content": []} - empty array, should preserve as 0 content items - // 2. {"content": [...]} - has items, process normally - // 3. {"some": "other"} - no content field, wrap as text - var hasContentField struct { - Content *json.RawMessage `json:"content"` - IsError bool `json:"isError,omitempty"` - } - - if err := json.Unmarshal(dataBytes, &hasContentField); err != nil || hasContentField.Content == nil { - // No "content" field or parse error - wrap raw response as text - log.Printf("[convertToCallToolResult] No content field found, wrapping raw response as text") - return &sdk.CallToolResult{ - Content: []sdk.Content{ - &sdk.TextContent{ - Text: string(dataBytes), - }, - }, - IsError: false, - }, nil - } - - // Parse the backend result structure (standard MCP CallToolResult format) - var backendResult struct { - Content []struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - } `json:"content"` - IsError bool `json:"isError,omitempty"` - } - - if err := json.Unmarshal(dataBytes, &backendResult); err != nil { - // If parsing fails, wrap the raw response as text content - log.Printf("[convertToCallToolResult] Failed to parse as CallToolResult, wrapping raw response: %v", err) - return &sdk.CallToolResult{ - Content: []sdk.Content{ - &sdk.TextContent{ - Text: string(dataBytes), - }, - }, - IsError: false, - }, nil - } - - // Convert content items to SDK Content format - // Note: Empty content array is valid and should be preserved (0 items) - content := make([]sdk.Content, 0, len(backendResult.Content)) - for _, item := range backendResult.Content { - switch item.Type { - case "text": - content = append(content, &sdk.TextContent{ - Text: item.Text, - }) - default: - // For unknown types, try to preserve as text - log.Printf("Warning: Unknown content type '%s', treating as text", item.Type) - content = append(content, &sdk.TextContent{ - Text: item.Text, - }) - } - } - - return &sdk.CallToolResult{ - Content: content, - IsError: backendResult.IsError, - }, nil -} - // callBackendTool calls a tool on a backend server with DIFC enforcement func (us *UnifiedServer) callBackendTool(ctx context.Context, serverID, toolName string, args interface{}) (*sdk.CallToolResult, interface{}, error) { // Note: Session validation happens at the tool registration level via closures @@ -974,8 +881,8 @@ func (us *UnifiedServer) callBackendTool(ctx context.Context, serverID, toolName agentID, agentLabels.GetSecrecyTags(), agentLabels.GetIntegrityTags()) ctx = context.WithValue(ctx, mcp.AgentTagsSnapshotContextKey, &mcp.AgentTagsSnapshot{ - Secrecy: tagsToStrings(agentLabels.GetSecrecyTags()), - Integrity: tagsToStrings(agentLabels.GetIntegrityTags()), + Secrecy: difc.TagsToStrings(agentLabels.GetSecrecyTags()), + Integrity: difc.TagsToStrings(agentLabels.GetIntegrityTags()), }) // Store request state for guards that need request context during response labeling. @@ -1114,7 +1021,7 @@ func (us *UnifiedServer) callBackendTool(ctx context.Context, serverID, toolName } // Convert finalResult to SDK CallToolResult format - callResult, err := convertToCallToolResult(finalResult) + callResult, err := mcp.ConvertToCallToolResult(finalResult) if err != nil { return newErrorCallToolResult(fmt.Errorf("failed to convert result: %w", err)) } @@ -1135,21 +1042,6 @@ func newErrorCallToolResult(err error) (*sdk.CallToolResult, interface{}, error) return &sdk.CallToolResult{IsError: true}, nil, err } -// parseToolArguments extracts and unmarshals tool arguments from a CallToolRequest -// Returns the parsed arguments as a map, or an error if parsing fails -func parseToolArguments(req *sdk.CallToolRequest) (map[string]interface{}, error) { - var toolArgs map[string]interface{} - if req.Params.Arguments != nil { - if err := json.Unmarshal(req.Params.Arguments, &toolArgs); err != nil { - return nil, fmt.Errorf("failed to parse arguments: %w", err) - } - } else { - // No arguments provided, use empty map - toolArgs = make(map[string]interface{}) - } - return toolArgs, nil -} - // getSessionID extracts the MCP session ID from the context func (us *UnifiedServer) getSessionID(ctx context.Context) string { if sessionID, ok := ctx.Value(SessionIDContextKey).(string); ok && sessionID != "" { @@ -1163,25 +1055,6 @@ func (us *UnifiedServer) getSessionID(ctx context.Context) string { return "default" } -func toDIFCTags(values []string) []difc.Tag { - tags := make([]difc.Tag, 0, len(values)) - for _, value := range values { - trimmed := strings.TrimSpace(value) - if trimmed != "" { - tags = append(tags, difc.Tag(trimmed)) - } - } - return tags -} - -func tagsToStrings(tags []difc.Tag) []string { - values := make([]string, 0, len(tags)) - for _, tag := range tags { - values = append(values, string(tag)) - } - return values -} - func normalizeScopeKind(policy map[string]interface{}) map[string]interface{} { if policy == nil { return nil @@ -1426,8 +1299,8 @@ func (us *UnifiedServer) ensureGuardInitialized( } agentID := guard.GetAgentIDFromContext(ctx) - secrecyTags := toDIFCTags(labelAgentResult.Agent.Secrecy) - integrityTags := toDIFCTags(labelAgentResult.Agent.Integrity) + secrecyTags := difc.StringsToTags(labelAgentResult.Agent.Secrecy) + integrityTags := difc.StringsToTags(labelAgentResult.Agent.Integrity) // Merge labels into existing agent (union semantics). // Multiple guards may contribute labels for the same agent; each guard's