Skip to content
Open
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: 11 additions & 1 deletion pkg/mcp/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ This is essential because:

**Exceptions:**
- The `list` tool operates on the cluster, not local files, so it does NOT use a path parameter (it uses namespace instead)
- The `delete` tool requires exactly one of `path` or `name`; it does NOT support a no-argument CWD mode (the MCP server process has its own working directory unrelated to the Function being managed)
- The `delete` and `describe` tools each require exactly one of `path` or `name`; they do NOT support a no-argument CWD mode (the MCP server process has its own working directory unrelated to the Function being managed)

## Deployment Behavior

Expand All @@ -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 'describe' → Read `func://help/describe`
- Before 'delete' → Read `func://help/delete`

The help text provides authoritative parameter information and usage context.
Expand Down Expand Up @@ -131,6 +132,15 @@ 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

### describe

- **FIRST:** Read `func://help/describe` for authoritative usage information
- Supports TWO modes (mutually exclusive):
1. **Describe by PATH:** Provide 'path' parameter (reads function name from func.yaml at that path)
2. **Describe by NAME:** Provide 'name' parameter (describes named function from cluster)
- Exactly ONE of 'path' or 'name' must be provided, not both
- Read-only; does not modify local files or cluster resources

### delete

- **FIRST:** Read `func://help/delete` for authoritative usage information
Expand Down
22 changes: 22 additions & 0 deletions pkg/mcp/mcp.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package mcp

import (
"bytes"
"context"
"fmt"
"os/exec"
Expand Down Expand Up @@ -32,6 +33,14 @@ type Server struct {

type executor interface {
Execute(ctx context.Context, subcommand string, args ...string) ([]byte, error)
// ExecuteSplit runs the command and returns stdout and stderr captured
// into separate buffers. Unlike Execute (which uses CombinedOutput and
// therefore offers no guarantee about the relative ordering of stdout
// and stderr bytes - they're copied by two independently-scheduled
// goroutines), ExecuteSplit gives each stream its own buffer, so callers
// that need to parse structured output (e.g. JSON) from stdout can do so
// without risk of stderr content (warnings, etc.) corrupting the parse.
ExecuteSplit(ctx context.Context, subcommand string, args ...string) (stdout, stderr []byte, err error)
}

type Option func(*Server)
Expand Down Expand Up @@ -108,6 +117,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, describeTool, s.describeHandler)
mcp.AddTool(i, deleteTool, s.deleteHandler)
mcp.AddTool(i, configVolumesListTool, s.configVolumesListHandler)
mcp.AddTool(i, configVolumesAddTool, s.configVolumesAddHandler)
Expand Down Expand Up @@ -138,6 +148,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, "Describe Help", "help for 'describe'", "describe"))
i.AddResource(newHelpResource(s, "Delete Help", "help for delete", "delete"))

i.AddResource(newHelpResource(s, "Volumes Help", "general help for volumes", "config", "volumes"))
Expand Down Expand Up @@ -178,6 +189,17 @@ func (e defaultExecutor) Execute(ctx context.Context, subcommand string, args ..
return cmd.CombinedOutput()
}

func (e defaultExecutor) ExecuteSplit(ctx context.Context, subcommand string, args ...string) (stdout, stderr []byte, err error) {
cmdParts := buildArgs(e.s.prefix, subcommand, args)
cmd := exec.CommandContext(ctx, cmdParts[0], cmdParts[1:]...)
// cmd.Dir not set - inherits process working directory which is the current working directory
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err = cmd.Run()
return outBuf.Bytes(), errBuf.Bytes(), err
}

