From 2d86db6853c8e637193a8954fa9dc8854f742e33 Mon Sep 17 00:00:00 2001 From: Ankitsinghsisodya Date: Wed, 20 May 2026 23:15:09 +0530 Subject: [PATCH 1/2] feat(mcp): add logs tool for retrieving function logs - Introduced a new 'logs' tool to fetch recent logs from deployed functions. - Updated documentation to include usage instructions for the logs command. - Added tests to ensure correct argument handling and mutual exclusivity between path and name parameters. This enhancement allows users to diagnose function behavior by accessing logs, improving the overall usability of the MCP tools. --- pkg/mcp/instructions.md | 12 +++ pkg/mcp/mcp.go | 2 + pkg/mcp/tools_logs.go | 68 ++++++++++++++++ pkg/mcp/tools_logs_test.go | 163 +++++++++++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 pkg/mcp/tools_logs.go create mode 100644 pkg/mcp/tools_logs_test.go diff --git a/pkg/mcp/instructions.md b/pkg/mcp/instructions.md index b16f30b35d..e04939561a 100644 --- a/pkg/mcp/instructions.md +++ b/pkg/mcp/instructions.md @@ -57,6 +57,7 @@ This is essential because: - Before 'deploy' → Read `func://help/deploy` - Before 'build' → Read `func://help/build` - Before 'list' → Read `func://help/list` +- Before 'logs' → Read `func://help/logs` - Before 'delete' → Read `func://help/delete` The help text provides authoritative parameter information and usage context. @@ -131,6 +132,17 @@ A first-time deploy can be detected by checking the func.yaml for a value in the - Optional `namespace` parameter to list Functions in specific namespace - Returns list of deployed Functions in current/specified namespace +### logs + +- **FIRST:** Read `func://help/logs` for authoritative usage information +- Identify the Function by **path** (reads func.yaml) OR by **name** — never both at the same time +- `path` must be an absolute path to the Function project directory +- `name` is the deployed Function name on the cluster +- Optional `namespace` parameter to target a specific Kubernetes namespace +- Optional `since` parameter controls the time window of returned logs (e.g. `30s`, `5m`, `2h`; default is `1m`) +- This tool is **read-only** — it never modifies any state +- Use logs to diagnose a deployed Function after `deploy`, especially when combined with `invoke` to trigger the Function and observe its output + ### delete - **FIRST:** Read `func://help/delete` for authoritative usage information diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 02ea0f5d44..b0085db891 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -108,6 +108,7 @@ func New(options ...Option) *Server { mcp.AddTool(i, buildTool, s.buildHandler) mcp.AddTool(i, deployTool, s.deployHandler) mcp.AddTool(i, listTool, s.listHandler) + mcp.AddTool(i, logsTool, s.logsHandler) mcp.AddTool(i, deleteTool, s.deleteHandler) mcp.AddTool(i, configVolumesListTool, s.configVolumesListHandler) mcp.AddTool(i, configVolumesAddTool, s.configVolumesAddHandler) @@ -138,6 +139,7 @@ func New(options ...Option) *Server { i.AddResource(newHelpResource(s, "Build Help", "help for 'build'", "build")) i.AddResource(newHelpResource(s, "Deploy Help", "help for 'deploy'", "deploy")) i.AddResource(newHelpResource(s, "List Help", "help for 'list'", "list")) + i.AddResource(newHelpResource(s, "Logs Help", "help for 'logs'", "logs")) i.AddResource(newHelpResource(s, "Delete Help", "help for delete", "delete")) i.AddResource(newHelpResource(s, "Volumes Help", "general help for volumes", "config", "volumes")) diff --git a/pkg/mcp/tools_logs.go b/pkg/mcp/tools_logs.go new file mode 100644 index 0000000000..c84e378bdc --- /dev/null +++ b/pkg/mcp/tools_logs.go @@ -0,0 +1,68 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var logsTool = &mcp.Tool{ + Name: "logs", + Title: "Get Function Logs", + Description: "Retrieve recent logs from a deployed Function. Use --since to control the time window (e.g. '5m', '1h'). Identify the Function by path (reads func.yaml) or by name.", + Annotations: &mcp.ToolAnnotations{ + Title: "Get Function Logs", + ReadOnlyHint: true, + DestructiveHint: ptr(false), + IdempotentHint: true, + }, +} + +func (s *Server) logsHandler(ctx context.Context, r *mcp.CallToolRequest, input LogsInput) (result *mcp.CallToolResult, output LogsOutput, err error) { + if input.Path != nil && input.Name != nil { + err = fmt.Errorf("'path' and 'name' are mutually exclusive: provide exactly one") + return + } + + out, err := s.executor.Execute(ctx, "logs", input.Args()...) + if err != nil { + err = fmt.Errorf("%w\n%s", err, string(out)) + return + } + output = LogsOutput{ + Logs: string(out), + } + return +} + +// LogsInput defines the input parameters for the logs tool. +// At most one of Path or Name may be provided; if neither is given the +// server's working directory is used (same default behaviour as the CLI). +type LogsInput struct { + Path *string `json:"path,omitempty" jsonschema:"Absolute path to the Function project directory (mutually exclusive with name)"` + Name *string `json:"name,omitempty" jsonschema:"Name of the deployed Function to fetch logs for (mutually exclusive with path)"` + Namespace *string `json:"namespace,omitempty" jsonschema:"Kubernetes namespace of the Function (default: current namespace)"` + Since *string `json:"since,omitempty" jsonschema:"Return logs newer than a relative duration such as 30s, 5m, or 2h (default: 1m)"` + Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"` +} + +func (i LogsInput) Args() []string { + args := []string{} + + if i.Path != nil { + args = append(args, "--path", *i.Path) + } else if i.Name != nil { + args = append(args, "--name", *i.Name) + } + + args = appendStringFlag(args, "--namespace", i.Namespace) + args = appendStringFlag(args, "--since", i.Since) + args = appendBoolFlag(args, "--verbose", i.Verbose) + return args +} + +// LogsOutput defines the structured output returned by the logs tool. +type LogsOutput struct { + Logs string `json:"logs" jsonschema:"Log output from the deployed Function"` +} diff --git a/pkg/mcp/tools_logs_test.go b/pkg/mcp/tools_logs_test.go new file mode 100644 index 0000000000..f59aa979ca --- /dev/null +++ b/pkg/mcp/tools_logs_test.go @@ -0,0 +1,163 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "knative.dev/func/pkg/mcp/mock" +) + +// TestTool_Logs_Args_ByPath ensures the logs tool passes all arguments correctly +// when identifying the Function by path. +func TestTool_Logs_Args_ByPath(t *testing.T) { + stringFlags := map[string]struct { + jsonKey string + flag string + value string + }{ + "path": {"path", "--path", "/home/user/myfunc"}, + "namespace": {"namespace", "--namespace", "prod"}, + "since": {"since", "--since", "10m"}, + } + + boolFlags := map[string]string{ + "verbose": "--verbose", + } + + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + if subcommand != "logs" { + t.Fatalf("expected subcommand 'logs', got %q", subcommand) + } + validateArgLength(t, args, len(stringFlags), len(boolFlags)) + validateStringFlags(t, args, stringFlags) + validateBoolFlags(t, args, boolFlags) + return []byte("2024/01/01 12:00:00 INFO handler invoked\n"), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "logs", + Arguments: buildInputArgs(stringFlags, boolFlags), + }) + if err != nil { + t.Fatal(err) + } + if result.IsError { + t.Fatalf("unexpected error result: %v", result) + } + if !executor.ExecuteInvoked { + t.Fatal("executor was not invoked") + } +} + +// TestTool_Logs_Args_ByName ensures the logs tool passes --name when the +// Function is identified by name instead of path. +func TestTool_Logs_Args_ByName(t *testing.T) { + stringFlags := map[string]struct { + jsonKey string + flag string + value string + }{ + "name": {"name", "--name", "my-function"}, + } + + boolFlags := map[string]string{} + + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + if subcommand != "logs" { + t.Fatalf("expected subcommand 'logs', got %q", subcommand) + } + validateArgLength(t, args, len(stringFlags), len(boolFlags)) + validateStringFlags(t, args, stringFlags) + return []byte("log line 1\nlog line 2\n"), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "logs", + Arguments: buildInputArgs(stringFlags, boolFlags), + }) + if err != nil { + t.Fatal(err) + } + if result.IsError { + t.Fatalf("unexpected error result: %v", result) + } + if !executor.ExecuteInvoked { + t.Fatal("executor was not invoked") + } +} + +// TestTool_Logs_MutuallyExclusive ensures that providing both 'path' and 'name' +// returns an error rather than executing the command. +func TestTool_Logs_MutuallyExclusive(t *testing.T) { + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + t.Fatal("executor should not be called when both path and name are provided") + return nil, nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "logs", + Arguments: map[string]any{ + "path": "/home/user/myfunc", + "name": "my-function", + }, + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Fatal("expected an error result when both path and name are provided") + } +} + +// TestTool_Logs_NoArgs ensures the logs tool works without any arguments, +// falling back to the server's working directory (CLI default behaviour). +func TestTool_Logs_NoArgs(t *testing.T) { + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + if subcommand != "logs" { + t.Fatalf("expected subcommand 'logs', got %q", subcommand) + } + if len(args) != 0 { + t.Fatalf("expected no args when nothing is provided, got %v", args) + } + return []byte("no logs yet\n"), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "logs", + Arguments: map[string]any{}, + }) + if err != nil { + t.Fatal(err) + } + if result.IsError { + t.Fatalf("unexpected error result: %v", result) + } + if !executor.ExecuteInvoked { + t.Fatal("executor was not invoked") + } +} From 28dd744b36cab8ae7f9c5fd7dffdf97ca10dde02 Mon Sep 17 00:00:00 2001 From: Ankitsinghsisodya Date: Wed, 20 May 2026 23:46:38 +0530 Subject: [PATCH 2/2] fix(mcp): update logs tool description and error message for mutual exclusivity - Revised the logs tool description to clarify the usage of the 'since' argument. - Updated error message in logsHandler to indicate that 'path' and 'name' are mutually exclusive, allowing for at most one instead of exactly one. These changes enhance the clarity of the tool's documentation and improve user feedback on input errors. --- pkg/mcp/tools_logs.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/mcp/tools_logs.go b/pkg/mcp/tools_logs.go index c84e378bdc..03abee1998 100644 --- a/pkg/mcp/tools_logs.go +++ b/pkg/mcp/tools_logs.go @@ -10,7 +10,7 @@ import ( var logsTool = &mcp.Tool{ Name: "logs", Title: "Get Function Logs", - Description: "Retrieve recent logs from a deployed Function. Use --since to control the time window (e.g. '5m', '1h'). Identify the Function by path (reads func.yaml) or by name.", + Description: "Retrieve recent logs from a deployed Function. Use the since argument to control the time window (e.g. '5m', '1h'). Identify the Function by path (reads func.yaml) or by name.", Annotations: &mcp.ToolAnnotations{ Title: "Get Function Logs", ReadOnlyHint: true, @@ -21,7 +21,7 @@ var logsTool = &mcp.Tool{ func (s *Server) logsHandler(ctx context.Context, r *mcp.CallToolRequest, input LogsInput) (result *mcp.CallToolResult, output LogsOutput, err error) { if input.Path != nil && input.Name != nil { - err = fmt.Errorf("'path' and 'name' are mutually exclusive: provide exactly one") + err = fmt.Errorf("'path' and 'name' are mutually exclusive: provide at most one") return }