diff --git a/pkg/mcp/instructions.md b/pkg/mcp/instructions.md index 45af704c81..79739491dc 100644 --- a/pkg/mcp/instructions.md +++ b/pkg/mcp/instructions.md @@ -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` @@ -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 diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 02ea0f5d44..b447baf02f 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -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) @@ -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")) diff --git a/pkg/mcp/resources_test.go b/pkg/mcp/resources_test.go index b4deee1645..4c71d31472 100644 --- a/pkg/mcp/resources_test.go +++ b/pkg/mcp/resources_test.go @@ -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", diff --git a/pkg/mcp/tools_version.go b/pkg/mcp/tools_version.go new file mode 100644 index 0000000000..3370869278 --- /dev/null +++ b/pkg/mcp/tools_version.go @@ -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"` +} diff --git a/pkg/mcp/tools_version_test.go b/pkg/mcp/tools_version_test.go new file mode 100644 index 0000000000..571d1ca260 --- /dev/null +++ b/pkg/mcp/tools_version_test.go @@ -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") + } +}