From 3f521f5b9a19dd1b5e4ebe6de7635aba7ebbc7a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:39:28 +0000 Subject: [PATCH 1/5] Initial plan From 3b1323654c1cb63268ba44edfd388630a4bfc88b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:53:31 +0000 Subject: [PATCH 2/5] feat: add `use` subcommands and shared primitives library for MCP client - Add client/internal/mcp/primitives.go with shared CallTool, ReadResource, GetPrompt, ListTools, ListResources, ListPrompts functions and formatters - Add client/internal/mcp/primitives_test.go with comprehensive unit tests - Add GetPrompt and ReadResource to client/internal/mcp/client.go - Add client/cmd/use.go parent subcommand - Add client/cmd/use_tool.go, use_resource.go, use_prompt.go subcommands - Add client/cmd/use_test.go with CLI tests - Refactor client/cmd/integration_tests.go to use shared primitives - Refactor client/internal/testing/runner.go to use shared ContentBlock type - Support --format json|text|markdown across all subcommands Agent-Logs-Url: https://github.com/advanced-security/codeql-development-mcp-server/sessions/78e52dbe-a4f9-4a7a-b042-c14ad89421b1 Co-authored-by: data-douser <70299490+data-douser@users.noreply.github.com> --- client/cmd/integration_tests.go | 24 +- client/cmd/root.go | 2 +- client/cmd/use.go | 58 ++++ client/cmd/use_prompt.go | 68 +++++ client/cmd/use_resource.go | 60 ++++ client/cmd/use_test.go | 167 +++++++++++ client/cmd/use_tool.go | 69 +++++ client/internal/mcp/client.go | 27 ++ client/internal/mcp/client_test.go | 8 + client/internal/mcp/primitives.go | 281 ++++++++++++++++++ client/internal/mcp/primitives_test.go | 394 +++++++++++++++++++++++++ client/internal/testing/runner.go | 12 +- client/internal/testing/runner_test.go | 30 +- 13 files changed, 1160 insertions(+), 40 deletions(-) create mode 100644 client/cmd/use.go create mode 100644 client/cmd/use_prompt.go create mode 100644 client/cmd/use_resource.go create mode 100644 client/cmd/use_test.go create mode 100644 client/cmd/use_tool.go create mode 100644 client/internal/mcp/primitives.go create mode 100644 client/internal/mcp/primitives_test.go diff --git a/client/cmd/integration_tests.go b/client/cmd/integration_tests.go index d36d2bba..76209ac7 100644 --- a/client/cmd/integration_tests.go +++ b/client/cmd/integration_tests.go @@ -10,7 +10,6 @@ import ( mcpclient "github.com/advanced-security/codeql-development-mcp-server/client/internal/mcp" itesting "github.com/advanced-security/codeql-development-mcp-server/client/internal/testing" - "github.com/mark3labs/mcp-go/mcp" "github.com/spf13/cobra" ) @@ -47,38 +46,29 @@ type mcpToolCaller struct { timeout time.Duration } -func (c *mcpToolCaller) CallToolRaw(name string, params map[string]any) ([]itesting.ContentBlock, bool, error) { +func (c *mcpToolCaller) CallToolRaw(name string, params map[string]any) ([]mcpclient.ContentBlock, bool, error) { ctx := context.Background() if c.timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, c.timeout) defer cancel() } - result, err := c.client.CallTool(ctx, name, params) + + result, err := mcpclient.CallTool(ctx, c.client, name, params) if err != nil { return nil, false, err } - var blocks []itesting.ContentBlock - for _, item := range result.Content { - if textContent, ok := item.(mcp.TextContent); ok { - blocks = append(blocks, itesting.ContentBlock{ - Type: "text", - Text: textContent.Text, - }) - } - } - - return blocks, result.IsError, nil + return result.Content, result.IsError, nil } func (c *mcpToolCaller) ListToolNames() ([]string, error) { - tools, err := c.client.ListTools(context.Background()) + infos, err := mcpclient.ListTools(context.Background(), c.client) if err != nil { return nil, err } - names := make([]string, len(tools)) - for i, t := range tools { + names := make([]string, len(infos)) + for i, t := range infos { names[i] = t.Name } return names, nil diff --git a/client/cmd/root.go b/client/cmd/root.go index 21d959b5..9447a59d 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -51,7 +51,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&mcpMode, "mode", "stdio", "MCP server transport mode (stdio or http)") rootCmd.PersistentFlags().StringVar(&mcpHost, "host", "localhost", "MCP server host (http mode)") rootCmd.PersistentFlags().IntVar(&mcpPort, "port", 3000, "MCP server port (http mode)") - rootCmd.PersistentFlags().StringVar(&outputFmt, "format", "text", "Output format (text or json)") + rootCmd.PersistentFlags().StringVar(&outputFmt, "format", "text", "Output format (text, json, or markdown)") } // MCPMode returns the configured MCP transport mode. diff --git a/client/cmd/use.go b/client/cmd/use.go new file mode 100644 index 00000000..5458b7e6 --- /dev/null +++ b/client/cmd/use.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var useCmd = &cobra.Command{ + Use: "use", + Short: "Call an individual MCP server primitive (tool, resource, or prompt)", + Long: `Connect to the MCP server and call a single primitive. + +Subcommands: + tool Call a tool by name with key-value arguments + resource Read a resource by URI + prompt Get a prompt by name with key-value arguments`, +} + +// parseArgs converts a list of "key=value" strings into a map. +func parseArgs(args []string) (map[string]string, error) { + result := make(map[string]string, len(args)) + for _, a := range args { + key, value, found := cutString(a, "=") + if !found || key == "" { + return nil, fmt.Errorf("invalid argument %q: expected key=value format", a) + } + result[key] = value + } + return result, nil +} + +// parseArgsAny converts a list of "key=value" strings into a map[string]any. +func parseArgsAny(args []string) (map[string]any, error) { + result := make(map[string]any, len(args)) + for _, a := range args { + key, value, found := cutString(a, "=") + if !found || key == "" { + return nil, fmt.Errorf("invalid argument %q: expected key=value format", a) + } + result[key] = value + } + return result, nil +} + +// cutString splits s around the first instance of sep. +func cutString(s, sep string) (before, after string, found bool) { + for i := 0; i+len(sep) <= len(s); i++ { + if s[i:i+len(sep)] == sep { + return s[:i], s[i+len(sep):], true + } + } + return s, "", false +} + +func init() { + rootCmd.AddCommand(useCmd) +} diff --git a/client/cmd/use_prompt.go b/client/cmd/use_prompt.go new file mode 100644 index 00000000..7bf70236 --- /dev/null +++ b/client/cmd/use_prompt.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + mcpclient "github.com/advanced-security/codeql-development-mcp-server/client/internal/mcp" + "github.com/spf13/cobra" +) + +var usePromptArgs []string + +var usePromptCmd = &cobra.Command{ + Use: "prompt ", + Short: "Get an MCP server prompt by name", + Long: `Get a specific MCP server prompt with key-value arguments and print the resulting messages. + +Example: + gh-ql-mcp-client use prompt explain_codeql_query --arg queryPath=/path/to/query.ql --arg language=javascript + gh-ql-mcp-client use prompt explain_codeql_query --arg queryPath=/path/to/query.ql --format json`, + Args: cobra.ExactArgs(1), + RunE: runUsePrompt, +} + +func init() { + useCmd.AddCommand(usePromptCmd) + usePromptCmd.Flags().StringArrayVar(&usePromptArgs, "arg", nil, "Prompt argument in key=value format (repeatable)") +} + +func runUsePrompt(_ *cobra.Command, args []string) error { + promptName := args[0] + + params, err := parseArgs(usePromptArgs) + if err != nil { + return fmt.Errorf("parse prompt arguments: %w", err) + } + + ctx := context.Background() + client, err := connectMCPClient(ctx) + if err != nil { + return err + } + defer client.Close() + + result, err := mcpclient.GetPrompt(ctx, client, promptName, params) + if err != nil { + return err + } + + return outputPromptMessages(result) +} + +func outputPromptMessages(result *mcpclient.PromptMessages) error { + switch OutputFormat() { + case "json": + s, err := mcpclient.FormatJSON(result) + if err != nil { + return err + } + fmt.Fprintln(os.Stdout, s) + case "markdown": + fmt.Fprint(os.Stdout, mcpclient.FormatPromptMessagesMarkdown(result)) + default: + fmt.Fprint(os.Stdout, mcpclient.FormatPromptMessagesText(result)) + } + return nil +} diff --git a/client/cmd/use_resource.go b/client/cmd/use_resource.go new file mode 100644 index 00000000..d87205f7 --- /dev/null +++ b/client/cmd/use_resource.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + mcpclient "github.com/advanced-security/codeql-development-mcp-server/client/internal/mcp" + "github.com/spf13/cobra" +) + +var useResourceCmd = &cobra.Command{ + Use: "resource ", + Short: "Read an MCP server resource by URI", + Long: `Read a specific MCP server resource and print its content. + +Example: + gh-ql-mcp-client use resource codeql://server/tools + gh-ql-mcp-client use resource codeql://server/overview --format markdown`, + Args: cobra.ExactArgs(1), + RunE: runUseResource, +} + +func init() { + useCmd.AddCommand(useResourceCmd) +} + +func runUseResource(_ *cobra.Command, args []string) error { + uri := args[0] + + ctx := context.Background() + client, err := connectMCPClient(ctx) + if err != nil { + return err + } + defer client.Close() + + result, err := mcpclient.ReadResource(ctx, client, uri) + if err != nil { + return err + } + + return outputResourceContent(result) +} + +func outputResourceContent(result *mcpclient.ResourceContent) error { + switch OutputFormat() { + case "json": + s, err := mcpclient.FormatJSON(result) + if err != nil { + return err + } + fmt.Fprintln(os.Stdout, s) + case "markdown": + fmt.Fprint(os.Stdout, mcpclient.FormatResourceContentMarkdown(result)) + default: + fmt.Fprint(os.Stdout, mcpclient.FormatResourceContentText(result)) + } + return nil +} diff --git a/client/cmd/use_test.go b/client/cmd/use_test.go new file mode 100644 index 00000000..1092bbff --- /dev/null +++ b/client/cmd/use_test.go @@ -0,0 +1,167 @@ +package cmd + +import ( + "bytes" + "testing" +) + +func TestUseCommand_InHelp(t *testing.T) { + output, _ := executeRootCmd([]string{"--help"}) + + if !bytes.Contains([]byte(output), []byte("use")) { + t.Error("root help should list 'use' subcommand") + } +} + +func TestUseCommand_Help(t *testing.T) { + output, err := executeRootCmd([]string{"use", "--help"}) + if err != nil { + t.Fatalf("use --help failed: %v", err) + } + + wantSubstrings := []string{ + "tool", + "resource", + "prompt", + } + for _, want := range wantSubstrings { + if !bytes.Contains([]byte(output), []byte(want)) { + t.Errorf("use help output missing %q", want) + } + } +} + +func TestUseToolCommand_InUseHelp(t *testing.T) { + output, err := executeRootCmd([]string{"use", "--help"}) + if err != nil { + t.Fatalf("use --help failed: %v", err) + } + + if !bytes.Contains([]byte(output), []byte("tool")) { + t.Error("use help should list 'tool' subcommand") + } +} + +func TestUseResourceCommand_InUseHelp(t *testing.T) { + output, err := executeRootCmd([]string{"use", "--help"}) + if err != nil { + t.Fatalf("use --help failed: %v", err) + } + + if !bytes.Contains([]byte(output), []byte("resource")) { + t.Error("use help should list 'resource' subcommand") + } +} + +func TestUsePromptCommand_InUseHelp(t *testing.T) { + output, err := executeRootCmd([]string{"use", "--help"}) + if err != nil { + t.Fatalf("use --help failed: %v", err) + } + + if !bytes.Contains([]byte(output), []byte("prompt")) { + t.Error("use help should list 'prompt' subcommand") + } +} + +func TestUseToolCommand_RequiresName(t *testing.T) { + _, err := executeRootCmd([]string{"use", "tool"}) + if err == nil { + t.Error("use tool without name should fail") + } +} + +func TestUseResourceCommand_RequiresURI(t *testing.T) { + _, err := executeRootCmd([]string{"use", "resource"}) + if err == nil { + t.Error("use resource without URI should fail") + } +} + +func TestUsePromptCommand_RequiresName(t *testing.T) { + _, err := executeRootCmd([]string{"use", "prompt"}) + if err == nil { + t.Error("use prompt without name should fail") + } +} + +func TestParseArgs_Valid(t *testing.T) { + args := []string{"key1=value1", "key2=value2"} + result, err := parseArgs(args) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result["key1"] != "value1" { + t.Errorf("expected key1=value1, got %q", result["key1"]) + } + if result["key2"] != "value2" { + t.Errorf("expected key2=value2, got %q", result["key2"]) + } +} + +func TestParseArgs_Empty(t *testing.T) { + result, err := parseArgs(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 0 { + t.Errorf("expected empty map, got %v", result) + } +} + +func TestParseArgs_Invalid(t *testing.T) { + args := []string{"noequals"} + _, err := parseArgs(args) + if err == nil { + t.Error("expected error for invalid argument") + } +} + +func TestParseArgs_EmptyKey(t *testing.T) { + args := []string{"=value"} + _, err := parseArgs(args) + if err == nil { + t.Error("expected error for empty key") + } +} + +func TestParseArgs_EmptyValue(t *testing.T) { + args := []string{"key="} + result, err := parseArgs(args) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result["key"] != "" { + t.Errorf("expected empty value, got %q", result["key"]) + } +} + +func TestParseArgs_ValueWithEquals(t *testing.T) { + args := []string{"key=value=with=equals"} + result, err := parseArgs(args) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result["key"] != "value=with=equals" { + t.Errorf("expected value=with=equals, got %q", result["key"]) + } +} + +func TestParseArgsAny_Valid(t *testing.T) { + args := []string{"key1=value1", "key2=value2"} + result, err := parseArgsAny(args) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result["key1"] != "value1" { + t.Errorf("expected key1=value1, got %v", result["key1"]) + } +} + +func TestParseArgsAny_Invalid(t *testing.T) { + args := []string{"noequals"} + _, err := parseArgsAny(args) + if err == nil { + t.Error("expected error for invalid argument") + } +} diff --git a/client/cmd/use_tool.go b/client/cmd/use_tool.go new file mode 100644 index 00000000..f1245ed1 --- /dev/null +++ b/client/cmd/use_tool.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + mcpclient "github.com/advanced-security/codeql-development-mcp-server/client/internal/mcp" + "github.com/spf13/cobra" +) + +var useToolArgs []string + +var useToolCmd = &cobra.Command{ + Use: "tool ", + Short: "Call an MCP server tool by name", + Long: `Call a specific MCP server tool with key-value arguments. + +Example: + gh-ql-mcp-client use tool codeql_resolve_languages + gh-ql-mcp-client use tool sarif_list_rules --arg sarifPath=/path/to/results.sarif + gh-ql-mcp-client use tool codeql_resolve_languages --format json`, + Args: cobra.ExactArgs(1), + RunE: runUseTool, +} + +func init() { + useCmd.AddCommand(useToolCmd) + useToolCmd.Flags().StringArrayVar(&useToolArgs, "arg", nil, "Tool argument in key=value format (repeatable)") +} + +func runUseTool(_ *cobra.Command, args []string) error { + toolName := args[0] + + params, err := parseArgsAny(useToolArgs) + if err != nil { + return fmt.Errorf("parse tool arguments: %w", err) + } + + ctx := context.Background() + client, err := connectMCPClient(ctx) + if err != nil { + return err + } + defer client.Close() + + result, err := mcpclient.CallTool(ctx, client, toolName, params) + if err != nil { + return err + } + + return outputToolResult(result) +} + +func outputToolResult(result *mcpclient.ToolResult) error { + switch OutputFormat() { + case "json": + s, err := mcpclient.FormatJSON(result) + if err != nil { + return err + } + fmt.Fprintln(os.Stdout, s) + case "markdown": + fmt.Fprint(os.Stdout, mcpclient.FormatToolResultMarkdown(result)) + default: + fmt.Fprint(os.Stdout, mcpclient.FormatToolResultText(result)) + } + return nil +} diff --git a/client/internal/mcp/client.go b/client/internal/mcp/client.go index 518fa466..6dd79497 100644 --- a/client/internal/mcp/client.go +++ b/client/internal/mcp/client.go @@ -20,9 +20,11 @@ type innerClient interface { Initialize(context.Context, mcp.InitializeRequest) (*mcp.InitializeResult, error) Close() error CallTool(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) + GetPrompt(context.Context, mcp.GetPromptRequest) (*mcp.GetPromptResult, error) ListTools(context.Context, mcp.ListToolsRequest) (*mcp.ListToolsResult, error) ListPrompts(context.Context, mcp.ListPromptsRequest) (*mcp.ListPromptsResult, error) ListResources(context.Context, mcp.ListResourcesRequest) (*mcp.ListResourcesResult, error) + ReadResource(context.Context, mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) } const ( @@ -233,6 +235,31 @@ func (c *Client) ListResources(ctx context.Context) ([]mcp.Resource, error) { return result.Resources, nil } +// GetPrompt retrieves a prompt by name with the given arguments. +func (c *Client) GetPrompt(ctx context.Context, name string, args map[string]string) (*mcp.GetPromptResult, error) { + if c.inner == nil { + return nil, fmt.Errorf("MCP client not connected") + } + + req := mcp.GetPromptRequest{} + req.Params.Name = name + req.Params.Arguments = args + + return c.inner.GetPrompt(ctx, req) +} + +// ReadResource reads a resource by URI. +func (c *Client) ReadResource(ctx context.Context, uri string) (*mcp.ReadResourceResult, error) { + if c.inner == nil { + return nil, fmt.Errorf("MCP client not connected") + } + + req := mcp.ReadResourceRequest{} + req.Params.URI = uri + + return c.inner.ReadResource(ctx, req) +} + // timeoutForTool returns the appropriate timeout for a given tool name. func timeoutForTool(name string) time.Duration { // CodeQL CLI tools need longer timeouts diff --git a/client/internal/mcp/client_test.go b/client/internal/mcp/client_test.go index f0c5e376..661cc4df 100644 --- a/client/internal/mcp/client_test.go +++ b/client/internal/mcp/client_test.go @@ -42,6 +42,14 @@ func (h *hangCloser) ListResources(_ context.Context, _ mcp.ListResourcesRequest return nil, nil } +func (h *hangCloser) GetPrompt(_ context.Context, _ mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return nil, nil +} + +func (h *hangCloser) ReadResource(_ context.Context, _ mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return nil, nil +} + // TestMain handles the subprocess helper mode used by // TestClose_KillsSubprocessOnTimeout. When GO_TEST_HANG_SUBPROCESS is set, // the process simply blocks forever, simulating a stuck MCP server on all diff --git a/client/internal/mcp/primitives.go b/client/internal/mcp/primitives.go new file mode 100644 index 00000000..b020bdc8 --- /dev/null +++ b/client/internal/mcp/primitives.go @@ -0,0 +1,281 @@ +// Package mcp provides a client for connecting to the CodeQL Development MCP Server. +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/mark3labs/mcp-go/mcp" +) + +// ToolResult holds the structured result of calling an MCP tool. +type ToolResult struct { + Content []ContentBlock `json:"content"` + IsError bool `json:"isError"` +} + +// ContentBlock represents a single content block in an MCP tool response. +type ContentBlock struct { + Text string `json:"text"` + Type string `json:"type"` +} + +// ResourceContent holds the structured result of reading an MCP resource. +type ResourceContent struct { + Contents []ResourceContentItem `json:"contents"` +} + +// ResourceContentItem represents a single content item in an MCP resource response. +type ResourceContentItem struct { + MIMEType string `json:"mimeType,omitempty"` + Text string `json:"text"` + URI string `json:"uri"` +} + +// PromptMessages holds the structured result of getting an MCP prompt. +type PromptMessages struct { + Description string `json:"description,omitempty"` + Messages []PromptMessage `json:"messages"` +} + +// PromptMessage represents a single message in an MCP prompt response. +type PromptMessage struct { + Content string `json:"content"` + Role string `json:"role"` +} + +// ToolInfo holds metadata about an MCP tool. +type ToolInfo struct { + Description string `json:"description,omitempty"` + Name string `json:"name"` +} + +// ResourceInfo holds metadata about an MCP resource. +type ResourceInfo struct { + Description string `json:"description,omitempty"` + Name string `json:"name"` + URI string `json:"uri"` +} + +// PromptInfo holds metadata about an MCP prompt. +type PromptInfo struct { + Description string `json:"description,omitempty"` + Name string `json:"name"` +} + +// CallTool invokes an MCP tool by name with the given arguments and returns +// a structured result. +func CallTool(ctx context.Context, c *Client, name string, args map[string]any) (*ToolResult, error) { + result, err := c.CallTool(ctx, name, args) + if err != nil { + return nil, fmt.Errorf("call tool %q: %w", name, err) + } + + tr := &ToolResult{IsError: result.IsError} + for _, item := range result.Content { + if textContent, ok := item.(mcp.TextContent); ok { + tr.Content = append(tr.Content, ContentBlock{ + Type: "text", + Text: textContent.Text, + }) + } + } + return tr, nil +} + +// ReadResource reads an MCP resource by URI and returns its content. +func ReadResource(ctx context.Context, c *Client, uri string) (*ResourceContent, error) { + result, err := c.ReadResource(ctx, uri) + if err != nil { + return nil, fmt.Errorf("read resource %q: %w", uri, err) + } + + rc := &ResourceContent{} + for _, item := range result.Contents { + if textRC, ok := item.(mcp.TextResourceContents); ok { + rc.Contents = append(rc.Contents, ResourceContentItem{ + URI: textRC.URI, + MIMEType: textRC.MIMEType, + Text: textRC.Text, + }) + } + } + return rc, nil +} + +// GetPrompt retrieves an MCP prompt by name with the given arguments. +func GetPrompt(ctx context.Context, c *Client, name string, args map[string]string) (*PromptMessages, error) { + result, err := c.GetPrompt(ctx, name, args) + if err != nil { + return nil, fmt.Errorf("get prompt %q: %w", name, err) + } + + pm := &PromptMessages{Description: result.Description} + for _, msg := range result.Messages { + text := extractContentText(msg.Content) + pm.Messages = append(pm.Messages, PromptMessage{ + Role: string(msg.Role), + Content: text, + }) + } + return pm, nil +} + +// ListTools returns metadata for all tools registered on the MCP server. +func ListTools(ctx context.Context, c *Client) ([]ToolInfo, error) { + tools, err := c.ListTools(ctx) + if err != nil { + return nil, fmt.Errorf("list tools: %w", err) + } + + infos := make([]ToolInfo, len(tools)) + for i, t := range tools { + infos[i] = ToolInfo{Name: t.Name, Description: t.Description} + } + return infos, nil +} + +// ListResources returns metadata for all resources registered on the MCP server. +func ListResources(ctx context.Context, c *Client) ([]ResourceInfo, error) { + resources, err := c.ListResources(ctx) + if err != nil { + return nil, fmt.Errorf("list resources: %w", err) + } + + infos := make([]ResourceInfo, len(resources)) + for i, r := range resources { + infos[i] = ResourceInfo{Name: r.Name, URI: r.URI, Description: r.Description} + } + return infos, nil +} + +// ListPrompts returns metadata for all prompts registered on the MCP server. +func ListPrompts(ctx context.Context, c *Client) ([]PromptInfo, error) { + prompts, err := c.ListPrompts(ctx) + if err != nil { + return nil, fmt.Errorf("list prompts: %w", err) + } + + infos := make([]PromptInfo, len(prompts)) + for i, p := range prompts { + infos[i] = PromptInfo{Name: p.Name, Description: p.Description} + } + return infos, nil +} + +// FormatJSON encodes a value as indented JSON. +func FormatJSON(v any) (string, error) { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return "", fmt.Errorf("format JSON: %w", err) + } + return string(data), nil +} + +// FormatToolResultText renders a ToolResult as human-readable text. +func FormatToolResultText(tr *ToolResult) string { + var sb strings.Builder + if tr.IsError { + sb.WriteString("[ERROR]\n") + } + for _, block := range tr.Content { + sb.WriteString(block.Text) + sb.WriteString("\n") + } + return sb.String() +} + +// FormatResourceContentText renders a ResourceContent as human-readable text. +func FormatResourceContentText(rc *ResourceContent) string { + var sb strings.Builder + for i, item := range rc.Contents { + if i > 0 { + sb.WriteString("\n---\n") + } + if item.URI != "" { + sb.WriteString(fmt.Sprintf("URI: %s\n", item.URI)) + } + if item.MIMEType != "" { + sb.WriteString(fmt.Sprintf("Type: %s\n", item.MIMEType)) + } + sb.WriteString("\n") + sb.WriteString(item.Text) + sb.WriteString("\n") + } + return sb.String() +} + +// FormatPromptMessagesText renders PromptMessages as human-readable text. +func FormatPromptMessagesText(pm *PromptMessages) string { + var sb strings.Builder + if pm.Description != "" { + sb.WriteString(fmt.Sprintf("Description: %s\n\n", pm.Description)) + } + for i, msg := range pm.Messages { + if i > 0 { + sb.WriteString("\n---\n") + } + sb.WriteString(fmt.Sprintf("[%s]\n", msg.Role)) + sb.WriteString(msg.Content) + sb.WriteString("\n") + } + return sb.String() +} + +// FormatToolResultMarkdown renders a ToolResult as markdown. +func FormatToolResultMarkdown(tr *ToolResult) string { + var sb strings.Builder + if tr.IsError { + sb.WriteString("**Error:**\n\n") + } + for _, block := range tr.Content { + sb.WriteString(block.Text) + sb.WriteString("\n") + } + return sb.String() +} + +// FormatResourceContentMarkdown renders a ResourceContent as markdown. +func FormatResourceContentMarkdown(rc *ResourceContent) string { + var sb strings.Builder + for i, item := range rc.Contents { + if i > 0 { + sb.WriteString("\n---\n\n") + } + sb.WriteString(item.Text) + sb.WriteString("\n") + } + return sb.String() +} + +// FormatPromptMessagesMarkdown renders PromptMessages as markdown. +func FormatPromptMessagesMarkdown(pm *PromptMessages) string { + var sb strings.Builder + if pm.Description != "" { + sb.WriteString(fmt.Sprintf("*%s*\n\n", pm.Description)) + } + for i, msg := range pm.Messages { + if i > 0 { + sb.WriteString("\n---\n\n") + } + sb.WriteString(fmt.Sprintf("### %s\n\n", msg.Role)) + sb.WriteString(msg.Content) + sb.WriteString("\n") + } + return sb.String() +} + +// extractContentText extracts the text from an MCP Content interface value. +func extractContentText(content mcp.Content) string { + if tc, ok := content.(mcp.TextContent); ok { + return tc.Text + } + // Fallback: try JSON representation + data, err := json.Marshal(content) + if err != nil { + return fmt.Sprintf("%v", content) + } + return string(data) +} diff --git a/client/internal/mcp/primitives_test.go b/client/internal/mcp/primitives_test.go new file mode 100644 index 00000000..1cbc2100 --- /dev/null +++ b/client/internal/mcp/primitives_test.go @@ -0,0 +1,394 @@ +package mcp + +import ( + "context" + "fmt" + "testing" + + "github.com/mark3labs/mcp-go/mcp" +) + +// fakeInner implements innerClient for unit tests. +type fakeInner struct { + callToolResult *mcp.CallToolResult + callToolErr error + getPromptResult *mcp.GetPromptResult + getPromptErr error + listToolsResult *mcp.ListToolsResult + listToolsErr error + listPromptsResult *mcp.ListPromptsResult + listPromptsErr error + listResourcesResult *mcp.ListResourcesResult + listResourcesErr error + readResourceResult *mcp.ReadResourceResult + readResourceErr error +} + +func (f *fakeInner) Initialize(_ context.Context, _ mcp.InitializeRequest) (*mcp.InitializeResult, error) { + return nil, nil +} + +func (f *fakeInner) Close() error { return nil } + +func (f *fakeInner) CallTool(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return f.callToolResult, f.callToolErr +} + +func (f *fakeInner) GetPrompt(_ context.Context, _ mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return f.getPromptResult, f.getPromptErr +} + +func (f *fakeInner) ListTools(_ context.Context, _ mcp.ListToolsRequest) (*mcp.ListToolsResult, error) { + return f.listToolsResult, f.listToolsErr +} + +func (f *fakeInner) ListPrompts(_ context.Context, _ mcp.ListPromptsRequest) (*mcp.ListPromptsResult, error) { + return f.listPromptsResult, f.listPromptsErr +} + +func (f *fakeInner) ListResources(_ context.Context, _ mcp.ListResourcesRequest) (*mcp.ListResourcesResult, error) { + return f.listResourcesResult, f.listResourcesErr +} + +func (f *fakeInner) ReadResource(_ context.Context, _ mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return f.readResourceResult, f.readResourceErr +} + +func newFakeClient(inner *fakeInner) *Client { + return &Client{inner: inner} +} + +func TestCallTool_Success(t *testing.T) { + fake := &fakeInner{ + callToolResult: &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{Type: "text", Text: "hello world"}, + }, + }, + } + c := newFakeClient(fake) + + result, err := CallTool(context.Background(), c, "test_tool", map[string]any{"key": "value"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Content) != 1 { + t.Fatalf("expected 1 content block, got %d", len(result.Content)) + } + if result.Content[0].Text != "hello world" { + t.Errorf("expected text %q, got %q", "hello world", result.Content[0].Text) + } + if result.IsError { + t.Error("expected IsError=false") + } +} + +func TestCallTool_Error(t *testing.T) { + fake := &fakeInner{ + callToolErr: fmt.Errorf("tool failed"), + } + c := newFakeClient(fake) + + _, err := CallTool(context.Background(), c, "test_tool", nil) + if err == nil { + t.Fatal("expected error") + } + if got := err.Error(); got != `call tool "test_tool": tool failed` { + t.Errorf("unexpected error: %q", got) + } +} + +func TestCallTool_IsError(t *testing.T) { + fake := &fakeInner{ + callToolResult: &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + mcp.TextContent{Type: "text", Text: "bad input"}, + }, + }, + } + c := newFakeClient(fake) + + result, err := CallTool(context.Background(), c, "test_tool", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.IsError { + t.Error("expected IsError=true") + } +} + +func TestCallTool_NotConnected(t *testing.T) { + c := NewClient(Config{Mode: ModeHTTP}) + + _, err := CallTool(context.Background(), c, "test_tool", nil) + if err == nil { + t.Fatal("expected error for disconnected client") + } +} + +func TestReadResource_Success(t *testing.T) { + fake := &fakeInner{ + readResourceResult: &mcp.ReadResourceResult{ + Contents: []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: "codeql://server/overview", + MIMEType: "text/markdown", + Text: "# Overview", + }, + }, + }, + } + c := newFakeClient(fake) + + result, err := ReadResource(context.Background(), c, "codeql://server/overview") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Contents) != 1 { + t.Fatalf("expected 1 content item, got %d", len(result.Contents)) + } + if result.Contents[0].URI != "codeql://server/overview" { + t.Errorf("unexpected URI: %q", result.Contents[0].URI) + } + if result.Contents[0].Text != "# Overview" { + t.Errorf("unexpected text: %q", result.Contents[0].Text) + } +} + +func TestReadResource_Error(t *testing.T) { + fake := &fakeInner{ + readResourceErr: fmt.Errorf("resource not found"), + } + c := newFakeClient(fake) + + _, err := ReadResource(context.Background(), c, "codeql://unknown") + if err == nil { + t.Fatal("expected error") + } +} + +func TestReadResource_NotConnected(t *testing.T) { + c := NewClient(Config{Mode: ModeHTTP}) + + _, err := ReadResource(context.Background(), c, "codeql://test") + if err == nil { + t.Fatal("expected error for disconnected client") + } +} + +func TestGetPrompt_Success(t *testing.T) { + fake := &fakeInner{ + getPromptResult: &mcp.GetPromptResult{ + Description: "Test prompt", + Messages: []mcp.PromptMessage{ + { + Role: mcp.RoleUser, + Content: mcp.TextContent{Type: "text", Text: "Hello from prompt"}, + }, + }, + }, + } + c := newFakeClient(fake) + + result, err := GetPrompt(context.Background(), c, "test_prompt", map[string]string{"lang": "go"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Description != "Test prompt" { + t.Errorf("unexpected description: %q", result.Description) + } + if len(result.Messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(result.Messages)) + } + if result.Messages[0].Role != "user" { + t.Errorf("unexpected role: %q", result.Messages[0].Role) + } + if result.Messages[0].Content != "Hello from prompt" { + t.Errorf("unexpected content: %q", result.Messages[0].Content) + } +} + +func TestGetPrompt_Error(t *testing.T) { + fake := &fakeInner{ + getPromptErr: fmt.Errorf("prompt not found"), + } + c := newFakeClient(fake) + + _, err := GetPrompt(context.Background(), c, "unknown_prompt", nil) + if err == nil { + t.Fatal("expected error") + } +} + +func TestGetPrompt_NotConnected(t *testing.T) { + c := NewClient(Config{Mode: ModeHTTP}) + + _, err := GetPrompt(context.Background(), c, "test_prompt", nil) + if err == nil { + t.Fatal("expected error for disconnected client") + } +} + +func TestListTools_Success(t *testing.T) { + fake := &fakeInner{ + listToolsResult: &mcp.ListToolsResult{ + Tools: []mcp.Tool{ + {Name: "tool_a", Description: "Description A"}, + {Name: "tool_b", Description: "Description B"}, + }, + }, + } + c := newFakeClient(fake) + + infos, err := ListTools(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(infos) != 2 { + t.Fatalf("expected 2 tools, got %d", len(infos)) + } + if infos[0].Name != "tool_a" { + t.Errorf("expected tool_a, got %q", infos[0].Name) + } +} + +func TestListResources_Success(t *testing.T) { + fake := &fakeInner{ + listResourcesResult: &mcp.ListResourcesResult{ + Resources: []mcp.Resource{ + {Name: "resource_a", URI: "codeql://test/a", Description: "Desc A"}, + }, + }, + } + c := newFakeClient(fake) + + infos, err := ListResources(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(infos) != 1 { + t.Fatalf("expected 1 resource, got %d", len(infos)) + } + if infos[0].URI != "codeql://test/a" { + t.Errorf("expected URI %q, got %q", "codeql://test/a", infos[0].URI) + } +} + +func TestListPrompts_Success(t *testing.T) { + fake := &fakeInner{ + listPromptsResult: &mcp.ListPromptsResult{ + Prompts: []mcp.Prompt{ + {Name: "prompt_a", Description: "Desc A"}, + {Name: "prompt_b", Description: "Desc B"}, + }, + }, + } + c := newFakeClient(fake) + + infos, err := ListPrompts(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(infos) != 2 { + t.Fatalf("expected 2 prompts, got %d", len(infos)) + } +} + +func TestFormatJSON(t *testing.T) { + data := map[string]string{"key": "value"} + result, err := FormatJSON(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == "" { + t.Error("expected non-empty JSON") + } +} + +func TestFormatToolResultText(t *testing.T) { + tr := &ToolResult{ + Content: []ContentBlock{ + {Type: "text", Text: "line1"}, + {Type: "text", Text: "line2"}, + }, + } + result := FormatToolResultText(tr) + if result != "line1\nline2\n" { + t.Errorf("unexpected result: %q", result) + } +} + +func TestFormatToolResultText_Error(t *testing.T) { + tr := &ToolResult{ + IsError: true, + Content: []ContentBlock{ + {Type: "text", Text: "bad input"}, + }, + } + result := FormatToolResultText(tr) + if result != "[ERROR]\nbad input\n" { + t.Errorf("unexpected result: %q", result) + } +} + +func TestFormatResourceContentText(t *testing.T) { + rc := &ResourceContent{ + Contents: []ResourceContentItem{ + {URI: "codeql://test", MIMEType: "text/markdown", Text: "# Hello"}, + }, + } + result := FormatResourceContentText(rc) + if result == "" { + t.Error("expected non-empty text") + } +} + +func TestFormatPromptMessagesText(t *testing.T) { + pm := &PromptMessages{ + Description: "Test", + Messages: []PromptMessage{ + {Role: "user", Content: "Hello"}, + }, + } + result := FormatPromptMessagesText(pm) + if result == "" { + t.Error("expected non-empty text") + } +} + +func TestFormatToolResultMarkdown(t *testing.T) { + tr := &ToolResult{ + Content: []ContentBlock{ + {Type: "text", Text: "result"}, + }, + } + result := FormatToolResultMarkdown(tr) + if result != "result\n" { + t.Errorf("unexpected result: %q", result) + } +} + +func TestFormatResourceContentMarkdown(t *testing.T) { + rc := &ResourceContent{ + Contents: []ResourceContentItem{ + {Text: "# Hello"}, + }, + } + result := FormatResourceContentMarkdown(rc) + if result != "# Hello\n" { + t.Errorf("unexpected result: %q", result) + } +} + +func TestFormatPromptMessagesMarkdown(t *testing.T) { + pm := &PromptMessages{ + Messages: []PromptMessage{ + {Role: "user", Content: "Hello"}, + }, + } + result := FormatPromptMessagesMarkdown(pm) + if result == "" { + t.Error("expected non-empty markdown") + } +} diff --git a/client/internal/testing/runner.go b/client/internal/testing/runner.go index 07e281de..ba3883c9 100644 --- a/client/internal/testing/runner.go +++ b/client/internal/testing/runner.go @@ -8,20 +8,16 @@ import ( "sort" "strings" "time" + + mcpprim "github.com/advanced-security/codeql-development-mcp-server/client/internal/mcp" ) // ToolCaller is the interface for making MCP tool calls. type ToolCaller interface { - CallToolRaw(name string, params map[string]any) ([]ContentBlock, bool, error) + CallToolRaw(name string, params map[string]any) ([]mcpprim.ContentBlock, bool, error) ListToolNames() ([]string, error) } -// ContentBlock represents a single content block in an MCP tool response. -type ContentBlock struct { - Text string `json:"text"` - Type string `json:"type"` -} - // TestConfig represents a test-config.json fixture file. type TestConfig struct { Arguments map[string]any `json:"arguments"` @@ -326,7 +322,7 @@ func (r *Runner) printSummary() bool { // validateAssertions checks test-config.json assertions against the tool // response content. Returns an empty string on success, or a description // of the first assertion failure. -func validateAssertions(testDir string, content []ContentBlock) string { +func validateAssertions(testDir string, content []mcpprim.ContentBlock) string { configPath := filepath.Join(testDir, "test-config.json") data, err := os.ReadFile(configPath) if err != nil { diff --git a/client/internal/testing/runner_test.go b/client/internal/testing/runner_test.go index bb265f45..175cb064 100644 --- a/client/internal/testing/runner_test.go +++ b/client/internal/testing/runner_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + mcpprim "github.com/advanced-security/codeql-development-mcp-server/client/internal/mcp" ) // mockCaller implements ToolCaller for tests. @@ -19,7 +21,7 @@ type mockCall struct { } type mockResult struct { - content []ContentBlock + content []mcpprim.ContentBlock err error isError bool } @@ -30,12 +32,12 @@ func newMockCaller() *mockCaller { } } -func (m *mockCaller) CallToolRaw(name string, params map[string]any) ([]ContentBlock, bool, error) { +func (m *mockCaller) CallToolRaw(name string, params map[string]any) ([]mcpprim.ContentBlock, bool, error) { m.calls = append(m.calls, mockCall{name: name, params: params}) if r, ok := m.results[name]; ok { return r.content, r.isError, r.err } - return []ContentBlock{{Type: "text", Text: "ok"}}, false, nil + return []mcpprim.ContentBlock{{Type: "text", Text: "ok"}}, false, nil } func (m *mockCaller) ListToolNames() ([]string, error) { @@ -238,7 +240,7 @@ func TestRunnerEmptyContentFails(t *testing.T) { caller := newMockCaller() // Return empty content blocks for mock_tool caller.results["mock_tool"] = mockResult{ - content: []ContentBlock{}, + content: []mcpprim.ContentBlock{}, isError: false, err: nil, } @@ -282,7 +284,7 @@ func TestRunnerEmptyContentFails(t *testing.T) { func TestValidateAssertions_NoConfig(t *testing.T) { dir := t.TempDir() // No test-config.json — should pass - result := validateAssertions(dir, []ContentBlock{{Text: "hello"}}) + result := validateAssertions(dir, []mcpprim.ContentBlock{{Text: "hello"}}) if result != "" { t.Errorf("expected no error, got %q", result) } @@ -293,7 +295,7 @@ func TestValidateAssertions_NoAssertions(t *testing.T) { os.WriteFile(filepath.Join(dir, "test-config.json"), []byte(`{"toolName":"my_tool","arguments":{}}`), 0o600) - result := validateAssertions(dir, []ContentBlock{{Text: "hello"}}) + result := validateAssertions(dir, []mcpprim.ContentBlock{{Text: "hello"}}) if result != "" { t.Errorf("expected no error when no assertions defined, got %q", result) } @@ -304,7 +306,7 @@ func TestValidateAssertions_ResponseContains_Pass(t *testing.T) { os.WriteFile(filepath.Join(dir, "test-config.json"), []byte(`{"toolName":"my_tool","arguments":{},"assertions":{"responseContains":["hello","world"]}}`), 0o600) - content := []ContentBlock{{Text: "hello world"}} + content := []mcpprim.ContentBlock{{Text: "hello world"}} result := validateAssertions(dir, content) if result != "" { t.Errorf("expected pass, got %q", result) @@ -316,7 +318,7 @@ func TestValidateAssertions_ResponseContains_Fail(t *testing.T) { os.WriteFile(filepath.Join(dir, "test-config.json"), []byte(`{"toolName":"my_tool","arguments":{},"assertions":{"responseContains":["missing"]}}`), 0o600) - content := []ContentBlock{{Text: "hello world"}} + content := []mcpprim.ContentBlock{{Text: "hello world"}} result := validateAssertions(dir, content) if result == "" { t.Error("expected assertion failure for missing content") @@ -328,7 +330,7 @@ func TestValidateAssertions_ResponseNotContains_Pass(t *testing.T) { os.WriteFile(filepath.Join(dir, "test-config.json"), []byte(`{"toolName":"my_tool","arguments":{},"assertions":{"responseNotContains":["error","fail"]}}`), 0o600) - content := []ContentBlock{{Text: "all good"}} + content := []mcpprim.ContentBlock{{Text: "all good"}} result := validateAssertions(dir, content) if result != "" { t.Errorf("expected pass, got %q", result) @@ -340,7 +342,7 @@ func TestValidateAssertions_ResponseNotContains_Fail(t *testing.T) { os.WriteFile(filepath.Join(dir, "test-config.json"), []byte(`{"toolName":"my_tool","arguments":{},"assertions":{"responseNotContains":["error"]}}`), 0o600) - content := []ContentBlock{{Text: "some error happened"}} + content := []mcpprim.ContentBlock{{Text: "some error happened"}} result := validateAssertions(dir, content) if result == "" { t.Error("expected assertion failure for forbidden content") @@ -352,7 +354,7 @@ func TestValidateAssertions_MinContentBlocks_Pass(t *testing.T) { os.WriteFile(filepath.Join(dir, "test-config.json"), []byte(`{"toolName":"my_tool","arguments":{},"assertions":{"minContentBlocks":2}}`), 0o600) - content := []ContentBlock{{Text: "block1"}, {Text: "block2"}} + content := []mcpprim.ContentBlock{{Text: "block1"}, {Text: "block2"}} result := validateAssertions(dir, content) if result != "" { t.Errorf("expected pass, got %q", result) @@ -364,7 +366,7 @@ func TestValidateAssertions_MinContentBlocks_Fail(t *testing.T) { os.WriteFile(filepath.Join(dir, "test-config.json"), []byte(`{"toolName":"my_tool","arguments":{},"assertions":{"minContentBlocks":3}}`), 0o600) - content := []ContentBlock{{Text: "only one"}} + content := []mcpprim.ContentBlock{{Text: "only one"}} result := validateAssertions(dir, content) if result == "" { t.Error("expected assertion failure for insufficient content blocks") @@ -376,7 +378,7 @@ func TestValidateAssertions_MultipleBlocks(t *testing.T) { os.WriteFile(filepath.Join(dir, "test-config.json"), []byte(`{"toolName":"my_tool","arguments":{},"assertions":{"responseContains":["from block2"]}}`), 0o600) - content := []ContentBlock{{Text: "block1 text"}, {Text: "from block2"}} + content := []mcpprim.ContentBlock{{Text: "block1 text"}, {Text: "from block2"}} result := validateAssertions(dir, content) if result != "" { t.Errorf("expected pass across multiple blocks, got %q", result) @@ -408,7 +410,7 @@ func TestRunnerMissingFixturesDirFails(t *testing.T) { func TestRunnerAssertionFailure(t *testing.T) { caller := newMockCaller() caller.results["mock_tool"] = mockResult{ - content: []ContentBlock{{Type: "text", Text: "unexpected output"}}, + content: []mcpprim.ContentBlock{{Type: "text", Text: "unexpected output"}}, isError: false, err: nil, } From 6e99d778b714a80723f2f4b3996e02622e900c9d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:11:54 +0000 Subject: [PATCH 3/5] fix: update setup-codeql-environment Go version default from 1.21 to 1.25 The client/go.mod requires go 1.25.6 but the setup action defaulted to Go 1.21, causing build failures when npm run build-and-test uses the action's default Go version. Agent-Logs-Url: https://github.com/advanced-security/codeql-development-mcp-server/sessions/78e52dbe-a4f9-4a7a-b042-c14ad89421b1 Co-authored-by: data-douser <70299490+data-douser@users.noreply.github.com> --- .github/actions/setup-codeql-environment/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-codeql-environment/action.yml b/.github/actions/setup-codeql-environment/action.yml index c2702f07..1095f0c9 100644 --- a/.github/actions/setup-codeql-environment/action.yml +++ b/.github/actions/setup-codeql-environment/action.yml @@ -29,7 +29,7 @@ inputs: go-version: description: 'Go version to install' required: false - default: '1.21' + default: '1.25' dotnet-version: description: '.NET version to install' required: false From 509393dc00708c22665c8f7f4fc20c918b89a5e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:13:37 +0000 Subject: [PATCH 4/5] docs: improve extractContentText documentation per code review feedback Agent-Logs-Url: https://github.com/advanced-security/codeql-development-mcp-server/sessions/78e52dbe-a4f9-4a7a-b042-c14ad89421b1 Co-authored-by: data-douser <70299490+data-douser@users.noreply.github.com> --- client/internal/mcp/primitives.go | 4 ++++ server/ql/go/tools/test/CallGraphFrom/go.mod | 2 ++ server/ql/go/tools/test/CallGraphFromTo/go.mod | 2 ++ server/ql/go/tools/test/CallGraphTo/go.mod | 2 ++ server/ql/go/tools/test/PrintAST/go.mod | 2 +- 5 files changed, 11 insertions(+), 1 deletion(-) diff --git a/client/internal/mcp/primitives.go b/client/internal/mcp/primitives.go index b020bdc8..2c466dd1 100644 --- a/client/internal/mcp/primitives.go +++ b/client/internal/mcp/primitives.go @@ -268,6 +268,10 @@ func FormatPromptMessagesMarkdown(pm *PromptMessages) string { } // extractContentText extracts the text from an MCP Content interface value. +// MCP prompt messages use the Content interface which can be TextContent, +// ImageContent, AudioContent, or EmbeddedResource. This function handles +// TextContent directly and falls back to JSON serialization for other types +// (e.g., image or audio content), preserving the data for the caller. func extractContentText(content mcp.Content) string { if tc, ok := content.(mcp.TextContent); ok { return tc.Text diff --git a/server/ql/go/tools/test/CallGraphFrom/go.mod b/server/ql/go/tools/test/CallGraphFrom/go.mod index 38febad2..6f0370fe 100644 --- a/server/ql/go/tools/test/CallGraphFrom/go.mod +++ b/server/ql/go/tools/test/CallGraphFrom/go.mod @@ -1 +1,3 @@ module languages/go/tools/test/CallGraphFrom + +go 1.21.13 diff --git a/server/ql/go/tools/test/CallGraphFromTo/go.mod b/server/ql/go/tools/test/CallGraphFromTo/go.mod index 6d52b17b..4e9350b4 100644 --- a/server/ql/go/tools/test/CallGraphFromTo/go.mod +++ b/server/ql/go/tools/test/CallGraphFromTo/go.mod @@ -1 +1,3 @@ module languages/go/tools/test/CallGraphFromTo + +go 1.21.13 diff --git a/server/ql/go/tools/test/CallGraphTo/go.mod b/server/ql/go/tools/test/CallGraphTo/go.mod index 0bd91f50..cfaa65e6 100644 --- a/server/ql/go/tools/test/CallGraphTo/go.mod +++ b/server/ql/go/tools/test/CallGraphTo/go.mod @@ -1 +1,3 @@ module languages/go/tools/test/CallGraphTo + +go 1.21.13 diff --git a/server/ql/go/tools/test/PrintAST/go.mod b/server/ql/go/tools/test/PrintAST/go.mod index 52ae6679..def198d1 100644 --- a/server/ql/go/tools/test/PrintAST/go.mod +++ b/server/ql/go/tools/test/PrintAST/go.mod @@ -1,3 +1,3 @@ module languages/go/tools/test/PrintAST -go 1.20 \ No newline at end of file +go 1.20 From fc24b2cb71ca379b3e3ae4f8bdfd8bda614e40ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 01:06:11 +0000 Subject: [PATCH 5/5] fix: exit non-zero when `use tool` gets MCP-level error; replace cutString with strings.Cut - outputToolResult() now returns an error when result.IsError is true, so the CLI exits 1 on MCP-level tool errors (e.g. nonexistent tool) - Replace custom cutString() with stdlib strings.Cut (available since Go 1.18) Agent-Logs-Url: https://github.com/advanced-security/codeql-development-mcp-server/sessions/cc25a44f-aecb-453c-be59-8064533607d3 Co-authored-by: data-douser <70299490+data-douser@users.noreply.github.com> --- client/cmd/use.go | 15 +++------------ client/cmd/use_tool.go | 3 +++ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/client/cmd/use.go b/client/cmd/use.go index 5458b7e6..d3b20a40 100644 --- a/client/cmd/use.go +++ b/client/cmd/use.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "strings" "github.com/spf13/cobra" ) @@ -21,7 +22,7 @@ Subcommands: func parseArgs(args []string) (map[string]string, error) { result := make(map[string]string, len(args)) for _, a := range args { - key, value, found := cutString(a, "=") + key, value, found := strings.Cut(a, "=") if !found || key == "" { return nil, fmt.Errorf("invalid argument %q: expected key=value format", a) } @@ -34,7 +35,7 @@ func parseArgs(args []string) (map[string]string, error) { func parseArgsAny(args []string) (map[string]any, error) { result := make(map[string]any, len(args)) for _, a := range args { - key, value, found := cutString(a, "=") + key, value, found := strings.Cut(a, "=") if !found || key == "" { return nil, fmt.Errorf("invalid argument %q: expected key=value format", a) } @@ -43,16 +44,6 @@ func parseArgsAny(args []string) (map[string]any, error) { return result, nil } -// cutString splits s around the first instance of sep. -func cutString(s, sep string) (before, after string, found bool) { - for i := 0; i+len(sep) <= len(s); i++ { - if s[i:i+len(sep)] == sep { - return s[:i], s[i+len(sep):], true - } - } - return s, "", false -} - func init() { rootCmd.AddCommand(useCmd) } diff --git a/client/cmd/use_tool.go b/client/cmd/use_tool.go index f1245ed1..63e9bffc 100644 --- a/client/cmd/use_tool.go +++ b/client/cmd/use_tool.go @@ -65,5 +65,8 @@ func outputToolResult(result *mcpclient.ToolResult) error { default: fmt.Fprint(os.Stdout, mcpclient.FormatToolResultText(result)) } + if result.IsError { + return fmt.Errorf("tool returned error") + } return nil }