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..03abee1998 --- /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 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, + 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 at most 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") + } +}