diff --git a/.gitignore b/.gitignore index 8afeced7dc..60041bcb34 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ /pkg/functions/testdata/default_home/go /pkg/functions/testdata/default_home/.cache /pkg/functions/testdata/migrations/*/.gitignore +/pkg/creds/auth.json +/pkg/oci/testdata/test-links/absoluteLink +/pkg/oci/testdata/test-links/absoluteLinkWindows # Go /templates/go/cloudevents/go.sum diff --git a/cmd/mcp.go b/cmd/mcp.go index cbec2b0b28..ecade163cd 100644 --- a/cmd/mcp.go +++ b/cmd/mcp.go @@ -1,46 +1,121 @@ package cmd import ( - "log" + "fmt" + "os" + "strconv" "github.com/spf13/cobra" "knative.dev/func/pkg/mcp" + + fn "knative.dev/func/pkg/functions" ) -func NewMCPServerCmd() *cobra.Command { +func NewMCPCmd(newClient ClientFactory) *cobra.Command { cmd := &cobra.Command{ Use: "mcp", - Short: "Start MCP server", + Short: "Model Context Protocol (MCP) server", Long: ` NAME - {{rootCmdUse}} mcp - start a Model Context Protocol (MCP) server + {{rootCmdUse}} mcp - Model Context Protocol (MCP) server SYNOPSIS - {{rootCmdUse}} mcp [flags] - + {{rootCmdUse}} mcp [command] [flags] DESCRIPTION - Starts a Model Context Protocol (MCP) server over standard input/output (stdio) transport. - This implementation aims to support tools for deploying and creating serverless functions. - - Note: This command is still under development. + The Functions Model Context Protocol (MCP) server can be used to give + agents the power of Functions. + + Configure your agentic client to use the MCP server with command + "{{rootCmdUse}} mcp start". Then get the conversation started with + + "Let's create a Function!". + + By default the MCP server operates in read-only mode, allowing Function + creation, building, and inspection, but preventing deployment and deletion. + To enable full write access (deploy and delete operations), set the + environment variable FUNC_ENABLE_MCP_WRITE=true. + + This is an experimental feature, and using an LLM to create and deploy + code running on your cluster requires careful supervision. Functions is + an inherently "mutative" tool, so enabling write mode for your LLM is + essentially giving (sometimes unpredictable) AI the ability to create, + modify, deploy, and delete Function instances on your currently connected + cluster. + + The command "{{rootCmdUse}} mcp start" is meant to be invoked by your MCP + client (such as Claude Code, Cursor, VS Code, Windsurf, etc.); not run + directly. Configure your client of choice with a new MCP server which runs + this command. For example, in Claude Code this can be accomplished with: + claude mcp add func func mcp start + Instructions for other clients can be found at: + https://github.com/knative/func/blob/main/docs/mcp-integration/integration.md + +AVAILABLE COMMANDS + start Start the MCP server (for use by your agent) EXAMPLES - o Run an MCP server: - {{rootCmdUse}} mcp + o View this help: + {{rootCmdUse}} mcp --help +`, + } + + cmd.AddCommand(NewMCPStartCmd(newClient)) + + return cmd +} + +func NewMCPStartCmd(newClient ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "start", + Short: "Start the MCP server", + Long: ` +NAME + {{rootCmdUse}} mcp start - start the Model Context Protocol (MCP) server + +SYNOPSIS + {{rootCmdUse}} mcp start [flags] + +DESCRIPTION + Starts the Model Context Protocol (MCP) server. + + This command is designed to be invoked by MCP clients directly + (such as Claude Code, Claude Desktop, Cursor, VS Code, Windsurf, etc.); + not run directly. + + Please see '{{rootCmdUse}} mcp --help' for more information. `, RunE: func(cmd *cobra.Command, args []string) error { - return runMCPServer(cmd, args) + return runMCPStart(cmd, args, newClient) }, } + // no flags at this time; future enhancements may be to allow configuring + // HTTP Stream vs stdio, single vs multiuser modes, etc. For now + // we just use a simple gathering of options in runMCPServer. return cmd } -func runMCPServer(cmd *cobra.Command, args []string) error { - s := mcp.NewServer() - if err := s.Start(); err != nil { - log.Fatalf("Server error: %v", err) - return err +func runMCPStart(cmd *cobra.Command, args []string, newClient ClientFactory) error { + // Configure write mode + writeEnabled := false + if val := os.Getenv("FUNC_ENABLE_MCP_WRITE"); val != "" { + parsed, err := strconv.ParseBool(val) + if err != nil { + return fmt.Errorf("FUNC_ENABLE_MCP_WRITE shuold be a boolean (true/false, 1/0, etc). Received %q", val) + } + writeEnabled = parsed } - return nil + + // Configure 'func' or 'kn func'? + rootCmd := cmd.Root() + cmdPrefix := rootCmd.Use + + // Instantiate + client, done := newClient(ClientConfig{}, + fn.WithMCPServer(mcp.New(mcp.WithPrefix(cmdPrefix)))) + defer done() + + // Start + return client.StartMCPServer(cmd.Context(), writeEnabled) + } diff --git a/cmd/mcp_test.go b/cmd/mcp_test.go new file mode 100644 index 0000000000..74ffd41f8e --- /dev/null +++ b/cmd/mcp_test.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "context" + "os" + "testing" + + fn "knative.dev/func/pkg/functions" + "knative.dev/func/pkg/mock" + . "knative.dev/func/pkg/testing" +) + +// TestMCP_Start ensures the "mcp start" command starts the MCP server. +func TestMCP_Start(t *testing.T) { + _ = FromTempDirectory(t) + + server := mock.NewMCPServer() + + cmd := NewMCPCmd(NewTestClient(fn.WithMCPServer(server))) + cmd.SetArgs([]string{"start"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + + if !server.StartInvoked { + // Indicates a failure of the command to correctly map the request + // for "mcp start" to an actual invocation of the client's + // StartMCPServer method, or something more fundamental like failure + // to register the subcommand, etc. + t.Fatal("MCP server's start method not invoked") + } +} + +// TestMCP_StartWriteable ensures that the server is started with write +// enabled only when the environment variable FUNC_ENABLE_MCP_WRITE is set. +func TestMCP_StartWriteable(t *testing.T) { + _ = FromTempDirectory(t) + + // Ensure it defaults to off. + server := mock.NewMCPServer() + server.StartFn = func(_ context.Context, writeEnabled bool) error { + if writeEnabled { + t.Fatal("MCP server started write-enabled by default") + } + return nil + } + cmd := NewMCPCmd(NewTestClient(fn.WithMCPServer(server))) + cmd.SetArgs([]string{"start"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + + // Ensure it is set to true on proper truthy value + _ = os.Setenv("FUNC_ENABLE_MCP_WRITE", "true") + server = mock.NewMCPServer() + server.StartFn = func(_ context.Context, writeEnabled bool) error { + if !writeEnabled { + t.Fatal("MCP server was not enabled") + } + return nil + } + cmd = NewMCPCmd(NewTestClient(fn.WithMCPServer(server))) + cmd.SetArgs([]string{"start"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/root.go b/cmd/root.go index b7137225b8..f476f8b8d9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -107,18 +107,13 @@ Learn more about Knative at: https://knative.dev`, cfg.Name), NewEnvironmentCmd(newClient, &cfg.Version), }, }, - { - Header: "MCP Commands:", - Commands: []*cobra.Command{ - NewMCPServerCmd(), - }, - }, { Header: "Other Commands:", Commands: []*cobra.Command{ NewCompletionCmd(), NewVersionCmd(cfg.Version), NewTektonClusterTasksCmd(), + NewMCPCmd(newClient), }, }, } diff --git a/docs/reference/func.md b/docs/reference/func.md index 6100b73e27..923f469b0a 100644 --- a/docs/reference/func.md +++ b/docs/reference/func.md @@ -34,7 +34,7 @@ Learn more about Knative at: https://knative.dev * [func invoke](func_invoke.md) - Invoke a local or remote function * [func languages](func_languages.md) - List available function language runtimes * [func list](func_list.md) - List deployed functions -* [func mcp](func_mcp.md) - Start MCP server +* [func mcp](func_mcp.md) - Model Context Protocol (MCP) server * [func repository](func_repository.md) - Manage installed template repositories * [func run](func_run.md) - Run the function locally * [func subscribe](func_subscribe.md) - Subscribe a function to events diff --git a/docs/reference/func_mcp.md b/docs/reference/func_mcp.md index 6a2b883002..17f13b7157 100644 --- a/docs/reference/func_mcp.md +++ b/docs/reference/func_mcp.md @@ -1,31 +1,52 @@ ## func mcp -Start MCP server +Model Context Protocol (MCP) server ### Synopsis NAME - func mcp - start a Model Context Protocol (MCP) server + func mcp - Model Context Protocol (MCP) server SYNOPSIS - func mcp [flags] - + func mcp [command] [flags] DESCRIPTION - Starts a Model Context Protocol (MCP) server over standard input/output (stdio) transport. - This implementation aims to support tools for deploying and creating serverless functions. + The Functions Model Context Protocol (MCP) server can be used to give + agents the power of Functions. - Note: This command is still under development. + Configure your agentic client to use the MCP server with command + "func mcp start". Then get the conversation started with -EXAMPLES + "Let's create a Function!". - o Run an MCP server: - func mcp + By default the MCP server operates in read-only mode, allowing Function + creation, building, and inspection, but preventing deployment and deletion. + To enable full write access (deploy and delete operations), set the + environment variable FUNC_ENABLE_MCP_WRITE=true. + This is an experimental feature, and using an LLM to create and deploy + code running on your cluster requires careful supervision. Functions is + an inherently "mutative" tool, so enabling write mode for your LLM is + essentially giving (sometimes unpredictable) AI the ability to create, + modify, deploy, and delete Function instances on your currently connected + cluster. + + The command "func mcp start" is meant to be invoked by your MCP + client (such as Claude Code, Cursor, VS Code, Windsurf, etc.); not run + directly. Configure your client of choice with a new MCP server which runs + this command. For example, in Claude Code this can be accomplished with: + claude mcp add func func mcp start + Instructions for other clients can be found at: + https://github.com/knative/func/blob/main/docs/mcp-integration/integration.md + +AVAILABLE COMMANDS + start Start the MCP server (for use by your agent) + +EXAMPLES + + o View this help: + func mcp --help -``` -func mcp -``` ### Options @@ -36,4 +57,5 @@ func mcp ### SEE ALSO * [func](func.md) - func manages Knative Functions +* [func mcp start](func_mcp_start.md) - Start the MCP server diff --git a/docs/reference/func_mcp_start.md b/docs/reference/func_mcp_start.md new file mode 100644 index 0000000000..23d5674d01 --- /dev/null +++ b/docs/reference/func_mcp_start.md @@ -0,0 +1,37 @@ +## func mcp start + +Start the MCP server + +### Synopsis + + +NAME + func mcp start - start the Model Context Protocol (MCP) server + +SYNOPSIS + func mcp start [flags] + +DESCRIPTION + Starts the Model Context Protocol (MCP) server. + + This command is designed to be invoked by MCP clients directly + (such as Claude Code, Claude Desktop, Cursor, VS Code, Windsurf, etc.); + not run directly. + + Please see 'func mcp --help' for more information. + + +``` +func mcp start +``` + +### Options + +``` + -h, --help help for start +``` + +### SEE ALSO + +* [func mcp](func_mcp.md) - Model Context Protocol (MCP) server + diff --git a/go.mod b/go.mod index 6baaf69f83..8373e43d5c 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,7 @@ require ( github.com/hinshun/vt10x v0.0.0-20220228203356-1ab2cad5fd82 github.com/manifestival/client-go-client v0.6.0 github.com/manifestival/manifestival v0.7.2 - github.com/mark3labs/mcp-go v0.30.0 + github.com/modelcontextprotocol/go-sdk v1.1.0 github.com/opencontainers/image-spec v1.1.1 github.com/openshift-pipelines/pipelines-as-code v0.31.0 github.com/openshift/source-to-image v1.6.0 @@ -171,6 +171,7 @@ require ( github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-intervals v0.0.2 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/jsonschema-go v0.3.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect diff --git a/go.sum b/go.sum index 1bc4ee7738..32b3990785 100644 --- a/go.sum +++ b/go.sum @@ -541,6 +541,8 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -718,8 +720,6 @@ github.com/manifestival/client-go-client v0.6.0/go.mod h1:2x6VHJ9/2It3TknttgiDgr github.com/manifestival/manifestival v0.7.1/go.mod h1:nl3T6HlfHCeidooWVTMI9vYNTBkQ1GdhLNb+smozbdk= github.com/manifestival/manifestival v0.7.2 h1:l4uFdWX/xQK4QcRfqGoMtBvaZeWPEuwD6hVsCwUqZY4= github.com/manifestival/manifestival v0.7.2/go.mod h1:nl3T6HlfHCeidooWVTMI9vYNTBkQ1GdhLNb+smozbdk= -github.com/mark3labs/mcp-go v0.30.0 h1:Taz7fiefkxY/l8jz1nA90V+WdM2eoMtlvwfWforVYbo= -github.com/mark3labs/mcp-go v0.30.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= @@ -789,6 +789,8 @@ github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcY github.com/moby/term v0.0.0-20221205130635-1aeaba878587/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA= +github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/pkg/functions/client.go b/pkg/functions/client.go index 6bb46612e3..525380407d 100644 --- a/pkg/functions/client.go +++ b/pkg/functions/client.go @@ -78,6 +78,7 @@ type Client struct { instances *InstanceRefs // Function Instances management transport http.RoundTripper // Customizable internal transport pipelinesProvider PipelinesProvider // CI/CD pipelines management + mcpServer MCPServer // MCP Server startTimeout time.Duration // default start timeout for all runs } @@ -205,6 +206,12 @@ type PipelinesProvider interface { RemovePAC(context.Context, Function, any) error } +// MCPServer for a given client instance which performs bidirectional +// communication with a client agent. +type MCPServer interface { + Start(context.Context, bool) error +} + // New client for function management. func New(options ...Option) *Client { // Instantiate client with static defaults. @@ -217,6 +224,7 @@ func New(options ...Option) *Client { describer: &noopDescriber{output: os.Stdout}, dnsProvider: &noopDNSProvider{output: os.Stdout}, pipelinesProvider: &noopPipelinesProvider{}, + mcpServer: &noopMCPServer{}, transport: http.DefaultTransport, startTimeout: DefaultStartTimeout, } @@ -362,6 +370,13 @@ func WithPipelinesProvider(pp PipelinesProvider) Option { } } +// WithMCPServer sets the MCP Server instance. +func WithMCPServer(s MCPServer) Option { + return func(c *Client) { + c.mcpServer = s + } +} + // WithStartTimeout sets a custom default timeout for functions which do not // define their own. This is useful in situations where the client is // operating in a restricted environment and all functions tend to take longer @@ -1108,6 +1123,12 @@ func (c *Client) Push(ctx context.Context, f Function) (Function, bool, error) { return f, true, err } +// StartMCPServer is currently a passthrough to the configured MCP Server +// intance. +func (c *Client) StartMCPServer(ctx context.Context, writeEnabled bool) error { + return c.mcpServer.Start(ctx, writeEnabled) +} + // ensureRunDataDir creates a .func directory at the given path, and // registers it as ignored in a .gitignore file. func ensureRunDataDir(root string) error { @@ -1382,3 +1403,8 @@ func (n *noopPipelinesProvider) RemovePAC(ctx context.Context, _ Function, _ any type noopDNSProvider struct{ output io.Writer } func (n *noopDNSProvider) Provide(_ Function) error { return nil } + +// MCPServer +type noopMCPServer struct{} + +func (n *noopMCPServer) Start(_ context.Context, _ bool) error { return nil } diff --git a/pkg/functions/client_test.go b/pkg/functions/client_test.go index d07d36743a..d4c5724af9 100644 --- a/pkg/functions/client_test.go +++ b/pkg/functions/client_test.go @@ -1264,6 +1264,22 @@ func TestClient_List(t *testing.T) { } } +// TestClient_StartMCPServer merely ensures the client invokes the configured +// MCP server. +func TestClient_StartMCPServer(t *testing.T) { + server := mock.NewMCPServer() + + client := fn.New(fn.WithMCPServer(server)) + + if err := client.StartMCPServer(context.Background(), true); err != nil { + t.Fatal(err) + } + + if !server.StartInvoked { + t.Fatal("MCP server was not invoked") + } +} + // TestClient_List_OutsideRoot ensures that a call to a function (in this case list) // that is not contextually dependent on being associated with a function, // can be run from anywhere, thus ensuring that the client itself makes diff --git a/pkg/mcp/help.go b/pkg/mcp/help.go deleted file mode 100644 index 2e575482d4..0000000000 --- a/pkg/mcp/help.go +++ /dev/null @@ -1,188 +0,0 @@ -package mcp - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os/exec" - "strings" - - "github.com/mark3labs/mcp-go/mcp" -) - -type template struct { - Repository string `json:"repository"` - Language string `json:"language"` - TemplateName string `json:"template"` -} - -func fetchTemplates() ([]template, error) { - var out []template - seen := make(map[string]bool) - - for _, repoURL := range TEMPLATE_RESOURCE_URIS { - owner, repo := parseGitHubURL(repoURL) - api := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/trees/main?recursive=1", owner, repo) - - resp, err := http.Get(api) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - var tree struct { - Tree []struct { - Path string `json:"path"` - } `json:"tree"` - } - if err := json.Unmarshal(body, &tree); err != nil { - return nil, err - } - - for _, item := range tree.Tree { - parts := strings.Split(item.Path, "/") - if len(parts) >= 2 && !strings.HasPrefix(parts[0], ".") { - lang, name := parts[0], parts[1] - key := lang + "/" + name - if !seen[key] { - out = append(out, template{ - Language: lang, - TemplateName: name, - Repository: repoURL, - }) - seen[key] = true - } - } - } - } - return out, nil -} - -func parseGitHubURL(url string) (owner, repo string) { - trim := strings.TrimPrefix(url, "https://github.com/") - parts := strings.Split(trim, "/") - return parts[0], parts[1] -} - -func handleRootHelpResource(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - content, err := exec.Command("func", "--help").Output() - if err != nil { - return nil, err - } - - return []mcp.ResourceContents{ - mcp.TextResourceContents{ - URI: "func://docs", - MIMEType: "text/plain", - Text: string(content), - }, - }, nil -} - -func runHelpCommand(args []string, uri string) ([]mcp.ResourceContents, error) { - args = append(args, "--help") - content, err := exec.Command("func", args...).Output() - if err != nil { - return nil, err - } - return []mcp.ResourceContents{ - mcp.TextResourceContents{ - URI: uri, - MIMEType: "text/plain", - Text: string(content), - }, - }, nil -} - -func handleListTemplatesResource(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - templates, err := fetchTemplates() - if err != nil { - return nil, err - } - content, err := json.MarshalIndent(templates, "", " ") - if err != nil { - return nil, err - } - - return []mcp.ResourceContents{ - mcp.TextResourceContents{ - URI: "func://templates", - MIMEType: "text/plain", - Text: string(content), - }, - }, nil -} - -func handleCmdHelpPrompt(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - cmd := request.Params.Arguments["cmd"] - if cmd == "" { - return nil, fmt.Errorf("cmd is required") - } - - parts := strings.Fields(cmd) - if len(parts) == 0 { - return nil, fmt.Errorf("invalid cmd: %s", cmd) - } - - return mcp.NewGetPromptResult( - "Cmd Help Prompt", - []mcp.PromptMessage{ - mcp.NewPromptMessage( - mcp.RoleUser, - mcp.NewTextContent("What can I do with this func command? Please provide help for the command: "+cmd), - ), - mcp.NewPromptMessage( - mcp.RoleAssistant, - mcp.NewEmbeddedResource(mcp.TextResourceContents{ - URI: fmt.Sprintf("func://%s/docs", strings.Join(parts, "/")), - MIMEType: "text/plain", - }), - ), - }, - ), nil -} - -func handleRootHelpPrompt(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - return mcp.NewGetPromptResult( - "Help Prompt", - []mcp.PromptMessage{ - mcp.NewPromptMessage( - mcp.RoleUser, - mcp.NewTextContent("What can I do with the func command?"), - ), - mcp.NewPromptMessage( - mcp.RoleAssistant, - mcp.NewEmbeddedResource(mcp.TextResourceContents{ - URI: "func://docs", - MIMEType: "text/plain", - }), - ), - }, - ), nil -} - -func handleListTemplatesPrompt(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - return mcp.NewGetPromptResult( - "List Templates Prompt", - []mcp.PromptMessage{ - mcp.NewPromptMessage( - mcp.RoleUser, - mcp.NewTextContent("List available function templates"), - ), - mcp.NewPromptMessage( - mcp.RoleAssistant, - mcp.NewEmbeddedResource(mcp.TextResourceContents{ - URI: "func://templates", - MIMEType: "text/plain", - }), - ), - }, - ), nil -} diff --git a/pkg/mcp/instructions.go b/pkg/mcp/instructions.go new file mode 100644 index 0000000000..e5b096b6a2 --- /dev/null +++ b/pkg/mcp/instructions.go @@ -0,0 +1,16 @@ +package mcp + +import _ "embed" + +//go:embed instructions_warning.md +var readonlyWarning string + +//go:embed instructions.md +var instructionsBody string + +func instructions(readonly bool) string { + if readonly { + return readonlyWarning + instructionsBody + } + return instructionsBody +} diff --git a/pkg/mcp/instructions.md b/pkg/mcp/instructions.md new file mode 100644 index 0000000000..6a03d4c7ad --- /dev/null +++ b/pkg/mcp/instructions.md @@ -0,0 +1,147 @@ +# Functions MCP Agent Instructions + +## Terminology + +Always capitalize "**Function**" when referring to a deployable Function (the service). Use lowercase "function" only for programming concepts (functions in code). Suggest users do the same when ambiguity is detected. + +**Examples:** +- "Let's create a Function!" (deployable service) ✓ +- "What is a Function?" (this project's concept) ✓ +- "What is a function?" (programming construct) ✓ +- "Let's create a function" (ambiguous - could mean code) ✗ + +## Workflow Pattern + +Functions work like 'git' - you should always BE in the Function directory: + +1. Navigate to (cd into) the directory where you want to work +2. Use tools to Read, Edit, etc. to work with files in that directory +3. When invoking MCP tools, ALWAYS pass your current working directory as the 'path' parameter + +The func binary is smart - if func.yaml has previous deployment config, the deploy tool can be called with minimal arguments and will reuse registry, builder, etc. In general, arguments need only be used once. Subsequent invocations of the command should "remember" the prior settings as they are populated based on the state of the Function. + +## Agent Working Directory Pattern + +**CRITICAL: YOU (the agent) should always BE in the Function directory you're working on.** + +This is essential because: +- File operations (Read, Edit, Bash, etc.) work relative to YOUR current working directory +- The user will say things like "edit my handler" expecting you to be IN the Function directory +- This matches how developers naturally work with Functions (like git repositories) + +**When calling MCP tools, ALWAYS pass the ABSOLUTE path to your current working directory as the 'path' parameter:** +- `create` tool: path = absolute path to directory where Function will be created +- `deploy` tool: path = absolute path to Function directory (where func.yaml exists) +- `build` tool: path = absolute path to Function directory (where func.yaml exists) +- `config_*` tools: path = absolute path to Function directory (where func.yaml exists) + +**IMPORTANT:** You must use absolute paths (e.g., `/Users/name/myproject/myfunc`), NOT relative paths (e.g., `.` or `myfunc`). The MCP server process runs in a different directory than your current working directory, so relative paths will not resolve correctly. + +**Exceptions:** +- The `list` tool operates on the cluster, not local files, so it does NOT use a path parameter (it uses namespace instead) +- The `delete` tool can accept an optional named Function to delete, in which case the path is not necessary (no named parameter indicates 'delete the Function in my cwd') + +## Deployment Behavior + +- **FIRST deployment** (no previous deploy): Should carefully gather registry, builder settings +- **SUBSEQUENT deployments**: Can call "deploy" tool directly with no arguments (reuses config from func.yaml) +- **OVERRIDE specific settings**: Call "deploy" tool with specific flags (e.g., --builder pack, --registry docker.io/user) + - Example: "deploy with pack builder" → call deploy tool with --builder pack only + +## Tool Usage Guide + +### General Rules + +**CRITICAL:** Before invoking ANY tool, ALWAYS read its help resource first to understand parameters and usage: +- Before 'create' → Read `func://help/create` +- Before 'deploy' → Read `func://help/deploy` +- Before 'build' → Read `func://help/build` +- Before 'list' → Read `func://help/list` +- Before 'delete' → Read `func://help/delete` + +The help text provides authoritative parameter information and usage context. + +### create + +- **FIRST:** Read `func://help/create` for authoritative usage information +- **BEFORE calling:** Read `func://languages` resource to get available languages +- **BEFORE calling:** Read `func://templates` resource to get available templates +- Ask user to choose from the ACTUAL available options (don't assume/guess) +- **REQUIRED parameters:** + - `language` (from languages list) + - `path` (directory where the Function will be created) +- **OPTIONAL parameters:** + - `template` (from templates list, defaults to "http" if omitted) + +### deploy + +- **FIRST:** Read `func://help/deploy` for authoritative usage information +- **REQUIRED parameters:** + - `path` (directory containing the Function to deploy) +- **FIRST deployment:** Also requires `registry` parameter (e.g., docker.io/username or ghcr.io/username) +- **SUBSEQUENT deployments:** Only path is required (reuses previous config from func.yaml) +- **Optional** `builder` parameter: "host" (default for go/python) or "pack" (default for node/typescript/rust/java) +- Check if func.yaml exists at path to determine if this is first or subsequent deployment + +#### Understanding the Registry Parameter + +A common challenge with users is determining the right value for "registry". This is composed of two parts: + +1. **Registry domain:** docker.io, ghcr.io, localhost:50000 +2. **Registry user:** alice, func, etc. + +When combined this constitutes a full "registry" location for the Function's built image. + +**Examples:** +- `docker.io/alice` +- `localhost:50000/func` + +The final Function image will then have the Function name as a suffix along with the :latest tag (example: `docker.io/alice/myfunc:latest`), but this is hidden from the user unless they want to fully override this behavior and supply their own custom value for the image parameter. + +**Important guidance:** +- It is important to carefully guide the user through the creation of this registry argument, as this is often the most challenging part of getting a Function deployed the first time +- Ask for the registry. If they only provide the DOMAIN part (eg docker.io or localhost:50000), ask them to either confirm there is no registry user part or provide it +- The final value is the two concatenated with a forward slash +- Subsequent deployments automatically reuse the last setting, so this should only be asked for on those first deployments +- BE SURE to verify the final format of this value as consisting of both a DOMAIN part and a USER part +- Domain-only is technically allowed, but should be explicitly acknowledged, as this is an edge case + +#### First-time Deployment Considerations + +A first-time deploy can be detected by checking the func.yaml for a value in the "deploy" section which denotes the settings used in the last deployment. If this is the first deployment: + +- A user should be warned to confirm their target cluster and namespace is the intended destination (this can also be determined for the user using kubectl if they agree) +- The "builder" argument should be defaulted to "host" for Go and Python functions +- For other languages, the user should be warned that first-time builds can be slow because the builder images will need to be downloaded, and they must have Podman or Docker available + +### build + +- **FIRST:** Read `func://help/build` for authoritative usage information +- **REQUIRED parameters:** + - `path` (directory containing the Function to build) +- Builds the container image without deploying +- Useful for creating custom deployments using .yaml files or integrating with other systems which expect containers +- Uses same builder settings as deploy would use +- The user should be notified this is an unnecessary step if they intend to deploy, as building is handled as part of deployment + +### list + +- **FIRST:** Read `func://help/list` for authoritative usage information +- Does NOT use path parameter (operates on cluster, not local files) +- Optional `namespace` parameter to list Functions in specific namespace +- Returns list of deployed Functions in current/specified namespace + +### delete + +- **FIRST:** Read `func://help/delete` for authoritative usage information +- Supports TWO modes (mutually exclusive): + 1. **Delete by PATH:** Provide 'path' parameter (reads function name from func.yaml at that path) + 2. **Delete by NAME:** Provide 'name' parameter (deletes named function from cluster) +- Exactly ONE of 'path' or 'name' must be provided, not both +- Deleting does not affect local files (source). Only cluster resources. + +### config_volumes, config_labels, config_envs + +- All config tools require the 'path' parameter +- path points to the Function directory whose func.yaml will be modified +- These tools modify local func.yaml files only (changes take effect on next deploy) diff --git a/pkg/mcp/instructions_warning.md b/pkg/mcp/instructions_warning.md new file mode 100644 index 0000000000..98697ddeec --- /dev/null +++ b/pkg/mcp/instructions_warning.md @@ -0,0 +1,33 @@ +# ⚠️ Read-Only Mode Warning + +**IMPORTANT INSTRUCTIONS FOR YOU (the AI assistant)** + +## Current Status + +The Functions MCP server is currently running in **read-only mode**. + +**Available operations:** +- Create Functions +- Build Functions +- Configure Functions (envs, labels, volumes) +- Inspect Functions + +**Disabled operations:** +- Deploy to cluster +- Delete from cluster + +These write operations are disabled to prevent unintended cluster modifications. + +## Enabling Write Mode + +If the user needs to deploy or delete Functions, you MUST inform them to enable write mode: + +1. Close/exit this application completely +2. Set the environment variable: `FUNC_ENABLE_MCP_WRITE=true` +3. Restart the application (thus restarting the MCP server process) + +## Important Notes + +- DO NOT suggest workarounds such as running the `func` binary directly +- The proper way to enable write operations is through the write mode configuration above +- For detailed setup instructions with popular MCP clients, see: https://github.com/knative/func/blob/main/docs/mcp-integration/integration.md diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 3a5ea68862..76b28df13e 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -2,362 +2,164 @@ package mcp import ( "context" + "errors" + "os/exec" + "strings" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/modelcontextprotocol/go-sdk/mcp" ) -var TEMPLATE_RESOURCE_URIS = []string{ - "https://github.com/functions-dev/templates", -} +const ( + Name = "func" + Title = "func" + Version = "0.1.0" +) -type MCPServer struct { - server *server.MCPServer +// NOTE: Invoking prompts in some interfaces (such as Claude Code) when all +// tool parameters are optional parameters requires at least one character of +// input. See issue: https://github.com/anthropics/claude-code/issues/5597 + +// Server is an MCP Server instance +type Server struct { + OnInit func(context.Context) // Invoked when the server is initialized + prefix string // Command prefix ("func" or "kn func") + readonly bool // enables deploy and delete + executor executor + transport mcp.Transport // Transport to use (defaults to StdioTransport) + impl *mcp.Server // implements the protocol } -func NewServer() *MCPServer { - mcpServer := server.NewMCPServer( - "func-mcp", - "1.0.0", - server.WithToolCapabilities(true), - ) - - mcpServer.AddTool( - mcp.NewTool("healthcheck", - mcp.WithDescription("Checks if the server is running"), - ), - handleHealthCheckTool, - ) - - mcpServer.AddTool( - mcp.NewTool("create", - mcp.WithDescription("Creates a Knative function project in the current or specified directory"), - mcp.WithString("cwd", - mcp.Required(), - mcp.Description("Current working directory of the MCP client"), - ), - mcp.WithString("name", - mcp.Required(), - mcp.Description("Name of the function to be created (used as subdirectory)"), - ), - mcp.WithString("language", - mcp.Required(), - mcp.Description("Language runtime to use (e.g., node, go, python)"), - ), - - // Optional flags - mcp.WithString("template", mcp.Description("Function template (e.g., http, cloudevents)")), - mcp.WithString("repository", mcp.Description("URI to Git repo containing the template. Overrides default template selection when provided.")), - mcp.WithBoolean("confirm", mcp.Description("Prompt to confirm options interactively")), - mcp.WithBoolean("verbose", mcp.Description("Print verbose logs")), - ), - handleCreateTool, - ) - - mcpServer.AddTool( - mcp.NewTool("deploy", - mcp.WithDescription("Deploys the function to the cluster"), - mcp.WithString("registry", - mcp.Required(), - mcp.Description("Registry to be used to push the function image"), - ), - mcp.WithString("cwd", - mcp.Required(), - mcp.Description("Full path of the function to be deployed"), - ), - mcp.WithString("builder", - mcp.Required(), - mcp.Description("Builder to be used to build the function image"), - ), - - // Optional flags - mcp.WithString("image", mcp.Description("Full image name (overrides registry)")), - mcp.WithString("namespace", mcp.Description("Namespace to deploy the function into")), - mcp.WithString("git-url", mcp.Description("Git URL containing the function source")), - mcp.WithString("git-branch", mcp.Description("Git branch for remote deployment")), - mcp.WithString("git-dir", mcp.Description("Directory inside the Git repository")), - mcp.WithString("builder-image", mcp.Description("Custom builder image")), - mcp.WithString("domain", mcp.Description("Domain for the function route")), - mcp.WithString("platform", mcp.Description("Target platform to build for (e.g., linux/amd64)")), - mcp.WithString("path", mcp.Description("Path to the function directory")), - mcp.WithString("build", mcp.Description(`Build control: "true", "false", or "auto"`)), - mcp.WithString("pvc-size", mcp.Description("Custom volume size for remote builds")), - mcp.WithString("service-account", mcp.Description("Kubernetes ServiceAccount to use")), - mcp.WithString("remote-storage-class", mcp.Description("Storage class for remote volume")), - - mcp.WithBoolean("confirm", mcp.Description("Prompt for confirmation before deploying")), - mcp.WithBoolean("push", mcp.Description("Push image to registry before deployment")), - mcp.WithBoolean("verbose", mcp.Description("Print verbose logs")), - mcp.WithBoolean("registry-insecure", mcp.Description("Skip TLS verification for registry")), - mcp.WithBoolean("build-timestamp", mcp.Description("Use actual time in image metadata")), - mcp.WithBoolean("remote", mcp.Description("Trigger remote deployment")), - ), - handleDeployTool, - ) - - mcpServer.AddTool( - mcp.NewTool("list", - mcp.WithDescription("Lists all deployed functions in the current or specified namespace"), - - // Optional flags - mcp.WithBoolean("all-namespaces", mcp.Description("List functions in all namespaces (overrides --namespace)")), - mcp.WithString("namespace", mcp.Description("The namespace to list functions in (default is current/active)")), - mcp.WithString("output", mcp.Description("Output format: human, plain, json, xml, yaml")), - mcp.WithBoolean("verbose", mcp.Description("Enable verbose output")), - ), - handleListTool, - ) - - mcpServer.AddTool( - mcp.NewTool("build", - mcp.WithDescription("Builds the function image in the current directory"), - mcp.WithString("cwd", - mcp.Required(), - mcp.Description("Current working directory of the MCP client"), - ), - mcp.WithString("builder", - mcp.Required(), - mcp.Description("Builder to be used to build the function image (pack, s2i, host)"), - ), - mcp.WithString("registry", - mcp.Required(), - mcp.Description("Registry to be used to push the function image (e.g. ghcr.io/user)"), - ), - - // Optional flags - mcp.WithString("builder-image", mcp.Description("Custom builder image to use with buildpacks")), - mcp.WithString("image", mcp.Description("Full image name (overrides registry + function name)")), - mcp.WithString("path", mcp.Description("Path to the function directory (default is current dir)")), - mcp.WithString("platform", mcp.Description("Target platform, e.g. linux/amd64 (for s2i builds)")), - - mcp.WithBoolean("confirm", mcp.Description("Prompt for confirmation before proceeding")), - mcp.WithBoolean("push", mcp.Description("Push image to registry after building")), - mcp.WithBoolean("verbose", mcp.Description("Enable verbose logging output")), - mcp.WithBoolean("registry-insecure", mcp.Description("Skip TLS verification for insecure registries")), - mcp.WithBoolean("build-timestamp", mcp.Description("Use actual time for image timestamp (buildpacks only)")), - ), - handleBuildTool, - ) - - mcpServer.AddTool( - mcp.NewTool("delete", - mcp.WithDescription("Deletes a function from the cluster"), - mcp.WithString("name", - mcp.Required(), - mcp.Description("Name of the function to be deleted"), - ), - - // Optional flags - mcp.WithString("namespace", mcp.Description("Namespace to delete from (default: current or active)")), - mcp.WithString("path", mcp.Description("Path to the function project (default is current directory)")), - mcp.WithString("all", mcp.Description(`Delete all related resources like Pipelines, Secrets ("true"/"false")`)), - - mcp.WithBoolean("confirm", mcp.Description("Prompt to confirm before deletion")), - mcp.WithBoolean("verbose", mcp.Description("Enable verbose output")), - ), - handleDeleteTool, - ) - - mcpServer.AddTool( - mcp.NewTool("config_volumes", - mcp.WithDescription("Lists and manages configured volumes for a function"), - mcp.WithString("action", - mcp.Required(), - mcp.Description("The action to perform: 'add' to add a volume, 'remove' to remove a volume, 'list' to list volumes"), - ), - mcp.WithString("path", - mcp.Required(), - mcp.Description("Path to the function. Default is current directory ($FUNC_PATH)"), - ), - - // Optional flags - mcp.WithString("type", mcp.Description("Volume type: configmap, secret, pvc, or emptydir")), - mcp.WithString("mount_path", mcp.Description("Mount path for the volume in the function container")), - mcp.WithString("source", mcp.Description("Name of the ConfigMap, Secret, or PVC to mount (not used for emptydir)")), - mcp.WithString("medium", mcp.Description("Storage medium for EmptyDir volume: 'Memory' or '' (default)")), - mcp.WithString("size", mcp.Description("Maximum size limit for EmptyDir volume (e.g., 1Gi)")), - mcp.WithBoolean("read_only", mcp.Description("Mount volume as read-only (only for PVC)")), - mcp.WithBoolean("verbose", mcp.Description("Print verbose logs ($FUNC_VERBOSE)")), - ), - handleConfigVolumesTool, - ) - - mcpServer.AddTool( - mcp.NewTool("config_labels", - mcp.WithDescription("Lists and manages labels for a function"), - mcp.WithString("action", - mcp.Required(), - mcp.Description("The action to perform: 'add' to add a label, 'remove' to remove a label, 'list' to list labels"), - ), - mcp.WithString("path", - mcp.Required(), - mcp.Description("Path to the function. Default is current directory ($FUNC_PATH)"), - ), - - // Optional flags - mcp.WithString("name", mcp.Description("Name of the label.")), - mcp.WithString("value", mcp.Description("Value of the label.")), - mcp.WithBoolean("verbose", mcp.Description("Print verbose logs ($FUNC_VERBOSE)")), - ), - handleConfigLabelsTool, - ) - - mcpServer.AddTool( - mcp.NewTool("config_envs", - mcp.WithDescription("Lists and manages environment variables for a function"), - mcp.WithString("action", - mcp.Required(), - mcp.Description("The action to perform: 'add' to add an env var, 'remove' to remove, 'list' to list env vars"), - ), - mcp.WithString("path", - mcp.Required(), - mcp.Description("Path to the function. Default is current directory ($FUNC_PATH)"), - ), - - // Optional flags - mcp.WithString("name", mcp.Description("Name of the environment variable.")), - mcp.WithString("value", mcp.Description("Value of the environment variable.")), - mcp.WithBoolean("verbose", mcp.Description("Print verbose logs ($FUNC_VERBOSE)")), - ), - handleConfigEnvsTool, - ) - - mcpServer.AddResource(mcp.NewResource( - "func://docs", - "Root Help Command", - mcp.WithResourceDescription("--help output of the func command"), - mcp.WithMIMEType("text/plain"), - ), handleRootHelpResource) - - // Static help resources for each command - mcpServer.AddResource(mcp.NewResource( - "func://create/docs", - "Create Command Help", - mcp.WithResourceDescription("--help output of the 'create' command"), - mcp.WithMIMEType("text/plain"), - ), func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - return runHelpCommand([]string{"create"}, "func://create/docs") - }) - - mcpServer.AddResource(mcp.NewResource( - "func://build/docs", - "Build Command Help", - mcp.WithResourceDescription("--help output of the 'build' command"), - mcp.WithMIMEType("text/plain"), - ), func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - return runHelpCommand([]string{"build"}, "func://build/docs") - }) - - mcpServer.AddResource(mcp.NewResource( - "func://deploy/docs", - "Deploy Command Help", - mcp.WithResourceDescription("--help output of the 'deploy' command"), - mcp.WithMIMEType("text/plain"), - ), func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - return runHelpCommand([]string{"deploy"}, "func://deploy/docs") - }) - - mcpServer.AddResource(mcp.NewResource( - "func://list/docs", - "List Command Help", - mcp.WithResourceDescription("--help output of the 'list' command"), - mcp.WithMIMEType("text/plain"), - ), func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - return runHelpCommand([]string{"list"}, "func://list/docs") - }) - - mcpServer.AddResource(mcp.NewResource( - "func://delete/docs", - "Delete Command Help", - mcp.WithResourceDescription("--help output of the 'delete' command"), - mcp.WithMIMEType("text/plain"), - ), func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - return runHelpCommand([]string{"delete"}, "func://delete/docs") - }) +type executor interface { + Execute(ctx context.Context, subcommand string, args ...string) ([]byte, error) +} - mcpServer.AddResource(mcp.NewResource( - "func://config/volumes/add/docs", - "Config Volumes Add Command Help", - mcp.WithResourceDescription("--help output of the 'config volumes add' command"), - mcp.WithMIMEType("text/plain"), - ), func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - return runHelpCommand([]string{"config", "volumes", "add"}, "func://config/volumes/add/docs") - }) +type Option func(*Server) - mcpServer.AddResource(mcp.NewResource( - "func://config/volumes/remove/docs", - "Config Volumes Remove Command Help", - mcp.WithResourceDescription("--help output of the 'config volumes remove' command"), - mcp.WithMIMEType("text/plain"), - ), func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - return runHelpCommand([]string{"config", "volumes", "remove"}, "func://config/volumes/remove/docs") - }) +// WithPrefix sets the command prefix (e.g., "func" or "kn func") +func WithPrefix(prefix string) Option { + return func(s *Server) { + s.prefix = prefix + } +} - mcpServer.AddResource(mcp.NewResource( - "func://config/labels/add/docs", - "Config Labels Add Command Help", - mcp.WithResourceDescription("--help output of the 'config labels add' command"), - mcp.WithMIMEType("text/plain"), - ), func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - return runHelpCommand([]string{"config", "labels", "add"}, "func://config/labels/add/docs") - }) +// WithExecutor sets a custom executor for running commands; used in tests. +func WithExecutor(executor executor) Option { + return func(s *Server) { + s.executor = executor + } +} - mcpServer.AddResource(mcp.NewResource( - "func://config/labels/remove/docs", - "Config Labels Remove Command Help", - mcp.WithResourceDescription("--help output of the 'config labels remove' command"), - mcp.WithMIMEType("text/plain"), - ), func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - return runHelpCommand([]string{"config", "labels", "remove"}, "func://config/labels/remove/docs") - }) +// WithTransport sets a custom transport for the server; used in tests. +func WithTransport(transport mcp.Transport) Option { + return func(s *Server) { + s.transport = transport + } +} - mcpServer.AddResource(mcp.NewResource( - "func://config/envs/add/docs", - "Config Envs Add Command Help", - mcp.WithResourceDescription("--help output of the 'config envs add' command"), - mcp.WithMIMEType("text/plain"), - ), func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - return runHelpCommand([]string{"config", "envs", "add"}, "func://config/envs/add/docs") - }) +// WithReadonly sets the server to readonly mode. +func WithReadonly(readonly bool) Option { + return func(s *Server) { + s.readonly = readonly + } +} - mcpServer.AddResource(mcp.NewResource( - "func://config/envs/remove/docs", - "Config Envs Remove Command Help", - mcp.WithResourceDescription("--help output of the 'config envs remove' command"), - mcp.WithMIMEType("text/plain"), - ), func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - return runHelpCommand([]string{"config", "envs", "remove"}, "func://config/envs/remove/docs") - }) +// New MCP Server +func New(options ...Option) *Server { + s := &Server{ + prefix: "func", + transport: &mcp.StdioTransport{}, + OnInit: func(_ context.Context) {}, + } + s.executor = defaultExecutor{s} + for _, o := range options { + o(s) + } - // Static resource for listing available templates - mcpServer.AddResource(mcp.NewResource( - "func://templates", - "Available Templates", - mcp.WithResourceDescription("List of available function templates"), - mcp.WithMIMEType("plain/text"), - ), handleListTemplatesResource) + i := mcp.NewServer( + &mcp.Implementation{ + Name: Name, + Title: Title, + Version: Version}, + &mcp.ServerOptions{ + Instructions: instructions(s.readonly), + HasPrompts: true, + HasResources: true, + HasTools: true, + InitializedHandler: func(ctx context.Context, _ *mcp.InitializedRequest) { s.OnInit(ctx) }, + }) + + // Tools + // ----- + // One for each command or command group + mcp.AddTool(i, healthCheckTool, s.healthcheckHandler) + mcp.AddTool(i, createTool, s.createHandler) + mcp.AddTool(i, buildTool, s.buildHandler) + mcp.AddTool(i, deployTool, s.deployHandler) + mcp.AddTool(i, listTool, s.listHandler) + mcp.AddTool(i, deleteTool, s.deleteHandler) + mcp.AddTool(i, configVolumesTool, s.configVolumesHandler) + mcp.AddTool(i, configLabelsTool, s.configLabelsHandler) + mcp.AddTool(i, configEnvsTool, s.configEnvsHandler) + + // Resources + // --------- + // Current Function state + i.AddResource(functionStateResource, s.functionStateHandler) + + // Available languages (output of the languages subcommand) + i.AddResource(languagesResource, s.languagesHandler) + + // Available templates + i.AddResource(templatesResource, s.templatesHandler) + + // Help + // 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, "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")) + i.AddResource(newHelpResource(s, "List Help", "help for 'list'", "list")) + + i.AddResource(newHelpResource(s, "Volumes Help", "general help for volumes", "config", "volumes")) + i.AddResource(newHelpResource(s, "Volumes Add Help", "help for 'config volumes add'", "config", "volumes", "add")) + i.AddResource(newHelpResource(s, "Volumes Remove Help", "help for 'config volumes remove'", "list", "volumes", "remove")) + + i.AddResource(newHelpResource(s, "Labels Help", "general help for labels", "config", "labels")) + i.AddResource(newHelpResource(s, "Labels Add Help", "help for 'config labels add'", "config", "labels", "add")) + i.AddResource(newHelpResource(s, "Labels Remove Help", "help for 'config labels remove'", "list", "labels", "remove")) + + i.AddResource(newHelpResource(s, "Envs Help", "general help for environment variables", "config", "envs")) + i.AddResource(newHelpResource(s, "Envs Add Help", "help for 'config envs add'", "config", "envs", "add")) + i.AddResource(newHelpResource(s, "Envs Remove Help", "help for 'config envs remove'", "list", "envs", "remove")) + + s.impl = i + + return s +} - mcpServer.AddPrompt(mcp.NewPrompt("help", - mcp.WithPromptDescription("help prompt for the root command"), - ), handleRootHelpPrompt) +// Start the MCP server using the configured transport +func (s *Server) Start(ctx context.Context, writeEnabled bool) error { + s.readonly = !writeEnabled + return s.impl.Run(ctx, s.transport) +} - mcpServer.AddPrompt(mcp.NewPrompt("cmd_help", - mcp.WithPromptDescription("help prompt for a specific command"), - mcp.WithArgument("cmd", - mcp.ArgumentDescription("The command for which help is requested"), - mcp.RequiredArgument(), - ), - ), handleCmdHelpPrompt) +// For now the executor is a simple run of the command "func" or "kn func" +// etc. This should be replaced with a direct integration with the functions +// client API. +type defaultExecutor struct { + s *Server +} - mcpServer.AddPrompt(mcp.NewPrompt("list_templates", - mcp.WithPromptDescription("prompt to list available function templates"), - ), handleListTemplatesPrompt) +func (e defaultExecutor) Execute(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + // Parse prefix: "func" or "kn func" -> ["func"] or ["kn", "func"] + cmdParts := strings.Fields(e.s.prefix) + cmdParts = append(cmdParts, subcommand) + cmdParts = append(cmdParts, args...) - return &MCPServer{ - server: mcpServer, - } + cmd := exec.CommandContext(ctx, cmdParts[0], cmdParts[1:]...) + // Commands always execute in current working directory + return cmd.CombinedOutput() } -func (s *MCPServer) Start() error { - return server.ServeStdio(s.server) -} +var ErrWriteDisabled = errors.New("server is not write enabled") diff --git a/pkg/mcp/mcp_test.go b/pkg/mcp/mcp_test.go new file mode 100644 index 0000000000..0b0f0e8cd0 --- /dev/null +++ b/pkg/mcp/mcp_test.go @@ -0,0 +1,151 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// TestStart ensures that the MCP server can be instantiated and started. +func TestStart(t *testing.T) { + _, _, err := newTestPair(t) + if err != nil { + t.Fatal(err) + } +} + +// TestInstructions ensures the instructions.md has been embedded as the +// server's instructions. +func TestInstructions(t *testing.T) { + // Test both readonly and write modes + testCases := []struct { + name string + readonly bool + }{ + {"write_mode", false}, + {"readonly_mode", true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + client, _, err := newTestPairWithReadonly(t, tc.readonly) + if err != nil { + t.Fatal(err) + } + + result := client.InitializeResult() + if result == nil { + t.Fatal("InitializeResult is nil") + } + + if result.Instructions == "" { + t.Fatal("Instructions are empty") + } + + if !strings.Contains(result.Instructions, "# Functions MCP Agent Instructions") { + t.Error("Instructions missing main title") + } + + // Verify readonly warning is present only in readonly mode + hasReadonlyWarning := strings.Contains(result.Instructions, "# ⚠️ Read-Only Mode Warning") + if tc.readonly && !hasReadonlyWarning { + t.Error("Readonly mode should include readonly warning") + } + if !tc.readonly && hasReadonlyWarning { + t.Error("Write mode should not include readonly warning") + } + }) + } +} + +// newTestPairWithReadonly returns a ClientSession and Server with the specified readonly mode. +func newTestPairWithReadonly(t *testing.T, readonly bool) (*mcp.ClientSession, *Server, error) { + t.Helper() + var ( + errCh = make(chan error, 1) + initCh = make(chan struct{}) + serverTpt, clientTpt = mcp.NewInMemoryTransports() + ) + + // Create a test server with in-memory transport and readonly flag set + server := New(WithTransport(serverTpt), WithReadonly(readonly)) + server.OnInit = func(ctx context.Context) { + close(initCh) + } + + // Start the Server (readonly already set via WithReadonly option) + go func() { + errCh <- server.Start(t.Context(), !readonly) + }() + + // Connect a client to trigger initialization + client := mcp.NewClient(&mcp.Implementation{ + Name: "test-client", + Version: "1.0.0", + }, nil) + session, err := client.Connect(t.Context(), clientTpt, nil) + if err != nil { + return nil, nil, fmt.Errorf("client connection failed: %v", err) + } + + // Wait for init + select { + case err = <-errCh: + return nil, nil, fmt.Errorf("server exited prematurely %v", err) + case <-t.Context().Done(): + return nil, nil, fmt.Errorf("timeout waiting for server initialization") + case <-initCh: // Successful start; continue. + } + return session, server, nil +} + +// newTestPair returns a ClientSession and Server connected over an in-memory transport. +func newTestPair(t *testing.T, options ...Option) (session *mcp.ClientSession, server *Server, err error) { + t.Helper() + var ( + errCh = make(chan error, 1) + initCh = make(chan struct{}) + serverTpt, clientTpt = mcp.NewInMemoryTransports() + ) + + oo := []Option{ + WithTransport(serverTpt), + } + oo = append(oo, options...) + + // Create a test server with in-memory transport and a channel it signals + // upon successful initialization. + server = New(oo...) + server.OnInit = func(ctx context.Context) { + close(initCh) + } + + // Start the Server + go func() { + errCh <- server.Start(t.Context(), false) + }() + + // Connect a client to trigger initialization + client := mcp.NewClient(&mcp.Implementation{ + Name: "test-client", + Version: "1.0.0", + }, nil) + session, err = client.Connect(t.Context(), clientTpt, nil) + if err != nil { + err = fmt.Errorf("client connection failed: %v", err) + return + } + + // Wait for init + select { + case err = <-errCh: + err = fmt.Errorf("server exited prematurely %v", err) + case <-t.Context().Done(): + err = fmt.Errorf("timeout waiting for server initialization") + case <-initCh: // Successful start; continue. + } + return +} diff --git a/pkg/mcp/mock/executor.go b/pkg/mcp/mock/executor.go new file mode 100644 index 0000000000..1427df430c --- /dev/null +++ b/pkg/mcp/mock/executor.go @@ -0,0 +1,29 @@ +package mock + +import ( + "context" +) + +// Executor is a mock implementation of the command executor interface. +// It implements the same interface as mcp.Executor through structural typing. +type Executor struct { + ExecuteInvoked bool + ExecuteFn func(context.Context, string, ...string) ([]byte, error) +} + +// NewExecutor creates a new mock executor +func NewExecutor() *Executor { + return &Executor{} +} + +// Execute implements the executor interface, recording invocation details +// and delegating to ExecuteFn if provided. +func (m *Executor) Execute(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + m.ExecuteInvoked = true + + if m.ExecuteFn != nil { + return m.ExecuteFn(ctx, subcommand, args...) + } + + return []byte(""), nil +} diff --git a/pkg/mcp/resources.go b/pkg/mcp/resources.go new file mode 100644 index 0000000000..c99e8b68cc --- /dev/null +++ b/pkg/mcp/resources.go @@ -0,0 +1,21 @@ +package mcp + +import ( + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// resource helpers: + +func newErrorResult(uri string, err error) *mcp.ReadResourceResult { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + URI: uri, + MIMEType: "text/plain", + Text: fmt.Sprintf("%v", err), + }, + }, + } +} diff --git a/pkg/mcp/resources_function.go b/pkg/mcp/resources_function.go new file mode 100644 index 0000000000..997f596cf0 --- /dev/null +++ b/pkg/mcp/resources_function.go @@ -0,0 +1,41 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + fn "knative.dev/func/pkg/functions" +) + +var functionStateResource = &mcp.Resource{ + URI: "func://function", + Name: "Current Function State", + Description: "Current Function configuration (from working directory)", + MIMEType: "application/json", +} + +func (s *Server) functionStateHandler(ctx context.Context, r *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + var ( + uri = "func://function" + f fn.Function + state []byte + err error + ) + if f, err = fn.NewFunction(""); err != nil { + return newErrorResult(uri, err), nil + } + if !f.Initialized() { + return newErrorResult(uri, fmt.Errorf("no Function found in current directory")), nil + } + if state, err = json.MarshalIndent(f, "", " "); err != nil { + return nil, err + } + + return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{ + URI: uri, + MIMEType: "application/json", + Text: string(state), + }}}, nil +} diff --git a/pkg/mcp/resources_help.go b/pkg/mcp/resources_help.go new file mode 100644 index 0000000000..b0d0fc4c97 --- /dev/null +++ b/pkg/mcp/resources_help.go @@ -0,0 +1,35 @@ +package mcp + +import ( + "context" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// newHelpResource returns a resource which will output the --help text +// of a given command. +func newHelpResource(s *Server, name string, desc string, cmd ...string) (resource *mcp.Resource, handler mcp.ResourceHandler) { + // URI in format "func://help/command/subcommand" + uri := strings.Join(append([]string{"func://help"}, cmd...), "/") + + resource = &mcp.Resource{ + URI: uri, + Name: name, + Description: desc, + MIMEType: "text/plain", + } + + handler = func(ctx context.Context, r *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + out, err := s.executor.Execute(ctx, "", append(cmd, "--help")...) + if err != nil { + return nil, err + } + return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{ + URI: uri, + MIMEType: "text/plain", + Text: string(out), + }}}, nil + } + return +} diff --git a/pkg/mcp/resources_language.go b/pkg/mcp/resources_language.go new file mode 100644 index 0000000000..11945650bf --- /dev/null +++ b/pkg/mcp/resources_language.go @@ -0,0 +1,27 @@ +package mcp + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var languagesResource = &mcp.Resource{ + URI: "func://languages", + Name: "Available Languages", + Description: "List of available language runtimes", + MIMEType: "text/plain", +} + +func (s *Server) languagesHandler(ctx context.Context, r *mcp.ReadResourceRequest) (result *mcp.ReadResourceResult, err error) { + out, err := s.executor.Execute(ctx, "languages") + if err != nil { + return result, err + } + + return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{ + URI: "func://languages", + MIMEType: "text/plain", + Text: string(out), + }}}, nil +} diff --git a/pkg/mcp/resources_templates.go b/pkg/mcp/resources_templates.go new file mode 100644 index 0000000000..e4a81ee814 --- /dev/null +++ b/pkg/mcp/resources_templates.go @@ -0,0 +1,27 @@ +package mcp + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var templatesResource = &mcp.Resource{ + URI: "func://templates", + Name: "Templates", + Description: "List of available local templates", + MIMEType: "text/plain", +} + +func (s *Server) templatesHandler(ctx context.Context, r *mcp.ReadResourceRequest) (result *mcp.ReadResourceResult, err error) { + out, err := s.executor.Execute(ctx, "templates") + if err != nil { + return result, err + } + + return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{ + URI: "func://templates", + MIMEType: "text/plain", + Text: string(out), + }}}, nil +} diff --git a/pkg/mcp/resources_test.go b/pkg/mcp/resources_test.go new file mode 100644 index 0000000000..b40ff82195 --- /dev/null +++ b/pkg/mcp/resources_test.go @@ -0,0 +1,189 @@ +package mcp + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + fn "knative.dev/func/pkg/functions" + "knative.dev/func/pkg/mcp/mock" +) + +func TestResource_FunctionState(t *testing.T) { + root := t.TempDir() + cwd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(cwd) }) // Change back out before cleanup on Windows + _ = os.Chdir(root) + + client, _, err := newTestPair(t) + if err != nil { + t.Fatal(err) + } + + // Test case 1: Error when no Function exists in working directory + result, err := client.ReadResource(context.Background(), &mcp.ReadResourceParams{ + URI: "func://function", + }) + if err != nil { + t.Fatal(err) + } + if len(result.Contents) != 1 { + t.Fatalf("expected 1 content, got %d", len(result.Contents)) + } + content := result.Contents[0] + if content.MIMEType != "text/plain" { + t.Fatalf("expected MIME type 'text/plain' for error, got %q", content.MIMEType) + } + if !strings.Contains(content.Text, "no Function found") { + t.Fatalf("expected error message to contain 'no Function found', got: %s", content.Text) + } + + // Test case 2: Success when Function exists + + // Initialize a function in the current directory + f := fn.Function{ + Name: "my-function", + Runtime: "go", + Registry: "quay.io/user", + Root: ".", + } + if _, err := fn.New().Init(f); err != nil { + t.Fatal(err) + } + + result, err = client.ReadResource(context.Background(), &mcp.ReadResourceParams{ + URI: "func://function", + }) + if err != nil { + t.Fatal(err) + } + if len(result.Contents) != 1 { + t.Fatalf("expected 1 content, got %d", len(result.Contents)) + } + content = result.Contents[0] + if content.MIMEType != "application/json" { + t.Fatalf("expected MIME type 'application/json', got %q", content.MIMEType) + } + + // Unmarshal and validate the function state + var state fn.Function + if err := json.Unmarshal([]byte(content.Text), &state); err != nil { + t.Fatalf("failed to unmarshal function state: %v", err) + } + if state.Name != "my-function" { + t.Fatalf("expected Name='my-function', got %q", state.Name) + } + if state.Runtime != "go" { + t.Fatalf("expected Runtime='go', got %q", state.Runtime) + } + if state.Created.IsZero() { + t.Fatal("expected Created timestamp to be set") + } +} + +func TestResource_Languages(t *testing.T) { + expectedOutput := `go +node +python +etc +` + + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + if subcommand != "languages" { + t.Fatalf("expected subcommand 'languages', got %q", subcommand) + } + if len(args) != 0 { + t.Fatalf("expected no args, got %v", args) + } + return []byte(expectedOutput), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.ReadResource(context.Background(), &mcp.ReadResourceParams{ + URI: "func://languages", + }) + if err != nil { + t.Fatal(err) + } + + if len(result.Contents) != 1 { + t.Fatalf("expected 1 content, got %d", len(result.Contents)) + } + + content := result.Contents[0] + if content.URI != "func://languages" { + t.Fatalf("expected URI 'func://languages', got %q", content.URI) + } + if content.MIMEType != "text/plain" { + t.Fatalf("expected MIME type 'text/plain', got %q", content.MIMEType) + } + if content.Text != expectedOutput { + t.Fatalf("expected output:\n%s\ngot:\n%s", expectedOutput, content.Text) + } + + if !executor.ExecuteInvoked { + t.Fatal("executor was not invoked") + } +} + +func TestResource_Templates(t *testing.T) { + expectedOutput := `LANGUAGE TEMPLATE +go cloudevents +go http +node cloudevents +node http +python cloudevents +python http +etc etc +` + + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + if subcommand != "templates" { + t.Fatalf("expected subcommand 'templates', got %q", subcommand) + } + if len(args) != 0 { + t.Fatalf("expected no args, got %v", args) + } + return []byte(expectedOutput), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.ReadResource(context.Background(), &mcp.ReadResourceParams{ + URI: "func://templates", + }) + if err != nil { + t.Fatal(err) + } + + if len(result.Contents) != 1 { + t.Fatalf("expected 1 content, got %d", len(result.Contents)) + } + + content := result.Contents[0] + if content.URI != "func://templates" { + t.Fatalf("expected URI 'func://templates', got %q", content.URI) + } + if content.MIMEType != "text/plain" { + t.Fatalf("expected MIME type 'text/plain', got %q", content.MIMEType) + } + if content.Text != expectedOutput { + t.Fatalf("expected output:\n%s\ngot:\n%s", expectedOutput, content.Text) + } + + if !executor.ExecuteInvoked { + t.Fatal("executor was not invoked") + } +} diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index de64efe0c4..0d7ed0fc7d 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -1,454 +1,42 @@ package mcp import ( - "context" "fmt" - "os/exec" - "github.com/mark3labs/mcp-go/mcp" + "github.com/modelcontextprotocol/go-sdk/mcp" ) -func handleHealthCheckTool( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - body := []byte(fmt.Sprintf(`{"message": "%s"}`, "The MCP server is running!")) - return mcp.NewToolResultText(string(body)), nil -} - -func handleCreateTool( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - cwd, err := request.RequireString("cwd") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - name, err := request.RequireString("name") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - language, err := request.RequireString("language") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - args := []string{"create", "-l", language} - - // Optional flags - if v := request.GetString("template", ""); v != "" { - args = append(args, "--template", v) - } - if v := request.GetString("repository", ""); v != "" { - args = append(args, "--repository", v) - } - if request.GetBool("confirm", false) { - args = append(args, "--confirm") - } - if request.GetBool("verbose", false) { - args = append(args, "--verbose") - } - - // `name` is passed as a positional argument (directory to create in) - args = append(args, name) - - cmd := exec.Command("func", args...) - cmd.Dir = cwd - - out, err := cmd.CombinedOutput() - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("func create failed: %s", out)), nil - } - - body := []byte(fmt.Sprintf(`{"result": "%s"}`, out)) - return mcp.NewToolResultText(string(body)), nil -} - -func handleDeployTool( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - cwd, err := request.RequireString("cwd") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - registry, err := request.RequireString("registry") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - builder, err := request.RequireString("builder") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - args := []string{"deploy", "--builder", builder, "--registry", registry} - - // Optional flags - if v := request.GetString("image", ""); v != "" { - args = append(args, "--image", v) - } - if v := request.GetString("namespace", ""); v != "" { - args = append(args, "--namespace", v) - } - if v := request.GetString("git-url", ""); v != "" { - args = append(args, "--git-url", v) - } - if v := request.GetString("git-branch", ""); v != "" { - args = append(args, "--git-branch", v) - } - if v := request.GetString("git-dir", ""); v != "" { - args = append(args, "--git-dir", v) - } - if v := request.GetString("builder-image", ""); v != "" { - args = append(args, "--builder-image", v) - } - if v := request.GetString("domain", ""); v != "" { - args = append(args, "--domain", v) - } - if v := request.GetString("platform", ""); v != "" { - args = append(args, "--platform", v) - } - if v := request.GetString("path", ""); v != "" { - args = append(args, "--path", v) - } - if v := request.GetString("build", ""); v != "" { - args = append(args, "--build", v) - } - if v := request.GetString("pvc-size", ""); v != "" { - args = append(args, "--pvc-size", v) - } - if v := request.GetString("service-account", ""); v != "" { - args = append(args, "--service-account", v) - } - if v := request.GetString("remote-storage-class", ""); v != "" { - args = append(args, "--remote-storage-class", v) - } - - if request.GetBool("confirm", false) { - args = append(args, "--confirm") - } - if request.GetBool("push", false) { - args = append(args, "--push") - } - if request.GetBool("verbose", false) { - args = append(args, "--verbose") - } - if request.GetBool("registry-insecure", false) { - args = append(args, "--registry-insecure") - } - if request.GetBool("build-timestamp", false) { - args = append(args, "--build-timestamp") - } - if request.GetBool("remote", false) { - args = append(args, "--remote") - } - - cmd := exec.Command("func", args...) - cmd.Dir = cwd - out, err := cmd.CombinedOutput() - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("func deploy failed: %s", out)), nil - } - body := []byte(fmt.Sprintf(`{"result": "%s"}`, out)) - return mcp.NewToolResultText(string(body)), nil -} - -func handleListTool( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - args := []string{"list"} - - // Optional flags - if request.GetBool("all-namespaces", false) { - args = append(args, "--all-namespaces") - } - if v := request.GetString("namespace", ""); v != "" { - args = append(args, "--namespace", v) - } - if v := request.GetString("output", ""); v != "" { - args = append(args, "--output", v) - } - if request.GetBool("verbose", false) { - args = append(args, "--verbose") - } - - cmd := exec.Command("func", args...) - out, err := cmd.CombinedOutput() - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("func list failed: %s", out)), nil - } - body := []byte(fmt.Sprintf(`{"result": "%s"}`, out)) - return mcp.NewToolResultText(string(body)), nil -} - -func handleBuildTool( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - cwd, err := request.RequireString("cwd") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - builder, err := request.RequireString("builder") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - registry, err := request.RequireString("registry") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - args := []string{"build", "--builder", builder, "--registry", registry} - - // Optional flags - if v := request.GetString("builder-image", ""); v != "" { - args = append(args, "--builder-image", v) - } - if v := request.GetString("image", ""); v != "" { - args = append(args, "--image", v) - } - if v := request.GetString("path", ""); v != "" { - args = append(args, "--path", v) - } - if v := request.GetString("platform", ""); v != "" { - args = append(args, "--platform", v) - } - - if v := request.GetBool("confirm", false); v { - args = append(args, "--confirm") - } - if v := request.GetBool("push", false); v { - args = append(args, "--push") - } - if v := request.GetBool("verbose", false); v { - args = append(args, "--verbose") - } - if v := request.GetBool("registry-insecure", false); v { - args = append(args, "--registry-insecure") - } - if v := request.GetBool("build-timestamp", false); v { - args = append(args, "--build-timestamp") - } - - cmd := exec.Command("func", args...) - cmd.Dir = cwd - out, err := cmd.CombinedOutput() - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("func build failed: %s", out)), nil - } - body := []byte(fmt.Sprintf(`{"result": "%s"}`, out)) - return mcp.NewToolResultText(string(body)), nil -} - -func handleDeleteTool( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - name, err := request.RequireString("name") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - args := []string{"delete", name} - - // Optional flags - if v := request.GetString("namespace", ""); v != "" { - args = append(args, "--namespace", v) - } - if v := request.GetString("path", ""); v != "" { - args = append(args, "--path", v) - } - if v := request.GetString("all", ""); v != "" { - args = append(args, "--all", v) - } - - if request.GetBool("confirm", false) { - args = append(args, "--confirm") - } - if request.GetBool("verbose", false) { - args = append(args, "--verbose") - } +// Helpers - cmd := exec.Command("func", args...) - out, err := cmd.CombinedOutput() - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("func delete failed: %s", out)), nil - } - - body := []byte(fmt.Sprintf(`{"result": "%s"}`, out)) - return mcp.NewToolResultText(string(body)), nil -} - -func handleConfigVolumesTool( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - action, err := request.RequireString("action") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - path, err := request.RequireString("path") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - if action == "list" { - args := []string{"config", "volumes", "--path", path} - if request.GetBool("verbose", false) { - args = append(args, "--verbose") - } - - cmd := exec.Command("func", args...) - out, err := cmd.CombinedOutput() - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("func config volumes list failed: %s", out)), nil +func resultToString(result *mcp.CallToolResult) string { + if len(result.Content) > 0 { + if tc, ok := result.Content[0].(*mcp.TextContent); ok { + return tc.Text } - body := []byte(fmt.Sprintf(`{"result": "%s"}`, out)) - return mcp.NewToolResultText(string(body)), nil - } - - args := []string{"config", "volumes", action} - - if action == "add" { - volumeType, err := request.RequireString("type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - args = append(args, "--type", volumeType) - } - mountPath, err := request.RequireString("mount_path") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - args = append(args, "--mount-path", mountPath, "--path", path) - - // Optional flags - if v := request.GetString("source", ""); v != "" { - args = append(args, "--source", v) - } - if v := request.GetString("medium", ""); v != "" { - args = append(args, "--medium", v) - } - if v := request.GetString("size", ""); v != "" { - args = append(args, "--size", v) } - if request.GetBool("read_only", false) { - args = append(args, "--read-only") - } - if request.GetBool("verbose", false) { - args = append(args, "--verbose") - } - - cmd := exec.Command("func", args...) - out, err := cmd.CombinedOutput() - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("func config volumes failed: %s", out)), nil - } - - body := []byte(fmt.Sprintf(`{"result": "%s"}`, out)) - return mcp.NewToolResultText(string(body)), nil + return fmt.Sprintf("%v", result.Content) } -func handleConfigLabelsTool( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - action, err := request.RequireString("action") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil +// appendStringFlag adds a string flag to args only if the value is non-nil and non-empty. +// This ensures we only pass flags that were explicitly provided by the user. +func appendStringFlag(args []string, flag string, value *string) []string { + if value != nil && *value != "" { + return append(args, flag, *value) } - path, err := request.RequireString("path") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - if action == "list" { - args := []string{"config", "labels", "--path", path} - if request.GetBool("verbose", false) { - args = append(args, "--verbose") - } - - cmd := exec.Command("func", args...) - out, err := cmd.CombinedOutput() - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("func config labels list failed: %s", out)), nil - } - body := []byte(fmt.Sprintf(`{"result": "%s"}`, out)) - return mcp.NewToolResultText(string(body)), nil - } - - args := []string{"config", "labels", action, "--path", path} - - // Optional flags - if name := request.GetString("name", ""); name != "" { - args = append(args, "--name", name) - } - if value := request.GetString("value", ""); value != "" { - args = append(args, "--value", value) - } - if request.GetBool("verbose", false) { - args = append(args, "--verbose") - } - - cmd := exec.Command("func", args...) - out, err := cmd.CombinedOutput() - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("func config labels %s failed: %s", action, out)), nil - } - - body := []byte(fmt.Sprintf(`{"result": "%s"}`, out)) - return mcp.NewToolResultText(string(body)), nil + return args } -func handleConfigEnvsTool( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - action, err := request.RequireString("action") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - path, err := request.RequireString("path") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Handle 'list' action separately - if action == "list" { - args := []string{"config", "envs", "--path", path} - if request.GetBool("verbose", false) { - args = append(args, "--verbose") - } - - cmd := exec.Command("func", args...) - out, err := cmd.CombinedOutput() - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("func config envs list failed: %s", out)), nil - } - body := []byte(fmt.Sprintf(`{"result": "%s"}`, out)) - return mcp.NewToolResultText(string(body)), nil - } - - // Handle 'add' and 'remove' actions - args := []string{"config", "envs", action, "--path", path} - - // Optional flags - if name := request.GetString("name", ""); name != "" { - args = append(args, "--name", name) - } - if value := request.GetString("value", ""); value != "" { - args = append(args, "--value", value) - } - if request.GetBool("verbose", false) { - args = append(args, "--verbose") - } - - cmd := exec.Command("func", args...) - out, err := cmd.CombinedOutput() - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("func config envs %s failed: %s", action, out)), nil +// appendBoolFlag adds a boolean flag to args only if the value is non-nil and true. +// This ensures we only pass flags that were explicitly provided by the user. +func appendBoolFlag(args []string, flag string, value *bool) []string { + if value != nil && *value { + return append(args, flag) } + return args +} - body := []byte(fmt.Sprintf(`{"result": "%s"}`, out)) - return mcp.NewToolResultText(string(body)), nil +// ptr returns a pointer to the given value. +// Useful for setting optional annotation fields. +func ptr[T any](v T) *T { + return &v } diff --git a/pkg/mcp/tools_build.go b/pkg/mcp/tools_build.go new file mode 100644 index 0000000000..52576187aa --- /dev/null +++ b/pkg/mcp/tools_build.go @@ -0,0 +1,72 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var buildTool = &mcp.Tool{ + Name: "build", + Title: "Build Function", + Description: "Build a Function's container image.", + Annotations: &mcp.ToolAnnotations{ + Title: "Build Function", + ReadOnlyHint: false, + DestructiveHint: ptr(false), + IdempotentHint: true, // Building the same source code multiple times produces the same container image. + }, +} + +func (s *Server) buildHandler(ctx context.Context, r *mcp.CallToolRequest, input BuildInput) (result *mcp.CallToolResult, output BuildOutput, err error) { + out, err := s.executor.Execute(ctx, "build", input.Args()...) + if err != nil { + err = fmt.Errorf("%w\n%s", err, string(out)) + return + } + output = BuildOutput{ + Message: string(out), + } + return +} + +// BuildInput defines the input parameters for the build tool. +type BuildInput struct { + Path string `json:"path" jsonschema:"required,Path to the function project directory"` + Builder *string `json:"builder,omitempty" jsonschema:"Builder to use (pack, s2i, or host)"` + Registry *string `json:"registry,omitempty" jsonschema:"Container registry for function image"` + BuilderImage *string `json:"builderImage,omitempty" jsonschema:"Custom builder image to use with buildpacks"` + Image *string `json:"image,omitempty" jsonschema:"Full image name (overrides registry)"` + Platform *string `json:"platform,omitempty" jsonschema:"Target platform (e.g., linux/amd64)"` + Push *bool `json:"push,omitempty" jsonschema:"Push image to registry after building"` + RegistryInsecure *bool `json:"registryInsecure,omitempty" jsonschema:"Skip TLS verification for insecure registries"` + BuildTimestamp *bool `json:"buildTimestamp,omitempty" jsonschema:"Use actual time for image timestamp (buildpacks only)"` + Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"` +} + +func (i BuildInput) Args() []string { + // Required + args := []string{"--path", i.Path} + + // String flags + args = appendStringFlag(args, "--builder", i.Builder) + args = appendStringFlag(args, "--registry", i.Registry) + args = appendStringFlag(args, "--builder-image", i.BuilderImage) + args = appendStringFlag(args, "--image", i.Image) + args = appendStringFlag(args, "--platform", i.Platform) + + // Boolean flags + args = appendBoolFlag(args, "--push", i.Push) + args = appendBoolFlag(args, "--registry-insecure", i.RegistryInsecure) + args = appendBoolFlag(args, "--build-timestamp", i.BuildTimestamp) + args = appendBoolFlag(args, "--verbose", i.Verbose) + + return args +} + +// BuildOutput defines the structured output returned by the build tool. +type BuildOutput struct { + Image string `json:"image,omitempty" jsonschema:"The built image name"` + Message string `json:"message" jsonschema:"Output message from func command"` +} diff --git a/pkg/mcp/tools_build_test.go b/pkg/mcp/tools_build_test.go new file mode 100644 index 0000000000..310fce45e0 --- /dev/null +++ b/pkg/mcp/tools_build_test.go @@ -0,0 +1,69 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "knative.dev/func/pkg/mcp/mock" +) + +// TestTool_Build_Args ensures the build tool executes with all arguments passed correctly. +func TestTool_Build_Args(t *testing.T) { + // Test data - defined once and used for both input and validation + stringFlags := map[string]struct { + jsonKey string + flag string + value string + }{ + "path": {"path", "--path", "."}, + "builder": {"builder", "--builder", "pack"}, + "registry": {"registry", "--registry", "ghcr.io/user"}, + "builderImage": {"builderImage", "--builder-image", "custom-builder:latest"}, + "image": {"image", "--image", "ghcr.io/user/my-func:latest"}, + "platform": {"platform", "--platform", "linux/amd64"}, + } + + boolFlags := map[string]string{ + "push": "--push", + "registryInsecure": "--registry-insecure", + "buildTimestamp": "--build-timestamp", + "verbose": "--verbose", + } + + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + if subcommand != "build" { + t.Fatalf("expected subcommand 'build', got %q", subcommand) + } + + validateArgLength(t, args, len(stringFlags), len(boolFlags)) + validateStringFlags(t, args, stringFlags) + validateBoolFlags(t, args, boolFlags) + + return []byte("OK\n"), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + // Build input arguments from test data + inputArgs := buildInputArgs(stringFlags, boolFlags) + + // Invoke tool with all optional arguments + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "build", + Arguments: inputArgs, + }) + 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") + } +} diff --git a/pkg/mcp/tools_config_envs.go b/pkg/mcp/tools_config_envs.go new file mode 100644 index 0000000000..150711a4d2 --- /dev/null +++ b/pkg/mcp/tools_config_envs.go @@ -0,0 +1,60 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var configEnvsTool = &mcp.Tool{ + Name: "config_envs", + Title: "Config Environment Variables", + Description: "Manages environment variable configurations for a function. Can add, remove, or list.", + Annotations: &mcp.ToolAnnotations{ + Title: "Config Environment Variables", + ReadOnlyHint: false, + DestructiveHint: ptr(true), + IdempotentHint: false, // Adding the same environment variable twice or removing a non-existent one will fail. + }, +} + +func (s *Server) configEnvsHandler(ctx context.Context, r *mcp.CallToolRequest, input ConfigEnvsInput) (result *mcp.CallToolResult, output ConfigEnvsOutput, err error) { + out, err := s.executor.Execute(ctx, "config", input.Args()...) + if err != nil { + err = fmt.Errorf("%w\n%s", err, string(out)) + return + } + output = ConfigEnvsOutput{ + Message: string(out), + } + return +} + +// ConfigEnvsInput defines the input parameters for the config_envs tool. +type ConfigEnvsInput struct { + Action string `json:"action" jsonschema:"required,Action to perform: add, remove, or list"` + Path string `json:"path" jsonschema:"required,Path to the function project directory"` + Name *string `json:"name,omitempty" jsonschema:"Name of the environment variable"` + Value *string `json:"value,omitempty" jsonschema:"Value of the environment variable (for add action)"` + Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"` +} + +func (i ConfigEnvsInput) Args() []string { + args := []string{"envs"} + + // allow "list" as alias for the default action + if i.Action != "list" { + args = append(args, i.Action) + } + args = append(args, "--path", i.Path) // required + args = appendStringFlag(args, "--name", i.Name) + args = appendStringFlag(args, "--value", i.Value) + args = appendBoolFlag(args, "--verbose", i.Verbose) + return args +} + +// ConfigEnvsOutput defines the structured output returned by the config_envs tool. +type ConfigEnvsOutput struct { + Message string `json:"message" jsonschema:"Output message"` +} diff --git a/pkg/mcp/tools_config_envs_test.go b/pkg/mcp/tools_config_envs_test.go new file mode 100644 index 0000000000..3ea81855f2 --- /dev/null +++ b/pkg/mcp/tools_config_envs_test.go @@ -0,0 +1,130 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "knative.dev/func/pkg/mcp/mock" +) + +// TestTool_ConfigEnvs_Add ensures the config envs tool executes with all arguments for add action. +func TestTool_ConfigEnvs_Add(t *testing.T) { + stringFlags := map[string]struct { + jsonKey string + flag string + value string + }{ + "path": {"path", "--path", "."}, + "name": {"name", "--name", "API_KEY"}, + "value": {"value", "--value", "secret123"}, + } + + boolFlags := map[string]string{ + "verbose": "--verbose", + } + + action := "add" + + 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) + } + + if len(args) < 2 { + t.Fatalf("expected at least 2 args (subcommand and action), got %d: %v", len(args), args) + } + + // Validate "envs" subcommand + if args[0] != "envs" { + t.Fatalf("expected args[0]='envs', got %q", args[0]) + } + + // Validate action + if args[1] != action { + t.Fatalf("expected args[1]=%q, got %q", action, args[1]) + } + + // Validate flags (skip first 2 args which are "envs" and "add") + validateArgLength(t, args[2:], len(stringFlags), len(boolFlags)) + validateStringFlags(t, args[2:], stringFlags) + validateBoolFlags(t, args[2:], boolFlags) + + return []byte("Environment variable added successfully\n"), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + // Build input arguments from test data + inputArgs := buildInputArgs(stringFlags, boolFlags) + inputArgs["action"] = action + + // Invoke tool with all arguments + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "config_envs", + Arguments: inputArgs, + }) + 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") + } +} + +// TestTool_ConfigEnvs_List ensures the config envs tool can list environment variables. +func TestTool_ConfigEnvs_List(t *testing.T) { + action := "list" + + 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) + } + + // For list action, "envs" + "--path" flag = 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]) + } + + // Validate path flag + argsMap := argsToMap(args[1:]) + if val, ok := argsMap["--path"]; !ok || val != "." { + t.Fatalf("expected --path flag with value '.', got %q", val) + } + + return []byte("DATABASE_URL=postgres://localhost\nAPI_KEY=secret\n"), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "config_envs", + Arguments: map[string]any{ + "action": action, + "path": ".", + }, + }) + 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") + } +} diff --git a/pkg/mcp/tools_config_labels.go b/pkg/mcp/tools_config_labels.go new file mode 100644 index 0000000000..2b8db1bf3c --- /dev/null +++ b/pkg/mcp/tools_config_labels.go @@ -0,0 +1,60 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var configLabelsTool = &mcp.Tool{ + Name: "config_labels", + Title: "Config Labels", + Description: "Manages label configurations for a function. Can add, remove, or list.", + Annotations: &mcp.ToolAnnotations{ + Title: "Config Labels", + ReadOnlyHint: false, + DestructiveHint: ptr(true), + IdempotentHint: false, // Adding the same label twice or removing a non-existent label will fail. + }, +} + +func (s *Server) configLabelsHandler(ctx context.Context, r *mcp.CallToolRequest, input ConfigLabelsInput) (result *mcp.CallToolResult, output ConfigLabelsOutput, err error) { + out, err := s.executor.Execute(ctx, "config", input.Args()...) + if err != nil { + err = fmt.Errorf("%w\n%s", err, string(out)) + return + } + output = ConfigLabelsOutput{ + Message: string(out), + } + return +} + +// ConfigLabelsInput defines the input parameters for the config_labels tool. +type ConfigLabelsInput struct { + Action string `json:"action" jsonschema:"required,Action to perform: add, remove, or list"` + Path string `json:"path" jsonschema:"required,Path to the function project directory"` + Name *string `json:"name,omitempty" jsonschema:"Name of the label"` + Value *string `json:"value,omitempty" jsonschema:"Value of the label (for add action)"` + Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"` +} + +func (i ConfigLabelsInput) Args() []string { + args := []string{"labels"} + + // allow "list" as an alias for the default action + if i.Action != "list" { + args = append(args, i.Action) + } + args = append(args, "--path", i.Path) + args = appendStringFlag(args, "--name", i.Name) + args = appendStringFlag(args, "--value", i.Value) + args = appendBoolFlag(args, "--verbose", i.Verbose) + return args +} + +// ConfigLabelsOutput defines the structured output returned by the config_labels tool. +type ConfigLabelsOutput struct { + Message string `json:"message" jsonschema:"Output message"` +} diff --git a/pkg/mcp/tools_config_labels_test.go b/pkg/mcp/tools_config_labels_test.go new file mode 100644 index 0000000000..31b45bf3ba --- /dev/null +++ b/pkg/mcp/tools_config_labels_test.go @@ -0,0 +1,132 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "knative.dev/func/pkg/mcp/mock" +) + +// TestTool_ConfigLabels_Add ensures the config labels tool executes with all arguments for add action. +func TestTool_ConfigLabels_Add(t *testing.T) { + // Test data - defined once and used for both input and validation + stringFlags := map[string]struct { + jsonKey string + flag string + value string + }{ + "path": {"path", "--path", "."}, + "name": {"name", "--name", "environment"}, + "value": {"value", "--value", "prod"}, + } + + boolFlags := map[string]string{ + "verbose": "--verbose", + } + + // Required field + action := "add" + + 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) + } + + if len(args) < 2 { + t.Fatalf("expected at least 2 args (subcommand and action), got %d: %v", len(args), args) + } + + // Validate "labels" subcommand + if args[0] != "labels" { + t.Fatalf("expected args[0]='labels', got %q", args[0]) + } + + // Validate action + if args[1] != action { + t.Fatalf("expected args[1]=%q, got %q", action, args[1]) + } + + // Validate flags (skip first 2 args which are "labels" and "add") + validateArgLength(t, args[2:], len(stringFlags), len(boolFlags)) + validateStringFlags(t, args[2:], stringFlags) + validateBoolFlags(t, args[2:], boolFlags) + + return []byte("Label added successfully\n"), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + // Build input arguments from test data + inputArgs := buildInputArgs(stringFlags, boolFlags) + inputArgs["action"] = action + + // Invoke tool with all arguments + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "config_labels", + Arguments: inputArgs, + }) + 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") + } +} + +// TestTool_ConfigLabels_List ensures the config labels tool can list labels. +func TestTool_ConfigLabels_List(t *testing.T) { + action := "list" + + 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) + } + + // For list action, "labels" + "--path" flag = 3 args + if len(args) != 3 { + t.Fatalf("expected 3 args, got %d: %v", len(args), args) + } + if args[0] != "labels" { + t.Fatalf("expected args[0]='labels', got %q", args[0]) + } + + // Validate path flag + argsMap := argsToMap(args[1:]) + if val, ok := argsMap["--path"]; !ok || val != "." { + t.Fatalf("expected --path flag with value '.', got %q", val) + } + + return []byte("app=my-function\nenvironment=prod\n"), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "config_labels", + Arguments: map[string]any{ + "action": action, + "path": ".", + }, + }) + 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") + } +} diff --git a/pkg/mcp/tools_config_volumes.go b/pkg/mcp/tools_config_volumes.go new file mode 100644 index 0000000000..5b9ed4580d --- /dev/null +++ b/pkg/mcp/tools_config_volumes.go @@ -0,0 +1,70 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var configVolumesTool = &mcp.Tool{ + Name: "config_volumes", + Title: "Config Volumes", + Description: "Manages volume configurations for a function. Can add, remove, or list.", + Annotations: &mcp.ToolAnnotations{ + Title: "Config Volumes", + ReadOnlyHint: false, + DestructiveHint: ptr(true), + IdempotentHint: false, // Adding the same volume twice or removing a non-existent volume will fail. + }, +} + +func (s *Server) configVolumesHandler(ctx context.Context, r *mcp.CallToolRequest, input ConfigVolumesInput) (result *mcp.CallToolResult, output ConfigVolumesOutput, err error) { + out, err := s.executor.Execute(ctx, "config", input.Args()...) + if err != nil { + err = fmt.Errorf("%w\n%s", err, string(out)) + return + } + output = ConfigVolumesOutput{ + Message: string(out), + } + return +} + +// ConfigVolumesInput defines the input parameters for the config_volumes tool. +type ConfigVolumesInput struct { + Action string `json:"action" jsonschema:"required,Action to perform: add, remove, or list"` + Path string `json:"path" jsonschema:"required,Path to the function project directory"` + Type *string `json:"type,omitempty" jsonschema:"Volume type for add action: configmap, secret, pvc, or emptydir"` + MountPath *string `json:"mountPath,omitempty" jsonschema:"Mount path for the volume in the container"` + Source *string `json:"source,omitempty" jsonschema:"Name of the ConfigMap, Secret, or PVC to mount"` + Medium *string `json:"medium,omitempty" jsonschema:"Storage medium for EmptyDir volume: Memory or empty string"` + Size *string `json:"size,omitempty" jsonschema:"Maximum size limit for EmptyDir volume (e.g., 1Gi)"` + ReadOnly *bool `json:"readOnly,omitempty" jsonschema:"Mount volume as read-only (only for PVC)"` + Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"` +} + +func (i ConfigVolumesInput) Args() []string { + args := []string{"volumes"} + + // Allow "list" as an alias for the default action + if i.Action != "list" { + args = append(args, i.Action) + } + + args = append(args, "--path", i.Path) + args = appendStringFlag(args, "--type", i.Type) + args = appendStringFlag(args, "--mount-path", i.MountPath) + args = appendStringFlag(args, "--source", i.Source) + args = appendStringFlag(args, "--medium", i.Medium) + args = appendStringFlag(args, "--size", i.Size) + args = appendBoolFlag(args, "--read-only", i.ReadOnly) + args = appendBoolFlag(args, "--verbose", i.Verbose) + + return args +} + +// ConfigVolumesOutput defines the structured output returned by the config_volumes tool. +type ConfigVolumesOutput struct { + Message string `json:"message" jsonschema:"Output message"` +} diff --git a/pkg/mcp/tools_config_volumes_test.go b/pkg/mcp/tools_config_volumes_test.go new file mode 100644 index 0000000000..5f30f69a47 --- /dev/null +++ b/pkg/mcp/tools_config_volumes_test.go @@ -0,0 +1,136 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "knative.dev/func/pkg/mcp/mock" +) + +// TestTool_ConfigVolumes_Add ensures the config volumes tool executes with all arguments for add action. +func TestTool_ConfigVolumes_Add(t *testing.T) { + // Test data - defined once and used for both input and validation + stringFlags := map[string]struct { + jsonKey string + flag string + value string + }{ + "path": {"path", "--path", "."}, + "type": {"type", "--type", "secret"}, + "mountPath": {"mountPath", "--mount-path", "/workspace/secret"}, + "source": {"source", "--source", "my-secret"}, + "medium": {"medium", "--medium", "Memory"}, + "size": {"size", "--size", "1Gi"}, + } + + boolFlags := map[string]string{ + "readOnly": "--read-only", + "verbose": "--verbose", + } + + // Required field + action := "add" + + 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) + } + + if len(args) < 2 { + t.Fatalf("expected at least 2 args (subcommand and action), got %d: %v", len(args), args) + } + + // Validate "volumes" subcommand + if args[0] != "volumes" { + t.Fatalf("expected args[0]='volumes', got %q", args[0]) + } + + // Validate action + if args[1] != action { + t.Fatalf("expected args[1]=%q, got %q", action, args[1]) + } + + // Validate flags (skip first 2 args which are "volumes" and "add") + validateArgLength(t, args[2:], len(stringFlags), len(boolFlags)) + validateStringFlags(t, args[2:], stringFlags) + validateBoolFlags(t, args[2:], boolFlags) + + return []byte("Volume added successfully\n"), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + // Build input arguments from test data + inputArgs := buildInputArgs(stringFlags, boolFlags) + inputArgs["action"] = action + + // Invoke tool with all arguments + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "config_volumes", + Arguments: inputArgs, + }) + 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") + } +} + +// TestTool_ConfigVolumes_List ensures the config volumes tool can list volumes. +func TestTool_ConfigVolumes_List(t *testing.T) { + action := "list" + + 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) + } + + // For list action, "volumes" + "--path" flag = 3 args + if len(args) != 3 { + t.Fatalf("expected 3 args, got %d: %v", len(args), args) + } + if args[0] != "volumes" { + t.Fatalf("expected args[0]='volumes', got %q", args[0]) + } + + // Validate path flag + argsMap := argsToMap(args[1:]) + if val, ok := argsMap["--path"]; !ok || val != "." { + t.Fatalf("expected --path flag with value '.', got %q", val) + } + + return []byte("secret:my-secret:/workspace/secret\n"), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "config_volumes", + Arguments: map[string]any{ + "action": action, + "path": ".", + }, + }) + 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") + } +} diff --git a/pkg/mcp/tools_create.go b/pkg/mcp/tools_create.go new file mode 100644 index 0000000000..28e4b7a03e --- /dev/null +++ b/pkg/mcp/tools_create.go @@ -0,0 +1,58 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var createTool = &mcp.Tool{ + Name: "create", + Title: "Create Function", + Description: "Create a new Function project.", + Annotations: &mcp.ToolAnnotations{ + Title: "Create Function", + ReadOnlyHint: false, + DestructiveHint: ptr(false), + IdempotentHint: false, // Running create twice on the same path fails because function files already exist. + }, +} + +func (s *Server) createHandler(ctx context.Context, r *mcp.CallToolRequest, input CreateInput) (result *mcp.CallToolResult, output CreateOutput, err error) { + out, err := s.executor.Execute(ctx, "create", input.Args()...) + if err != nil { + err = fmt.Errorf("%w\n%s", err, string(out)) + return + } + output = CreateOutput{ + Runtime: input.Language, + Template: input.Template, + Message: string(out), + } + return +} + +type CreateInput struct { + Language string `json:"language" jsonschema:"required,Language runtime to use"` + Path string `json:"path" jsonschema:"required,Path to the function project directory"` + Template *string `json:"template,omitempty" jsonschema:"Function template (e.g., http, cloudevents)"` + Repository *string `json:"repository,omitempty" jsonschema:"Git repository URI containing custom templates"` + Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"` +} + +func (i CreateInput) Args() []string { + args := []string{"-l", i.Language, "--path", i.Path} + + // Optional + args = appendStringFlag(args, "--template", i.Template) + args = appendStringFlag(args, "--repository", i.Repository) + args = appendBoolFlag(args, "--verbose", i.Verbose) + return args +} + +type CreateOutput struct { + Runtime string `json:"runtime" jsonschema:"Language runtime used"` + Template *string `json:"template" jsonschema:"Template used"` + Message string `json:"message,omitempty" jsonschema:"Output message"` +} diff --git a/pkg/mcp/tools_create_test.go b/pkg/mcp/tools_create_test.go new file mode 100644 index 0000000000..737102b8a3 --- /dev/null +++ b/pkg/mcp/tools_create_test.go @@ -0,0 +1,125 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "knative.dev/func/pkg/mcp/mock" +) + +// TestTool_Create_Args ensures the create tool executes, passing in args. +func TestTool_Create_Args(t *testing.T) { + // Test data - defined once and used for both input and validation + // Note: language (-l) is required and handled separately + stringFlags := map[string]struct { + jsonKey string + flag string + value string + }{ + "path": {"path", "--path", "."}, + "template": {"template", "--template", "cloudevents"}, + "repository": {"repository", "--repository", "https://example.com/repo"}, + } + + boolFlags := map[string]string{ + "verbose": "--verbose", + } + + // Required fields + language := "go" + + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + if subcommand != "create" { + t.Fatalf("expected subcommand 'create', got %q", subcommand) + } + + // Expected: 1 required string flag (-l) + stringFlags + boolFlags + if len(args) != (1+len(stringFlags))*2+len(boolFlags) { + t.Fatalf("expected %d args, got %d: %v", (1+len(stringFlags))*2+len(boolFlags), len(args), args) + } + + // Validate required -l flag + argsMap := argsToMap(args) + if val, ok := argsMap["-l"]; !ok { + t.Fatalf("missing required flag '-l'") + } else if val != language { + t.Fatalf("flag '-l': expected value %q, got %q", language, val) + } + + // Validate optional string and boolean flags + validateStringFlags(t, args, stringFlags) + validateBoolFlags(t, args, boolFlags) + + return []byte("OK\n"), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + // Build input arguments from test data + inputArgs := buildInputArgs(stringFlags, boolFlags) + inputArgs["language"] = language + + // Invoke tool with all arguments + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "create", + Arguments: inputArgs, + }) + if err != nil { + t.Fatal(err) + } + if result.IsError { + t.Fatal(err) + } + if !executor.ExecuteInvoked { + t.Fatal("executor was not invoked") + } +} + +// TestCreate_PathValidation is removed - path validation no longer exists +// Create now operates in current working directory + +// TestCreate_BinaryFailure ensures errors from the func binary are returned as MCP errors +func TestTool_Create_BinaryFailure(t *testing.T) { + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + // Simulate binary returning an error + return []byte("Error: example error\n"), fmt.Errorf("exit status 1") + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + // Invoke, expecting an error + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "create", + Arguments: map[string]any{ + "language": "go", + "path": ".", + }, + }) + if err != nil { + t.Fatal(err) + } + + // Should return error from binary + if !result.IsError { + t.Fatal("expected error result when binary fails") + } + if !executor.ExecuteInvoked { + t.Fatal("executor should have been invoked") + } + + // Error should include binary output + if !strings.Contains(resultToString(result), "example error") { + t.Errorf("expected error to include binary output, got: %s", resultToString(result)) + } +} diff --git a/pkg/mcp/tools_delete.go b/pkg/mcp/tools_delete.go new file mode 100644 index 0000000000..a44df3076f --- /dev/null +++ b/pkg/mcp/tools_delete.go @@ -0,0 +1,75 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var deleteTool = &mcp.Tool{ + Name: "delete", + Title: "Delete Function", + Description: "Delete a deployed Function from the cluster (but not locally).", + Annotations: &mcp.ToolAnnotations{ + Title: "Delete Function", + ReadOnlyHint: false, + DestructiveHint: ptr(true), + IdempotentHint: true, // Deleting the same function multiple times results in the same end state (function doesn't exist). + }, +} + +func (s *Server) deleteHandler(ctx context.Context, r *mcp.CallToolRequest, input DeleteInput) (result *mcp.CallToolResult, output DeleteOutput, err error) { + if s.readonly { + err = fmt.Errorf("the server is currently in readonly mode. Please set FUNC_ENABLE_MCP_WRITE and restart the client") + return + } + + // Validate: exactly one of Path or Name must be provided + if (input.Path != nil && input.Name != nil) || (input.Path == nil && input.Name == nil) { + err = fmt.Errorf("exactly one of 'path' or 'name' must be provided") + return + } + + // Execute + out, err := s.executor.Execute(ctx, "delete", input.Args()...) + if err != nil { + err = fmt.Errorf("%w\n%s", err, string(out)) + return + } + output = DeleteOutput{ + Message: string(out), + } + return +} + +// DeleteInput defines the input parameters for the delete tool. +// Exactly one of Path or Name must be provided. +type DeleteInput struct { + Path *string `json:"path,omitempty" jsonschema:"Path to the function project directory (mutually exclusive with name)"` + Name *string `json:"name,omitempty" jsonschema:"Name of the function to delete (mutually exclusive with path)"` + Namespace *string `json:"namespace,omitempty" jsonschema:"Kubernetes namespace to delete from (default: current or active namespace)"` + All *string `json:"all,omitempty" jsonschema:"Delete all related resources like Pipelines, Secrets (true/false)"` + Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"` +} + +func (i DeleteInput) Args() []string { + args := []string{} + + // Either path flag or positional name argument + if i.Path != nil { + args = append(args, "--path", *i.Path) + } else if i.Name != nil { + args = append(args, *i.Name) + } + + args = appendStringFlag(args, "--namespace", i.Namespace) + args = appendStringFlag(args, "--all", i.All) + args = appendBoolFlag(args, "--verbose", i.Verbose) + return args +} + +// DeleteOutput defines the structured output returned by the delete tool. +type DeleteOutput struct { + Message string `json:"message" jsonschema:"Output message"` +} diff --git a/pkg/mcp/tools_delete_test.go b/pkg/mcp/tools_delete_test.go new file mode 100644 index 0000000000..1bccc57df6 --- /dev/null +++ b/pkg/mcp/tools_delete_test.go @@ -0,0 +1,78 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "knative.dev/func/pkg/mcp/mock" +) + +// TestTool_Delete_Args ensures the delete tool executes with all arguments passed correctly. +func TestTool_Delete_Args(t *testing.T) { + // Test data - defined once and used for both input and validation + stringFlags := map[string]struct { + jsonKey string + flag string + value string + }{ + "namespace": {"namespace", "--namespace", "prod"}, + "all": {"all", "--all", "true"}, + } + + boolFlags := map[string]string{ + "verbose": "--verbose", + } + + // Required fields and positional arguments + name := "my-function" + + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + if subcommand != "delete" { + t.Fatalf("expected subcommand 'delete', got %q", subcommand) + } + + // Expected: 1 positional + 2 string flags + 1 bool flag = 1 + 2*2 + 1 = 6 args + if len(args) != 1+len(stringFlags)*2+len(boolFlags) { + t.Fatalf("expected %d args, got %d: %v", 1+len(stringFlags)*2+len(boolFlags), len(args), args) + } + + // Validate positional argument (name) comes first + if args[0] != name { + t.Fatalf("expected positional arg %q, got %q", name, args[0]) + } + + // Validate flags (excluding positional argument at beginning) + validateStringFlags(t, args[1:], stringFlags) + validateBoolFlags(t, args[1:], boolFlags) + + return []byte("Function 'my-function' deleted from namespace 'prod'\n"), nil + } + + client, server, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + // Delete requires write mode - enable it for this test + server.readonly = false + + // Build input arguments from test data + inputArgs := buildInputArgs(stringFlags, boolFlags) + inputArgs["name"] = name + + // Invoke tool with all arguments + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "delete", + Arguments: inputArgs, + }) + 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") + } +} diff --git a/pkg/mcp/tools_deploy.go b/pkg/mcp/tools_deploy.go new file mode 100644 index 0000000000..518052130f --- /dev/null +++ b/pkg/mcp/tools_deploy.go @@ -0,0 +1,94 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var deployTool = &mcp.Tool{ + Name: "deploy", + Title: "Deploy Function", + Description: "Deploy a Function. Builds the container as needed.", + Annotations: &mcp.ToolAnnotations{ + Title: "Deploy Function", + ReadOnlyHint: false, + DestructiveHint: ptr(false), + IdempotentHint: true, // Deploying the same function configuration multiple times converges to the same desired state. + }, +} + +func (s *Server) deployHandler(ctx context.Context, r *mcp.CallToolRequest, input DeployInput) (result *mcp.CallToolResult, output DeployOutput, err error) { + if s.readonly { + err = fmt.Errorf("the server is currently in readonly mode. Please set FUNC_ENABLE_MCP_WRITE and restart the client") + return + } + out, err := s.executor.Execute(ctx, "deploy", input.Args()...) + if err != nil { + err = fmt.Errorf("%w\n%s", err, string(out)) + return + } + output = DeployOutput{ + Message: string(out), + } + 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"` + Builder *string `json:"builder,omitempty" jsonschema:"Builder to use (pack, s2i, or host)"` + Registry *string `json:"registry,omitempty" jsonschema:"Container registry for function image"` + Image *string `json:"image,omitempty" jsonschema:"Full image name (overrides registry)"` + Namespace *string `json:"namespace,omitempty" jsonschema:"Kubernetes namespace to deploy into"` + GitURL *string `json:"gitUrl,omitempty" jsonschema:"Git URL containing the function source"` + GitBranch *string `json:"gitBranch,omitempty" jsonschema:"Git branch for remote deployment"` + GitDir *string `json:"gitDir,omitempty" jsonschema:"Directory inside the Git repository"` + BuilderImage *string `json:"builderImage,omitempty" jsonschema:"Custom builder image"` + Domain *string `json:"domain,omitempty" jsonschema:"Domain for the function route"` + Platform *string `json:"platform,omitempty" jsonschema:"Target platform (e.g., linux/amd64)"` + Build *string `json:"build,omitempty" jsonschema:"Build control: true, false, or auto"` + PVCSize *string `json:"pvcSize,omitempty" jsonschema:"Custom volume size for remote builds"` + ServiceAccount *string `json:"serviceAccount,omitempty" jsonschema:"Kubernetes ServiceAccount to use"` + RemoteStorageClass *string `json:"remoteStorageClass,omitempty" jsonschema:"Storage class for remote volume"` + Push *bool `json:"push,omitempty" jsonschema:"Push image to registry before deployment"` + RegistryInsecure *bool `json:"registryInsecure,omitempty" jsonschema:"Skip TLS verification for registry"` + BuildTimestamp *bool `json:"buildTimestamp,omitempty" jsonschema:"Use actual time in image metadata"` + Remote *bool `json:"remote,omitempty" jsonschema:"Trigger remote deployment"` + Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"` +} + +func (i DeployInput) Args() []string { + args := []string{"--path", i.Path} + + args = appendStringFlag(args, "--builder", i.Builder) + args = appendStringFlag(args, "--registry", i.Registry) + args = appendStringFlag(args, "--image", i.Image) + args = appendStringFlag(args, "--namespace", i.Namespace) + args = appendStringFlag(args, "--git-url", i.GitURL) + args = appendStringFlag(args, "--git-branch", i.GitBranch) + args = appendStringFlag(args, "--git-dir", i.GitDir) + args = appendStringFlag(args, "--builder-image", i.BuilderImage) + args = appendStringFlag(args, "--domain", i.Domain) + args = appendStringFlag(args, "--platform", i.Platform) + args = appendStringFlag(args, "--build", i.Build) + args = appendStringFlag(args, "--pvc-size", i.PVCSize) + args = appendStringFlag(args, "--service-account", i.ServiceAccount) + args = appendStringFlag(args, "--remote-storage-class", i.RemoteStorageClass) + + args = appendBoolFlag(args, "--push", i.Push) + args = appendBoolFlag(args, "--registry-insecure", i.RegistryInsecure) + args = appendBoolFlag(args, "--build-timestamp", i.BuildTimestamp) + args = appendBoolFlag(args, "--remote", i.Remote) + args = appendBoolFlag(args, "--verbose", i.Verbose) + + return args +} + +// DeployOutput defines the structured output returned by the deploy tool. +type DeployOutput struct { + URL string `json:"url,omitempty" jsonschema:"The deployed Function URL"` + Image string `json:"image,omitempty" jsonschema:"The Function image name"` + Message string `json:"message" jsonschema:"Output message"` +} diff --git a/pkg/mcp/tools_deploy_test.go b/pkg/mcp/tools_deploy_test.go new file mode 100644 index 0000000000..4b8d2e83c4 --- /dev/null +++ b/pkg/mcp/tools_deploy_test.go @@ -0,0 +1,81 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "knative.dev/func/pkg/mcp/mock" +) + +// TestTool_Deploy_Args ensures the deploy tool executes with all arguments passed correctly. +func TestTool_Deploy_Args(t *testing.T) { + // Test data - defined once and used for both input and validation + stringFlags := map[string]struct { + jsonKey string + flag string + value string + }{ + "path": {"path", "--path", "."}, + "builder": {"builder", "--builder", "pack"}, + "registry": {"registry", "--registry", "ghcr.io/user"}, + "image": {"image", "--image", "ghcr.io/user/my-func:latest"}, + "namespace": {"namespace", "--namespace", "prod"}, + "gitUrl": {"gitUrl", "--git-url", "https://github.com/user/repo"}, + "gitBranch": {"gitBranch", "--git-branch", "main"}, + "gitDir": {"gitDir", "--git-dir", "functions/my-func"}, + "builderImage": {"builderImage", "--builder-image", "custom-builder:latest"}, + "domain": {"domain", "--domain", "example.com"}, + "platform": {"platform", "--platform", "linux/amd64"}, + "build": {"build", "--build", "auto"}, + "pvcSize": {"pvcSize", "--pvc-size", "5Gi"}, + "serviceAccount": {"serviceAccount", "--service-account", "func-deployer"}, + "remoteStorageClass": {"remoteStorageClass", "--remote-storage-class", "fast"}, + } + + boolFlags := map[string]string{ + "push": "--push", + "registryInsecure": "--registry-insecure", + "buildTimestamp": "--build-timestamp", + "remote": "--remote", + "verbose": "--verbose", + } + + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + if subcommand != "deploy" { + t.Fatalf("expected subcommand 'deploy', got %q", subcommand) + } + + validateArgLength(t, args, len(stringFlags), len(boolFlags)) + validateStringFlags(t, args, stringFlags) + validateBoolFlags(t, args, boolFlags) + + return []byte("Function deployed: https://my-function.example.com\n"), nil + } + + client, server, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + // Deploy requires write mode - enable it for this test + server.readonly = false + + // Build input arguments from test data + inputArgs := buildInputArgs(stringFlags, boolFlags) + + // Invoke tool with all optional arguments + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "deploy", + Arguments: inputArgs, + }) + 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") + } +} diff --git a/pkg/mcp/tools_healthcheck.go b/pkg/mcp/tools_healthcheck.go new file mode 100644 index 0000000000..907b921f8a --- /dev/null +++ b/pkg/mcp/tools_healthcheck.go @@ -0,0 +1,36 @@ +package mcp + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var healthCheckTool = &mcp.Tool{ + Name: "healthcheck", + Title: "Healthcheck", + Description: "Checks if the MCP server is running and responsive", + Annotations: &mcp.ToolAnnotations{ + Title: "Healthcheck", + ReadOnlyHint: true, + IdempotentHint: true, + }, +} + +func (s *Server) healthcheckHandler(ctx context.Context, r *mcp.CallToolRequest, input HealthcheckInput) (result *mcp.CallToolResult, output HealthcheckOutput, err error) { + output = HealthcheckOutput{ + Status: "ok", + Message: "The MCP server is running!", + } + return +} + +// HealthcheckInput defines the input parameters for the healthcheck tool. +// No parameters are required for healthcheck. +type HealthcheckInput struct{} + +// HealthcheckOutput defines the structured output returned by the healthcheck tool. +type HealthcheckOutput struct { + Status string `json:"status" jsonschema:"Status of the server (ok)"` + Message string `json:"message" jsonschema:"Healthcheck message"` +} diff --git a/pkg/mcp/tools_healthcheck_test.go b/pkg/mcp/tools_healthcheck_test.go new file mode 100644 index 0000000000..64ea4482e4 --- /dev/null +++ b/pkg/mcp/tools_healthcheck_test.go @@ -0,0 +1,66 @@ +package mcp + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// TestTool_Healthcheck verifies the healthcheck tool returns expected output +func TestTool_Healthcheck(t *testing.T) { + client, _, err := newTestPair(t) + if err != nil { + t.Fatal(err) + } + + // Invoke the healthcheck tool + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "healthcheck", + Arguments: map[string]any{}, + }) + if err != nil { + t.Fatalf("healthcheck tool call failed: %v", err) + } + + // Verify the result is not an error + if result.IsError { + t.Fatal("healthcheck returned an error result") + } + + // Verify we got content back + if len(result.Content) == 0 { + t.Fatal("healthcheck returned no content") + } + + // Verify the content contains expected fields + textContent, ok := result.Content[0].(*mcp.TextContent) + if !ok { + t.Fatal("expected TextContent in healthcheck result") + } + + // The response should be JSON with status and message + if textContent.Text == "" { + t.Fatal("healthcheck returned empty text content") + } + + // Parse the JSON output to verify structure + var output HealthcheckOutput + if err := json.Unmarshal([]byte(textContent.Text), &output); err != nil { + t.Fatalf("failed to parse healthcheck output as JSON: %v", err) + } + + // Verify expected fields + if output.Status != "ok" { + t.Errorf("expected status 'ok', got %q", output.Status) + } + + if output.Message == "" { + t.Error("expected non-empty message") + } + + if !strings.Contains(output.Message, "running") { + t.Errorf("expected message to contain 'running', got %q", output.Message) + } +} diff --git a/pkg/mcp/tools_list.go b/pkg/mcp/tools_list.go new file mode 100644 index 0000000000..4f8b0ec6ba --- /dev/null +++ b/pkg/mcp/tools_list.go @@ -0,0 +1,55 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var listTool = &mcp.Tool{ + Name: "list", + Title: "List Functions", + Description: "Lists all deployed functions in the current namespace, specified namespace, or all namespaces.", + Annotations: &mcp.ToolAnnotations{ + Title: "List Functions", + ReadOnlyHint: true, + IdempotentHint: true, // Listing functions with the same parameters multiple times returns consistent results at any point in time. + }, +} + +func (s *Server) listHandler(ctx context.Context, r *mcp.CallToolRequest, input ListInput) (result *mcp.CallToolResult, output ListOutput, err error) { + out, err := s.executor.Execute(ctx, "list", input.Args()...) + if err != nil { + err = fmt.Errorf("%w\n%s", err, string(out)) + return + } + output = ListOutput{ + Message: string(out), + } + return +} + +// ListInput defines the input parameters for the list tool. +// All fields are optional since list can work without any parameters. +type ListInput struct { + AllNamespaces *bool `json:"allNamespaces,omitempty" jsonschema:"List functions in all namespaces (overrides namespace parameter)"` + Namespace *string `json:"namespace,omitempty" jsonschema:"Kubernetes namespace to list functions in (default: current namespace)"` + Output *string `json:"output,omitempty" jsonschema:"Output format: human, plain, json, xml, or yaml"` + Verbose *bool `json:"verbose,omitempty" jsonschema:"Enable verbose logging output"` +} + +func (i ListInput) Args() []string { + args := []string{} + + args = appendBoolFlag(args, "--all-namespaces", i.AllNamespaces) + args = appendStringFlag(args, "--namespace", i.Namespace) + args = appendStringFlag(args, "--output", i.Output) + args = appendBoolFlag(args, "--verbose", i.Verbose) + return args +} + +// ListOutput defines the structured output returned by the list tool. +type ListOutput struct { + Message string `json:"message" jsonschema:"Output message"` +} diff --git a/pkg/mcp/tools_list_test.go b/pkg/mcp/tools_list_test.go new file mode 100644 index 0000000000..54caf014fe --- /dev/null +++ b/pkg/mcp/tools_list_test.go @@ -0,0 +1,63 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "knative.dev/func/pkg/mcp/mock" +) + +// TestTool_List_Args ensures the list tool executes with all arguments passed correctly. +func TestTool_List_Args(t *testing.T) { + // Test data - defined once and used for both input and validation + stringFlags := map[string]struct { + jsonKey string + flag string + value string + }{ + "namespace": {"namespace", "--namespace", "prod"}, + "output": {"output", "--output", "json"}, + } + + boolFlags := map[string]string{ + "allNamespaces": "--all-namespaces", + "verbose": "--verbose", + } + + executor := mock.NewExecutor() + executor.ExecuteFn = func(ctx context.Context, subcommand string, args ...string) ([]byte, error) { + if subcommand != "list" { + t.Fatalf("expected subcommand 'list', got %q", subcommand) + } + + validateArgLength(t, args, len(stringFlags), len(boolFlags)) + validateStringFlags(t, args, stringFlags) + validateBoolFlags(t, args, boolFlags) + + return []byte("NAME\tNAMESPACE\tRUNTIME\nmy-func\tprod\tgo\n"), nil + } + + client, _, err := newTestPair(t, WithExecutor(executor)) + if err != nil { + t.Fatal(err) + } + + // Build input arguments from test data + inputArgs := buildInputArgs(stringFlags, boolFlags) + + // Invoke tool with all optional arguments + result, err := client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "list", + Arguments: inputArgs, + }) + 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") + } +} diff --git a/pkg/mcp/tools_test.go b/pkg/mcp/tools_test.go new file mode 100644 index 0000000000..a4931ecdbf --- /dev/null +++ b/pkg/mcp/tools_test.go @@ -0,0 +1,79 @@ +package mcp + +import ( + "strings" + "testing" +) + +// validateArgLength validates that the args slice has the expected length based on +// the number of string flags (2 args each: flag + value) and bool flags (1 arg each). +func validateArgLength(t *testing.T, args []string, stringFlagsCount, boolFlagsCount int) { + t.Helper() + expected := stringFlagsCount*2 + boolFlagsCount + if len(args) != expected { + t.Fatalf("expected %d args (%d string flags * 2 + %d bool flags), got %d: %v", + expected, stringFlagsCount, boolFlagsCount, len(args), args) + } +} + +// argsToMap converts a command-line arguments slice into a map for order-independent validation. +// String flags are stored as "--flag" -> "value", boolean flags as "--flag" -> "". +func argsToMap(args []string) map[string]string { + argsMap := make(map[string]string) + for i := 0; i < len(args); { + if i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") { + // String flag: --flag value + argsMap[args[i]] = args[i+1] + i += 2 + } else { + // Boolean flag: --flag (no value) + argsMap[args[i]] = "" + i++ + } + } + return argsMap +} + +// validateStringFlags checks that all expected string flags are present with correct values. +func validateStringFlags(t *testing.T, args []string, stringFlags map[string]struct { + jsonKey string + flag string + value string +}) { + t.Helper() + argsMap := argsToMap(args) + for _, flagInfo := range stringFlags { + if val, ok := argsMap[flagInfo.flag]; !ok { + t.Fatalf("missing expected flag %q", flagInfo.flag) + } else if val != flagInfo.value { + t.Fatalf("flag %q: expected value %q, got %q", flagInfo.flag, flagInfo.value, val) + } + } +} + +// validateBoolFlags checks that all expected boolean flags are present. +func validateBoolFlags(t *testing.T, args []string, boolFlags map[string]string) { + t.Helper() + argsMap := argsToMap(args) + for _, flag := range boolFlags { + if _, ok := argsMap[flag]; !ok { + t.Fatalf("missing expected flag %q", flag) + } + } +} + +// buildInputArgs constructs the input arguments map for CallTool from test data. +func buildInputArgs(stringFlags map[string]struct { + jsonKey string + flag string + value string +}, boolFlags map[string]string) map[string]any { + inputArgs := make(map[string]any) + for _, flagInfo := range stringFlags { + inputArgs[flagInfo.jsonKey] = flagInfo.value + } + for key := range boolFlags { + inputArgs[key] = true + } + return inputArgs +} diff --git a/pkg/mock/mcp_server.go b/pkg/mock/mcp_server.go new file mode 100644 index 0000000000..f1fe6ce3c0 --- /dev/null +++ b/pkg/mock/mcp_server.go @@ -0,0 +1,21 @@ +package mock + +import ( + "context" +) + +type MCPServer struct { + StartInvoked bool + StartFn func(context.Context, bool) error +} + +func NewMCPServer() *MCPServer { + return &MCPServer{ + StartFn: func(context.Context, bool) error { return nil }, + } +} + +func (s *MCPServer) Start(ctx context.Context, writeEnabled bool) error { + s.StartInvoked = true + return s.StartFn(ctx, writeEnabled) +}