// buildArgs constructs the ordered argument list for execution.
// An empty subcommand is omitted so that commands like "func --help" are
// built correctly rather than "func --help" with a spurious empty argument.
Expand Down
15 changes: 15 additions & 0 deletions pkg/mcp/mock/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import (
type Executor struct {
ExecuteInvoked bool
ExecuteFn func(context.Context, string, ...string) ([]byte, error)

ExecuteSplitInvoked bool
ExecuteSplitFn func(context.Context, string, ...string) (stdout, stderr []byte, err error)
}

// NewExecutor creates a new mock executor
Expand All @@ -27,3 +30,15 @@ func (m *Executor) Execute(ctx context.Context, subcommand string, args ...strin

return []byte(""), nil
}

// ExecuteSplit implements the executor interface, recording invocation
// details and delegating to ExecuteSplitFn if provided.
func (m *Executor) ExecuteSplit(ctx context.Context, subcommand string, args ...string) ([]byte, []byte, error) {
m.ExecuteSplitInvoked = true

if m.ExecuteSplitFn != nil {
return m.ExecuteSplitFn(ctx, subcommand, args...)
}

return []byte(""), []byte(""), nil
}
102 changes: 102 additions & 0 deletions pkg/mcp/tools_describe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package mcp

import (
"context"
"encoding/json"
"fmt"

"github.com/modelcontextprotocol/go-sdk/mcp"
fn "knative.dev/func/pkg/functions"
)

var describeTool = &mcp.Tool{
Name: "describe",
Title: "Describe Function",
Description: "Describe a deployed Function: URL, routes, image, namespace, deployer, labels, revision, readiness, and event subscriptions.",
Annotations: &mcp.ToolAnnotations{
Title: "Describe Function",
ReadOnlyHint: true,
IdempotentHint: true, // Describing the same function multiple times returns consistent results at any point in time.
},
}

func (s *Server) describeHandler(ctx context.Context, r *mcp.CallToolRequest, input DescribeInput) (result *mcp.CallToolResult, output DescribeOutput, err error) {
// Validate: exactly one of Path or Name must be provided
if (input.Path != nil && input.Name != nil) || (input.Path == nil && input.Name == nil) {
err = fmt.Errorf("exactly one of 'path' or 'name' must be provided")
return
}

// ExecuteSplit (rather than Execute/CombinedOutput) is required here:
// the CLI can write warnings to stderr on an otherwise-successful call
// (e.g. permission warnings from the knative describer), and stdout and
// stderr copied via CombinedOutput have no guaranteed relative ordering.
// Parsing JSON only ever out of a clean, unmixed stdout avoids that
// entirely rather than relying on any heuristic about stream ordering.
stdout, stderr, err := s.executor.ExecuteSplit(ctx, "describe", input.Args()...)
if err != nil {
err = fmt.Errorf("%w\nstdout: %s\nstderr: %s", err, string(stdout), string(stderr))
return
}

var instance fn.Instance
if err = json.Unmarshal(stdout, &instance); err != nil {
err = fmt.Errorf("failed to parse describe output: %w\n%s", err, string(stdout))
return
}

output = DescribeOutput{
Name: instance.Name,
Namespace: instance.Namespace,
URL: instance.Route,
Routes: instance.Routes,
Image: instance.Image,
Ready: instance.Ready,
Deployer: instance.Deployer,
Labels: instance.Labels,
Subscriptions: instance.Subscriptions,
Revision: instance.Revision,
}
return
}

// DescribeInput defines the input parameters for the describe tool.
// Exactly one of Path or Name must be provided.
type DescribeInput struct {
Path *string `json:"path,omitempty" jsonschema:"Path to the function project directory (mutually exclusive with name)"`
Name *string `json:"name,omitempty" jsonschema:"Name of the function to describe (mutually exclusive with path)"`
Namespace *string `json:"namespace,omitempty" jsonschema:"Kubernetes namespace to describe from (default: current or active namespace)"`
Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"`
}

func (i DescribeInput) Args() []string {
args := []string{}

// Either path flag or positional name argument
if i.Path != nil {
args = append(args, "--path", *i.Path)
} else if i.Name != nil {
args = append(args, *i.Name)
}

args = appendStringFlag(args, "--namespace", i.Namespace)
args = appendBoolFlag(args, "--verbose", i.Verbose)

// The tool's contract is structured JSON, regardless of caller input.
args = append(args, "--output", "json")
return args
}

// DescribeOutput defines the structured output returned by the describe tool.
type DescribeOutput struct {
Name string `json:"name" jsonschema:"Function name"`
Namespace string `json:"namespace,omitempty" jsonschema:"Kubernetes namespace"`
URL string `json:"url,omitempty" jsonschema:"Primary route URL"`
Routes []string `json:"routes,omitempty" jsonschema:"All route URLs"`
Image string `json:"image,omitempty" jsonschema:"Deployed container image"`
Ready string `json:"ready,omitempty" jsonschema:"Overall readiness (true/false/unknown)"`
Deployer string `json:"deployer,omitempty" jsonschema:"Deployer backend (knative, k8s, keda)"`
Labels map[string]string `json:"labels,omitempty" jsonschema:"Function labels"`
Subscriptions []fn.Subscription `json:"subscriptions,omitempty" jsonschema:"Active event subscriptions"`
Revision string `json:"revision,omitempty" jsonschema:"Source commit SHA, read from the OCI revision label baked into the built image"`
}
Loading
Loading