Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions pkg/mcp/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions pkg/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"))
Expand Down
68 changes: 68 additions & 0 deletions pkg/mcp/tools_logs.go
Original file line number Diff line number Diff line change
@@ -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"`
}
163 changes: 163 additions & 0 deletions pkg/mcp/tools_logs_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading