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
4 changes: 4 additions & 0 deletions pkg/mcp/tools_build.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"

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

var buildTool = &mcp.Tool{
Expand All @@ -28,6 +29,9 @@ func (s *Server) buildHandler(ctx context.Context, r *mcp.CallToolRequest, input
output = BuildOutput{
Message: string(out),
}
if f, ferr := fn.NewFunction(input.Path); ferr == nil {
output.Image = f.Build.Image
}
return
}

Expand Down
86 changes: 86 additions & 0 deletions pkg/mcp/tools_build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package mcp

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

"github.com/modelcontextprotocol/go-sdk/mcp"
Expand Down Expand Up @@ -67,3 +70,86 @@ func TestTool_Build_Args(t *testing.T) {
t.Fatal("executor was not invoked")
}
}

// TestTool_Build_StructuredOutput verifies that the Image field is populated
// from the .func/built-image file written by the func CLI after a successful build.
func TestTool_Build_StructuredOutput(t *testing.T) {
const wantImage = "ghcr.io/user/my-func:latest"

// Create a minimal function directory that fn.NewFunction can read.
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "func.yaml"), []byte("name: my-func\nruntime: go\n"), 0644); err != nil {
t.Fatal(err)
}
funcDir := filepath.Join(root, ".func")
if err := os.MkdirAll(funcDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(funcDir, "built-image"), []byte(wantImage), 0644); err != nil {
t.Fatal(err)
}

executor := mock.NewExecutor()
executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) {
return []byte("Build successful\n"), nil
}

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

result, err := client.CallTool(t.Context(), &mcp.CallToolParams{
Name: "build",
Arguments: map[string]any{"path": root},
})
if err != nil {
t.Fatal(err)
}
if result.IsError {
t.Fatalf("unexpected error result: %v", result)
}

raw := resultToString(result)
var output BuildOutput
if err := json.Unmarshal([]byte(raw), &output); err != nil {
t.Fatalf("failed to unmarshal output: %v\nraw: %s", err, raw)
}
if output.Image != wantImage {
t.Errorf("Image = %q, want %q", output.Image, wantImage)
}
}

// TestTool_Build_StructuredOutput_NoFuncYaml verifies that a missing func.yaml
// (e.g. an invalid path) does not cause the handler to fail — Image is just empty.
func TestTool_Build_StructuredOutput_NoFuncYaml(t *testing.T) {
executor := mock.NewExecutor()
executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) {
return []byte("Build successful\n"), nil
}

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

result, err := client.CallTool(t.Context(), &mcp.CallToolParams{
Name: "build",
Arguments: map[string]any{"path": t.TempDir()},
})
if err != nil {
t.Fatal(err)
}
if result.IsError {
t.Fatalf("unexpected error result: %v", result)
}

raw := resultToString(result)
var output BuildOutput
if err := json.Unmarshal([]byte(raw), &output); err != nil {
t.Fatalf("failed to unmarshal output: %v\nraw: %s", err, raw)
}
if output.Image != "" {
t.Errorf("expected empty Image when func.yaml absent, got %q", output.Image)
}
}
42 changes: 42 additions & 0 deletions pkg/mcp/tools_deploy.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package mcp

import (
"bufio"
"bytes"
"context"
"fmt"
"strings"

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

var deployTool = &mcp.Tool{
Expand All @@ -31,10 +35,48 @@ func (s *Server) deployHandler(ctx context.Context, r *mcp.CallToolRequest, inpu
}
output = DeployOutput{
Message: string(out),
URL: parseDeployedURL(out),
}
if f, ferr := fn.NewFunction(input.Path); ferr == nil {
output.Image = f.Deploy.Image
}
return
}

// parseDeployedURL extracts the deployed function URL from combined command
// output. It handles two formats produced by the func CLI:
//
// - Local deploy (written to stderr by the functions client):
// "✅ Function deployed/updated in namespace "ns" and exposed at URL: \n <url>"
//
// - Remote pipeline deploy (written to stdout by cmd/deploy.go):
// "Function Deployed at <url>"
func parseDeployedURL(out []byte) string {
scanner := bufio.NewScanner(bytes.NewReader(out))
urlNext := false
for scanner.Scan() {
Comment on lines +55 to +57
line := scanner.Text()
if urlNext {
if u := strings.TrimSpace(line); u != "" {
return u
}
}
// Local deploy: URL follows on the next non-empty line after this marker.
if strings.Contains(line, "exposed at URL:") {
urlNext = true
continue
}
// Remote pipeline deploy: URL is on the same line after the prefix.
const remotePrefix = "Function Deployed at "
if idx := strings.Index(line, remotePrefix); idx >= 0 {
if u := strings.TrimSpace(line[idx+len(remotePrefix):]); u != "" {
return u
}
}
}
return ""
}

// DeployInput defines the input parameters for the deploy tool.
type DeployInput struct {
Path string `json:"path" jsonschema:"required,Path to the function project directory"`
Expand Down
86 changes: 86 additions & 0 deletions pkg/mcp/tools_deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mcp

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

"github.com/modelcontextprotocol/go-sdk/mcp"
Expand Down Expand Up @@ -79,3 +80,88 @@ func TestTool_Deploy_Args(t *testing.T) {
t.Fatal("executor was not invoked")
}
}

// TestParseDeployedURL verifies URL extraction from both local and remote deploy output.
func TestParseDeployedURL(t *testing.T) {
tests := []struct {
name string
out string
want string
}{
{
name: "local deploy",
out: "✅ Function deployed in namespace \"default\" and exposed at URL: \n https://my-func.default.example.com\n",
want: "https://my-func.default.example.com",
},
{
name: "local update",
out: "✅ Function updated in namespace \"prod\" and exposed at URL: \n https://my-func.prod.example.com\n",
want: "https://my-func.prod.example.com",
},
{
name: "remote pipeline deploy",
out: "Function Deployed at https://my-func.remote.example.com\n",
want: "https://my-func.remote.example.com",
},
{
name: "remote pipeline deploy with surrounding output",
out: "Building...\nPushing...\nFunction Deployed at https://my-func.remote.example.com\nDone.\n",
want: "https://my-func.remote.example.com",
},
{
name: "no url in output",
out: "function up-to-date. Force rebuild with --build\n",
want: "",
},
{
name: "empty output",
out: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseDeployedURL([]byte(tt.out))
if got != tt.want {
t.Errorf("parseDeployedURL(%q) = %q, want %q", tt.out, got, tt.want)
}
})
}
}

// TestTool_Deploy_StructuredOutput verifies that URL is populated from parsed output.
func TestTool_Deploy_StructuredOutput(t *testing.T) {
const wantURL = "https://my-func.default.example.com"

executor := mock.NewExecutor()
executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) {
out := "✅ Function deployed in namespace \"default\" and exposed at URL: \n " + wantURL + "\n"
return []byte(out), nil
}

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

result, err := client.CallTool(t.Context(), &mcp.CallToolParams{
Name: "deploy",
Arguments: map[string]any{"path": t.TempDir()},
})
if err != nil {
t.Fatal(err)
}
if result.IsError {
t.Fatalf("unexpected error result: %v", result)
}

raw := resultToString(result)
var output DeployOutput
if err := json.Unmarshal([]byte(raw), &output); err != nil {
t.Fatalf("failed to unmarshal output: %v\nraw: %s", err, raw)
}
if output.URL != wantURL {
t.Errorf("URL = %q, want %q", output.URL, wantURL)
}
}
Loading