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
8 changes: 8 additions & 0 deletions pkg/mcp/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ This is essential because:
### General Rules

**CRITICAL:** Before invoking ANY tool, ALWAYS read its help resource first to understand parameters and usage:
- Before 'version' → Read `func://help/version`
- Before 'create' → Read `func://help/create`
- Before 'deploy' → Read `func://help/deploy`
- Before 'build' → Read `func://help/build`
Expand All @@ -61,6 +62,13 @@ This is essential because:

The help text provides authoritative parameter information and usage context.

### version

- **FIRST:** Read `func://help/version` for authoritative usage information
- Takes no parameters
- Reports the version (and git commit hash, when available) of the func client binary being driven
- Use this to gate usage of newer tools/flags on the version of func actually installed, before assuming they are supported

### create

- **FIRST:** Read `func://help/create` 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 @@ -104,6 +104,7 @@ func New(options ...Option) *Server {
// -----
// One for each command or command group
mcp.AddTool(i, healthCheckTool, s.healthcheckHandler)
mcp.AddTool(i, versionTool, s.versionHandler)
mcp.AddTool(i, createTool, s.createHandler)
mcp.AddTool(i, buildTool, s.buildHandler)
mcp.AddTool(i, deployTool, s.deployHandler)
Expand Down Expand Up @@ -134,6 +135,7 @@ func New(options ...Option) *Server {
// A resource for each command which returns its help
// eg. "config volumes add" -> "func://help/config/volumes/add")
i.AddResource(newHelpResource(s, "Help", "help for the command root"))
i.AddResource(newHelpResource(s, "Version Help", "help for 'version'", "version"))
i.AddResource(newHelpResource(s, "Create Help", "help for 'create'", "create"))
i.AddResource(newHelpResource(s, "Build Help", "help for 'build'", "build"))
i.AddResource(newHelpResource(s, "Deploy Help", "help for 'deploy'", "deploy"))
Expand Down
5 changes: 5 additions & 0 deletions pkg/mcp/resources_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ func TestResource_Help(t *testing.T) {
uri: "func://help",
wantArgs: []string{"--help"},
},
{
name: "version help",
uri: "func://help/version",
wantArgs: []string{"version", "--help"},
},
{
name: "create help",
uri: "func://help/create",
Expand Down
56 changes: 56 additions & 0 deletions pkg/mcp/tools_version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package mcp

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

"github.com/modelcontextprotocol/go-sdk/mcp"
)

var versionTool = &mcp.Tool{
Name: "version",
Title: "Version",
Description: "Reports the version of the func client binary, so agents can gate feature usage on version support.",
Annotations: &mcp.ToolAnnotations{
Title: "Version",
ReadOnlyHint: true,
IdempotentHint: true,
},
}

func (s *Server) versionHandler(ctx context.Context, r *mcp.CallToolRequest, input VersionInput) (result *mcp.CallToolResult, output VersionOutput, err error) {
out, err := s.executor.Execute(ctx, "version", "--output", "json")
if err != nil {
err = fmt.Errorf("%w\n%s", err, string(out))
return
}

// raw mirrors only the fields of cmd.Version's JSON output that we need
// (see cmd/version.go); importing cmd directly would create an import
// cycle since cmd/mcp.go imports this package.
var raw struct {
Vers string `json:"version,omitempty"`
Hash string `json:"commit,omitempty"`
}
if err = json.Unmarshal(out, &raw); err != nil {
err = fmt.Errorf("error parsing version output: %w\n%s", err, string(out))
return
}

output = VersionOutput{
Version: raw.Vers,
GitRevision: raw.Hash,
}
return
}

// VersionInput defines the input parameters for the version tool.
// No parameters are required for version.
type VersionInput struct{}

// VersionOutput defines the structured output returned by the version tool.
type VersionOutput struct {
Version string `json:"version" jsonschema:"Version of the func client binary"`
GitRevision string `json:"gitRevision,omitempty" jsonschema:"Git commit hash the binary was built from, if available"`
}
147 changes: 147 additions & 0 deletions pkg/mcp/tools_version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package mcp

import (
"context"
"encoding/json"
"errors"
"testing"

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

// TestTool_Version verifies the version tool executes "version --output json"
// and maps the result into VersionOutput.
func TestTool_Version(t *testing.T) {
executor := mock.NewExecutor()
executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) {
if subcommand != "version" {
t.Fatalf("expected subcommand 'version', got %q", subcommand)
}
validateArgLength(t, args, 1, 0)
validateStringFlags(t, args, map[string]struct {
jsonKey string
flag string
value string
}{
"output": {"output", "--output", "json"},
})
return []byte(`{"version":"v1.16.0","commit":"abc123"}`), nil
}

client, _, err := newTestPair(t, WithExecutor(executor))
if err != nil {
t.Fatal(err)
}

result, err := client.CallTool(t.Context(), &mcp.CallToolParams{
Name: "version",
Arguments: map[string]any{},
})
if err != nil {
t.Fatalf("version tool call failed: %v", err)
}
if result.IsError {
t.Fatalf("version returned an error result: %v", resultToString(result))
}
if !executor.ExecuteInvoked {
t.Fatal("executor was not invoked")
}

var output VersionOutput
if err := json.Unmarshal([]byte(resultToString(result)), &output); err != nil {
t.Fatalf("failed to parse version output as JSON: %v", err)
}
if output.Version != "v1.16.0" {
t.Errorf("expected version 'v1.16.0', got %q", output.Version)
}
if output.GitRevision != "abc123" {
t.Errorf("expected gitRevision 'abc123', got %q", output.GitRevision)
}
}

// TestTool_Version_NoCommit verifies a missing "commit" field (e.g. a build
// from source without ldflags) results in an empty GitRevision, not an error.
func TestTool_Version_NoCommit(t *testing.T) {
executor := mock.NewExecutor()
executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) {
return []byte(`{"version":"v0.0.0+source"}`), nil
}

client, _, err := newTestPair(t, WithExecutor(executor))
if err != nil {
t.Fatal(err)
}

result, err := client.CallTool(t.Context(), &mcp.CallToolParams{
Name: "version",
Arguments: map[string]any{},
})
if err != nil {
t.Fatalf("version tool call failed: %v", err)
}
if result.IsError {
t.Fatalf("version returned an error result: %v", resultToString(result))
}

var output VersionOutput
if err := json.Unmarshal([]byte(resultToString(result)), &output); err != nil {
t.Fatalf("failed to parse version output as JSON: %v", err)
}
if output.Version != "v0.0.0+source" {
t.Errorf("expected version 'v0.0.0+source', got %q", output.Version)
}
if output.GitRevision != "" {
t.Errorf("expected empty gitRevision, got %q", output.GitRevision)
}
}

// TestTool_Version_ExecutorError verifies an executor failure surfaces as an
// error result rather than a panic or malformed output.
func TestTool_Version_ExecutorError(t *testing.T) {
executor := mock.NewExecutor()
executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) {
return []byte("boom"), errors.New("executor error")
}

client, _, err := newTestPair(t, WithExecutor(executor))
if err != nil {
t.Fatal(err)
}

result, err := client.CallTool(t.Context(), &mcp.CallToolParams{
Name: "version",
Arguments: map[string]any{},
})
if err != nil {
t.Fatalf("unexpected transport-level error: %v", err)
}
if !result.IsError {
t.Fatal("expected an error result when the executor fails")
}
}

// TestTool_Version_MalformedJSON verifies malformed JSON from the executor
// surfaces as an error result rather than a panic.
func TestTool_Version_MalformedJSON(t *testing.T) {
executor := mock.NewExecutor()
executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) {
return []byte("not json"), nil
}

client, _, err := newTestPair(t, WithExecutor(executor))
if err != nil {
t.Fatal(err)
}

result, err := client.CallTool(t.Context(), &mcp.CallToolParams{
Name: "version",
Arguments: map[string]any{},
})
if err != nil {
t.Fatalf("unexpected transport-level error: %v", err)
}
if !result.IsError {
t.Fatal("expected an error result when the executor returns malformed JSON")
}
}
Loading