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
14 changes: 13 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,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

### describe

- **FIRST:** Read `func://help/describe` for authoritative usage information
- Describes a **deployed** Function instance on the cluster; it never just reads local `func.yaml`. This tool will fail if the Function has not yet been deployed (e.g. calling it right after 'create' but before 'deploy' is a usage error, not a tool bug)
- Supports TWO modes (mutually exclusive):
1. **Describe by PATH:** Provide 'path' parameter (reads the Function's name/namespace from func.yaml at that path, then describes whatever is deployed for that Function)
2. **Describe by NAME:** Provide 'name' parameter (describes the named Function on the cluster)
- Exactly ONE of 'path' or 'name' must be provided, not both
- 'namespace' is only valid together with 'name'; providing both 'path' and 'namespace' is rejected, since path mode already determines the namespace from the Function's own deploy identity
- Read-only; does not modify local files or cluster resources
Comment thread
Ankitsinghsisodya marked this conversation as resolved.

### 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)
Comment thread
Ankitsinghsisodya marked this conversation as resolved.
mcp.AddTool(i, deleteTool, s.deleteHandler)
mcp.AddTool(i, configVolumesListTool, s.configVolumesListHandler)
mcp.AddTool(i, configVolumesAddTool, s.configVolumesAddHandler)
Expand Down Expand Up @@ -139,6 +149,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 @@ -181,6 +192,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) {
Comment thread
Ankitsinghsisodya marked this conversation as resolved.
m.ExecuteSplitInvoked = true

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

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

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

"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
}
Comment thread
Ankitsinghsisodya marked this conversation as resolved.

// Validate: namespace only makes sense alongside 'name'. When describing
// by 'path', the Function's name and namespace are read from its own
// deploy identity (func.yaml); the CLI rejects a separate --namespace in
// that mode ("must also specify a name when specifying namespace").
if input.Path != nil && input.Namespace != nil {
err = fmt.Errorf("'namespace' is only valid with 'name'; when describing by 'path', the namespace is read from the Function's own deploy identity")
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
}
Comment thread
Ankitsinghsisodya marked this conversation as resolved.

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
}

var middleware *fn.Middleware
if instance.Middleware.Version != "" {
middleware = &instance.Middleware
}

output = DescribeOutput{
Comment thread
Ankitsinghsisodya marked this conversation as resolved.
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,
Middleware: middleware,
Revision: instance.Revision,
// A non-fatal warning on stderr (e.g. RBAC denying the eventing
// trigger list) can accompany an otherwise-successful, but partial,
// JSON payload on stdout (e.g. an empty subscriptions list).
// Surface it so the agent doesn't mistake "no subscriptions" for
// "no permission to see them".
Warnings: strings.TrimSpace(string(stderr)),
}
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). Only valid together with 'name'; when describing by 'path' the namespace is read from the Function's own deploy identity"`
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")
Comment thread
Ankitsinghsisodya marked this conversation as resolved.
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"`
Middleware *fn.Middleware `json:"middleware,omitempty" jsonschema:"Middleware backend (e.g. keda) applied at deploy time, if any"`
Revision string `json:"revision,omitempty" jsonschema:"Source commit SHA, read from the OCI revision label baked into the built image"`
Comment thread
Ankitsinghsisodya marked this conversation as resolved.
Warnings string `json:"warnings,omitempty" jsonschema:"Non-fatal warnings emitted while gathering this Function's status (e.g. permission errors that caused partial data, such as an empty subscriptions list)"`
}
Loading
Loading