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
50 changes: 39 additions & 11 deletions pkg/mcp/tools_config_envs.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package mcp
import (
"context"
"fmt"
"strings"

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

// config_envs_list
Expand All @@ -21,30 +23,56 @@ var configEnvsListTool = &mcp.Tool{
}

type ConfigEnvsListInput struct {
Path string `json:"path" jsonschema:"required,Path to the function project directory"`
Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"`
Path string `json:"path" jsonschema:"required,Path to the function project directory"`
}

func (i ConfigEnvsListInput) Args() []string {
args := []string{"envs", "--path", i.Path}
args = appendBoolFlag(args, "--verbose", i.Verbose)
return args
// EnvVar is the MCP-facing representation of a configured environment
// variable. It mirrors fn.Env without the invopop/jsonschema struct tags,
// which the MCP SDK's output-schema generator cannot parse.
type EnvVar struct {
Name *string `json:"name,omitempty" jsonschema:"Environment variable name"`
Value *string `json:"value,omitempty" jsonschema:"Literal value or template expression"`
}

// ConfigEnvsListOutput exposes the configured environment variables as typed
// structured data; Message is a human-readable summary for fallback display.
type ConfigEnvsListOutput struct {
Message string `json:"message" jsonschema:"Output message"`
Envs []EnvVar `json:"envs" jsonschema:"Configured environment variables"`
Message string `json:"message" jsonschema:"Human-readable summary"`
}

func (s *Server) configEnvsListHandler(ctx context.Context, r *mcp.CallToolRequest, input ConfigEnvsListInput) (result *mcp.CallToolResult, output ConfigEnvsListOutput, err error) {
out, err := s.executor.Execute(ctx, "config", input.Args()...)
// configEnvsListHandler loads the function at the given path and returns its
// configured environment variables directly from pkg/functions rather than
// shelling out to `func config envs`. Part of the migration tracked in
// https://github.com/knative/func/issues/3771.
func (s *Server) configEnvsListHandler(_ context.Context, _ *mcp.CallToolRequest, input ConfigEnvsListInput) (result *mcp.CallToolResult, output ConfigEnvsListOutput, err error) {
f, err := fn.NewFunction(input.Path)
if err != nil {
err = fmt.Errorf("%w\n%s", err, string(out))
return
}
output = ConfigEnvsListOutput{Message: string(out)}
envs := make([]EnvVar, len(f.Run.Envs))
for i, e := range f.Run.Envs {
envs[i] = EnvVar{Name: e.Name, Value: e.Value}
}
output = ConfigEnvsListOutput{
Envs: envs,
Message: formatEnvs(f.Run.Envs),
}
return
}

func formatEnvs(envs fn.Envs) string {
if len(envs) == 0 {
return "There aren't any configured Environment variables"
}
var b strings.Builder
b.WriteString("Configured Environment variables:\n")
for _, e := range envs {
fmt.Fprintf(&b, " - %s\n", e.String())
}
return b.String()
}

// config_envs_add

var configEnvsAddTool = &mcp.Tool{
Expand Down
79 changes: 44 additions & 35 deletions pkg/mcp/tools_config_envs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,29 @@ package mcp

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

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

// writeTestFunction writes a minimal valid function to root, applying modify
// to set envs/labels/volumes for the test. Shared by the config *_list tests.
func writeTestFunction(t *testing.T, root string, modify func(*fn.Function)) {
t.Helper()
f := fn.NewFunctionWith(fn.Function{Root: root, Name: "test-fn", Runtime: "go"})
if modify != nil {
modify(&f)
}
if err := f.Write(); err != nil {
t.Fatalf("writing test function: %v", err)
}
}

// TestTool_ConfigEnvsAdd ensures the config_envs_add tool executes with all arguments.
func TestTool_ConfigEnvsAdd(t *testing.T) {
stringFlags := map[string]struct {
Expand Down Expand Up @@ -456,47 +472,44 @@ func TestTool_ConfigEnvsAdd_ConfigMapKeyWithoutConfigMapName(t *testing.T) {
}
}

// TestTool_ConfigEnvsList ensures the config_envs_list tool lists environment variables.
// TestTool_ConfigEnvsList verifies the config_envs_list tool reads envs
// directly from the function on disk via pkg/functions (no subprocess) and
// returns them as structured output.
func TestTool_ConfigEnvsList(t *testing.T) {
executor := mock.NewExecutor()
executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) {
if subcommand != "config" {
t.Fatalf("expected subcommand 'config', got %q", subcommand)
}

// "envs" + "--path" + "." = 3 args
if len(args) != 3 {
t.Fatalf("expected 3 args, got %d: %v", len(args), args)
}
if args[0] != "envs" {
t.Fatalf("expected args[0]='envs', got %q", args[0])
}

argsMap := argsToMap(args[1:])
if val, ok := argsMap["--path"]; !ok || val != "." {
t.Fatalf("expected --path='.', got %q", val)
}

return []byte("DATABASE_URL=postgres://localhost\nAPI_KEY=secret\n"), nil
}
root := t.TempDir()
writeTestFunction(t, root, func(f *fn.Function) {
f.Run.Envs = fn.Envs{{Name: ptr("API_KEY"), Value: ptr("secret")}}
})

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

result, err := client.CallTool(t.Context(), &mcp.CallToolParams{
Name: "config_envs_list",
Arguments: map[string]any{"path": "."},
Arguments: map[string]any{"path": root},
})
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")
if result.StructuredContent == nil {
t.Fatal("expected StructuredContent to be populated")
}

raw, err := json.Marshal(result.StructuredContent)
if err != nil {
t.Fatalf("marshal structured content: %v", err)
}
var out ConfigEnvsListOutput
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal output: %v", err)
}
if len(out.Envs) != 1 || out.Envs[0].Name == nil || *out.Envs[0].Name != "API_KEY" {
t.Fatalf("unexpected envs in output: %+v", out.Envs)
}
}

Expand Down Expand Up @@ -556,27 +569,23 @@ func TestTool_ConfigEnvsRemove(t *testing.T) {
}
}

// TestTool_ConfigEnvsList_Error ensures the config_envs_list tool propagates executor errors.
// TestTool_ConfigEnvsList_Error ensures the config_envs_list tool returns an
// error result when the function path does not exist.
func TestTool_ConfigEnvsList_Error(t *testing.T) {
executor := mock.NewExecutor()
executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) {
return []byte("list failed"), errors.New("executor error")
}

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

result, err := client.CallTool(t.Context(), &mcp.CallToolParams{
Name: "config_envs_list",
Arguments: map[string]any{"path": "."},
Arguments: map[string]any{"path": filepath.Join(t.TempDir(), "does-not-exist")},
})
if err != nil {
t.Fatal(err)
}
if !result.IsError {
t.Fatal("expected error result, got success")
t.Fatal("expected error result for nonexistent path")
}
}

Expand Down
50 changes: 39 additions & 11 deletions pkg/mcp/tools_config_labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package mcp
import (
"context"
"fmt"
"strings"

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

// config_labels_list
Expand All @@ -21,30 +23,56 @@ var configLabelsListTool = &mcp.Tool{
}

type ConfigLabelsListInput struct {
Path string `json:"path" jsonschema:"required,Path to the function project directory"`
Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"`
Path string `json:"path" jsonschema:"required,Path to the function project directory"`
}

func (i ConfigLabelsListInput) Args() []string {
args := []string{"labels", "--path", i.Path}
args = appendBoolFlag(args, "--verbose", i.Verbose)
return args
// LabelPair is the MCP-facing representation of a configured label. It
// mirrors fn.Label without the invopop/jsonschema struct tags, which the MCP
// SDK's output-schema generator cannot parse.
type LabelPair struct {
Key *string `json:"key,omitempty" jsonschema:"Label key"`
Value *string `json:"value,omitempty" jsonschema:"Label value or template expression"`
}

// ConfigLabelsListOutput exposes the configured labels as typed structured
// data; Message is a human-readable summary for fallback display.
type ConfigLabelsListOutput struct {
Message string `json:"message" jsonschema:"Output message"`
Labels []LabelPair `json:"labels" jsonschema:"Configured labels"`
Message string `json:"message" jsonschema:"Human-readable summary"`
}

func (s *Server) configLabelsListHandler(ctx context.Context, r *mcp.CallToolRequest, input ConfigLabelsListInput) (result *mcp.CallToolResult, output ConfigLabelsListOutput, err error) {
out, err := s.executor.Execute(ctx, "config", input.Args()...)
// configLabelsListHandler loads the function at the given path and returns its
// configured labels directly from pkg/functions rather than shelling out to
// `func config labels`. Part of the migration tracked in
// https://github.com/knative/func/issues/3771.
func (s *Server) configLabelsListHandler(_ context.Context, _ *mcp.CallToolRequest, input ConfigLabelsListInput) (result *mcp.CallToolResult, output ConfigLabelsListOutput, err error) {
f, err := fn.NewFunction(input.Path)
if err != nil {
err = fmt.Errorf("%w\n%s", err, string(out))
return
}
output = ConfigLabelsListOutput{Message: string(out)}
labels := make([]LabelPair, len(f.Deploy.Labels))
for i, l := range f.Deploy.Labels {
labels[i] = LabelPair{Key: l.Key, Value: l.Value}
}
output = ConfigLabelsListOutput{
Labels: labels,
Message: formatLabels(f.Deploy.Labels),
}
return
}

func formatLabels(labels []fn.Label) string {
if len(labels) == 0 {
return "No labels defined"
}
var b strings.Builder
b.WriteString("Labels:\n")
for _, l := range labels {
fmt.Fprintf(&b, " - %s\n", l.String())
}
return b.String()
}

// config_labels_add

var configLabelsAddTool = &mcp.Tool{
Expand Down
Loading
Loading