Skip to content

Commit 96ba29e

Browse files
committed
feat(mcp): integrate tools with functions.Client API instead of CLI shell-out
Replace MCP tool handlers that exec'd the func binary with a native service layer backed by functions.Client. This enables structured JSON outputs, correct path handling, and readonly enforcement at the API level. Adds describe, invoke, run, and logs MCP tools. Help resources continue to use CLI subprocess for --help text.
1 parent cdf3c96 commit 96ba29e

32 files changed

Lines changed: 1637 additions & 1566 deletions

cmd/mcp.go

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import (
66
"strconv"
77

88
"github.com/spf13/cobra"
9-
"knative.dev/func/pkg/mcp"
10-
119
fn "knative.dev/func/pkg/functions"
10+
"knative.dev/func/pkg/mcp"
1211
)
1312

13+
// testMCPOptions is set by tests to inject MCP server options (e.g. in-memory transport).
14+
var testMCPOptions []mcp.Option
15+
1416
func NewMCPCmd(newClient ClientFactory) *cobra.Command {
1517
cmd := &cobra.Command{
1618
Use: "mcp",
@@ -89,14 +91,10 @@ DESCRIPTION
8991
return runMCPStart(cmd, args, newClient)
9092
},
9193
}
92-
// no flags at this time; future enhancements may be to allow configuring
93-
// HTTP Stream vs stdio, single vs multiuser modes, etc. For now
94-
// we just use a simple gathering of options in runMCPStart.
9594
return cmd
9695
}
9796

9897
func runMCPStart(cmd *cobra.Command, args []string, newClient ClientFactory) error {
99-
// Configure write mode
10098
writeEnabled := false
10199
if val := os.Getenv("FUNC_ENABLE_MCP_WRITE"); val != "" {
102100
parsed, err := strconv.ParseBool(val)
@@ -106,18 +104,23 @@ func runMCPStart(cmd *cobra.Command, args []string, newClient ClientFactory) err
106104
writeEnabled = parsed
107105
}
108106

109-
// Configure 'func' or 'kn func'?
110-
rootCmd := cmd.Root()
111-
cmdPrefix := rootCmd.Use
107+
cmdPrefix := cmd.Root().Use
112108

113-
// Instantiate
114-
client, done := newClient(ClientConfig{},
115-
fn.WithMCPServer(mcp.New(
116-
mcp.WithPrefix(cmdPrefix),
117-
mcp.WithReadonly(!writeEnabled))))
118-
defer done()
109+
factory := func(cfg mcp.ClientConfig, options ...fn.Option) (*fn.Client, func()) {
110+
return newClient(ClientConfig{
111+
Verbose: cfg.Verbose,
112+
InsecureSkipVerify: cfg.InsecureSkipVerify,
113+
}, options...)
114+
}
115+
116+
opts := []mcp.Option{
117+
mcp.WithPrefix(cmdPrefix),
118+
mcp.WithReadonly(!writeEnabled),
119+
mcp.WithClientFactory(factory),
120+
}
121+
opts = append(opts, testMCPOptions...)
119122

120-
// Start
121-
return client.StartMCPServer(cmd.Context())
123+
server := mcp.New(opts...)
122124

125+
return server.Start(cmd.Context())
123126
}

cmd/mcp_test.go

Lines changed: 48 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,77 @@
11
package cmd
22

33
import (
4+
"context"
45
"testing"
56

6-
fn "knative.dev/func/pkg/functions"
7-
"knative.dev/func/pkg/mock"
7+
"github.com/modelcontextprotocol/go-sdk/mcp"
8+
mcpkg "knative.dev/func/pkg/mcp"
89
. "knative.dev/func/pkg/testing"
910
)
1011

11-
// TestMCP_Start ensures the "mcp start" command starts the MCP server.
1212
func TestMCP_Start(t *testing.T) {
1313
_ = FromTempDirectory(t)
1414

15-
server := mock.NewMCPServer()
15+
serverTpt, clientTpt := mcp.NewInMemoryTransports()
16+
testMCPOptions = []mcpkg.Option{mcpkg.WithTransport(serverTpt)}
17+
t.Cleanup(func() { testMCPOptions = nil })
1618

17-
cmd := NewMCPCmd(NewTestClient(fn.WithMCPServer(server)))
19+
cmd := NewMCPCmd(NewTestClient())
1820
cmd.SetArgs([]string{"start"})
19-
if err := cmd.Execute(); err != nil {
20-
t.Fatal(err)
21-
}
2221

23-
if !server.StartInvoked {
24-
// Indicates a failure of the command to correctly map the request
25-
// for "mcp start" to an actual invocation of the client's
26-
// StartMCPServer method, or something more fundamental like failure
27-
// to register the subcommand, etc.
28-
t.Fatal("MCP server's start method not invoked")
22+
ctx, cancel := context.WithCancel(t.Context())
23+
defer cancel()
24+
25+
go func() { _ = cmd.ExecuteContext(ctx) }()
26+
27+
client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1.0"}, nil)
28+
session, err := client.Connect(t.Context(), clientTpt, nil)
29+
if err != nil {
30+
cancel()
31+
t.Fatal(err)
2932
}
33+
session.Close()
34+
cancel()
3035
}
3136

32-
// TestMCP_StartWriteable ensures that the FUNC_ENABLE_MCP_WRITE environment
33-
// variable is correctly parsed and the server starts in both default
34-
// (readonly) and write-enabled modes.
3537
func TestMCP_StartWriteable(t *testing.T) {
3638
_ = FromTempDirectory(t)
3739

38-
// Ensure it defaults to readonly (no env var set).
39-
server := mock.NewMCPServer()
40-
cmd := NewMCPCmd(NewTestClient(fn.WithMCPServer(server)))
40+
// Readonly mode
41+
serverTpt, clientTpt := mcp.NewInMemoryTransports()
42+
testMCPOptions = []mcpkg.Option{mcpkg.WithTransport(serverTpt)}
43+
t.Cleanup(func() { testMCPOptions = nil })
44+
45+
cmd := NewMCPCmd(NewTestClient())
4146
cmd.SetArgs([]string{"start"})
42-
if err := cmd.Execute(); err != nil {
47+
ctx, cancel := context.WithCancel(t.Context())
48+
go func() { _ = cmd.ExecuteContext(ctx) }()
49+
50+
client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1.0"}, nil)
51+
session, err := client.Connect(t.Context(), clientTpt, nil)
52+
if err != nil {
53+
cancel()
4354
t.Fatal(err)
4455
}
45-
if !server.StartInvoked {
46-
t.Fatal("MCP server was not started in default mode")
47-
}
56+
session.Close()
57+
cancel()
4858

49-
// Ensure it starts successfully with write mode enabled.
59+
// Write mode
5060
t.Setenv("FUNC_ENABLE_MCP_WRITE", "true")
51-
server = mock.NewMCPServer()
52-
cmd = NewMCPCmd(NewTestClient(fn.WithMCPServer(server)))
61+
serverTpt2, clientTpt2 := mcp.NewInMemoryTransports()
62+
testMCPOptions = []mcpkg.Option{mcpkg.WithTransport(serverTpt2)}
63+
64+
cmd = NewMCPCmd(NewTestClient())
5365
cmd.SetArgs([]string{"start"})
54-
if err := cmd.Execute(); err != nil {
66+
ctx2, cancel2 := context.WithCancel(t.Context())
67+
go func() { _ = cmd.ExecuteContext(ctx2) }()
68+
69+
client2 := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1.0"}, nil)
70+
session2, err := client2.Connect(t.Context(), clientTpt2, nil)
71+
if err != nil {
72+
cancel2()
5573
t.Fatal(err)
5674
}
57-
if !server.StartInvoked {
58-
t.Fatal("MCP server was not started with write mode enabled")
59-
}
75+
session2.Close()
76+
cancel2()
6077
}

pkg/mcp/client_options.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package mcp
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/google/go-containerregistry/pkg/name"
8+
"knative.dev/func/pkg/builders"
9+
"knative.dev/func/pkg/buildpacks"
10+
"knative.dev/func/pkg/config"
11+
"knative.dev/func/pkg/creds"
12+
"knative.dev/func/pkg/docker"
13+
fn "knative.dev/func/pkg/functions"
14+
fnhttp "knative.dev/func/pkg/http"
15+
"knative.dev/func/pkg/k8s"
16+
"knative.dev/func/pkg/keda"
17+
"knative.dev/func/pkg/knative"
18+
"knative.dev/func/pkg/oci"
19+
"knative.dev/func/pkg/pipelines/tekton"
20+
"knative.dev/func/pkg/s2i"
21+
)
22+
23+
// builderClientOptions returns client options for the given builder name.
24+
func builderClientOptions(builder string, cfg ClientConfig, withTimestamp bool, registryAuthfile string) ([]fn.Option, error) {
25+
if builder == "" {
26+
builder = builders.Pack
27+
}
28+
29+
o := []fn.Option{
30+
fn.WithVerbose(cfg.Verbose),
31+
}
32+
33+
t := fnhttp.NewRoundTripper(
34+
fnhttp.WithInsecureSkipVerify(cfg.InsecureSkipVerify),
35+
fnhttp.WithOpenShiftServiceCA(),
36+
)
37+
credsProvider := newCredentialsProvider(config.Dir(), t, registryAuthfile, cfg.InsecureSkipVerify)
38+
39+
switch builder {
40+
case builders.Host:
41+
o = append(o,
42+
fn.WithScaffolder(oci.NewScaffolder(cfg.Verbose)),
43+
fn.WithBuilder(oci.NewBuilder(builders.Host, cfg.Verbose)),
44+
fn.WithPusher(oci.NewPusher(cfg.InsecureSkipVerify, false, cfg.Verbose,
45+
oci.WithTransport(fnhttp.NewRoundTripper(fnhttp.WithInsecureSkipVerify(cfg.InsecureSkipVerify), fnhttp.WithOpenShiftServiceCA())),
46+
oci.WithCredentialsProvider(credsProvider),
47+
oci.WithVerbose(cfg.Verbose))),
48+
)
49+
case builders.Pack:
50+
o = append(o,
51+
fn.WithScaffolder(buildpacks.NewScaffolder(cfg.Verbose)),
52+
fn.WithBuilder(buildpacks.NewBuilder(
53+
buildpacks.WithName(builders.Pack),
54+
buildpacks.WithTimestamp(withTimestamp),
55+
buildpacks.WithVerbose(cfg.Verbose))),
56+
fn.WithPusher(docker.NewPusher(
57+
docker.WithCredentialsProvider(credsProvider),
58+
docker.WithTransport(t),
59+
docker.WithVerbose(cfg.Verbose),
60+
docker.WithInsecure(cfg.InsecureSkipVerify))),
61+
)
62+
case builders.S2I:
63+
o = append(o,
64+
fn.WithScaffolder(s2i.NewScaffolder(cfg.Verbose)),
65+
fn.WithBuilder(s2i.NewBuilder(
66+
s2i.WithName(builders.S2I),
67+
s2i.WithVerbose(cfg.Verbose))),
68+
fn.WithPusher(docker.NewPusher(
69+
docker.WithCredentialsProvider(credsProvider),
70+
docker.WithTransport(t),
71+
docker.WithVerbose(cfg.Verbose),
72+
docker.WithInsecure(cfg.InsecureSkipVerify))),
73+
)
74+
default:
75+
return o, builders.ErrUnknownBuilder{Name: builder, Known: builders.All()}
76+
}
77+
return o, nil
78+
}
79+
80+
// deployClientOptions returns client options for a deploy operation.
81+
func deployClientOptions(builder, deployer string, cfg ClientConfig, withTimestamp bool) ([]fn.Option, error) {
82+
o, err := builderClientOptions(builder, cfg, withTimestamp, "")
83+
if err != nil {
84+
return nil, err
85+
}
86+
87+
t := fnhttp.NewRoundTripper(
88+
fnhttp.WithInsecureSkipVerify(cfg.InsecureSkipVerify),
89+
fnhttp.WithOpenShiftServiceCA(),
90+
)
91+
credsProvider := newCredentialsProvider(config.Dir(), t, "", cfg.InsecureSkipVerify)
92+
93+
o = append(o, fn.WithPipelinesProvider(tekton.NewPipelinesProvider(
94+
tekton.WithCredentialsProvider(credsProvider),
95+
tekton.WithVerbose(cfg.Verbose),
96+
tekton.WithTransport(t),
97+
)))
98+
99+
if deployer == "" {
100+
deployer = knative.KnativeDeployerName
101+
}
102+
switch deployer {
103+
case knative.KnativeDeployerName:
104+
o = append(o, fn.WithDeployer(knative.NewDeployer(knative.WithDeployerVerbose(cfg.Verbose))))
105+
case k8s.KubernetesDeployerName:
106+
o = append(o, fn.WithDeployer(k8s.NewDeployer(k8s.WithDeployerVerbose(cfg.Verbose))))
107+
case keda.KedaDeployerName:
108+
o = append(o, fn.WithDeployer(keda.NewDeployer(keda.WithDeployerVerbose(cfg.Verbose))))
109+
default:
110+
return nil, fmt.Errorf("unsupported deployer: %s (supported: %s, %s, %s)",
111+
deployer, knative.KnativeDeployerName, k8s.KubernetesDeployerName, keda.KedaDeployerName)
112+
}
113+
return o, nil
114+
}
115+
116+
func platformBuildOptions(platform string) ([]fn.BuildOption, error) {
117+
if platform == "" {
118+
return nil, nil
119+
}
120+
parts := strings.Split(platform, "/")
121+
if len(parts) != 2 {
122+
return nil, fmt.Errorf("platform must be in the form OS/Architecture (e.g. linux/amd64)")
123+
}
124+
return []fn.BuildOption{fn.BuildWithPlatforms([]fn.Platform{{OS: parts[0], Architecture: parts[1]}})}, nil
125+
}
126+
127+
func shouldBuild(buildFlag string, f fn.Function) (bool, error) {
128+
if buildFlag == "" || buildFlag == "auto" {
129+
if f.Built() {
130+
return false, nil
131+
}
132+
return true, nil
133+
}
134+
build, err := parseBoolFlag(buildFlag)
135+
if err != nil {
136+
return false, fmt.Errorf("invalid build flag %q: must be 'auto' or a boolean", buildFlag)
137+
}
138+
return build, nil
139+
}
140+
141+
func parseBoolFlag(v string) (bool, error) {
142+
switch strings.ToLower(v) {
143+
case "true", "1", "t", "yes":
144+
return true, nil
145+
case "false", "0", "f", "no":
146+
return false, nil
147+
default:
148+
return false, fmt.Errorf("invalid boolean value %q", v)
149+
}
150+
}
151+
152+
func isDigestedImage(v string) (bool, error) {
153+
ref, err := name.ParseReference(v)
154+
if err != nil {
155+
return false, err
156+
}
157+
_, ok := ref.(name.Digest)
158+
return ok, nil
159+
}
160+
161+
func newCredentialsProvider(configPath string, t fnhttp.RoundTripCloser, authFilePath string, insecure bool) oci.CredentialsProvider {
162+
additionalLoaders := append(k8s.GetOpenShiftDockerCredentialLoaders(), k8s.GetGoogleCredentialLoader()...)
163+
additionalLoaders = append(additionalLoaders, k8s.GetECRCredentialLoader()...)
164+
additionalLoaders = append(additionalLoaders, k8s.GetACRCredentialLoader()...)
165+
166+
options := []creds.Opt{
167+
creds.WithTransport(t),
168+
creds.WithInsecure(insecure),
169+
creds.WithAdditionalCredentialLoaders(additionalLoaders...),
170+
}
171+
if authFilePath != "" {
172+
options = append(options, creds.WithAuthFilePath(authFilePath))
173+
}
174+
return creds.NewCredentialsProvider(configPath, options...)
175+
}

0 commit comments

Comments
 (0)