Skip to content

feat: Add MCP server#3577

Draft
kichristensen wants to merge 2 commits intogetporter:mainfrom
kichristensen:mcpServer
Draft

feat: Add MCP server#3577
kichristensen wants to merge 2 commits intogetporter:mainfrom
kichristensen:mcpServer

Conversation

@kichristensen
Copy link
Contributor

What does this change

Adds a porter mcp subcommand that starts an MCP (Model Context Protocol) server over stdio, allowing AI coding assistants (Claude Code, Cursor, VS Code Copilot, etc.) to interact with Porter using natural language.

Read-only tools (always available):

  • explain_bundle — show bundle parameters, credentials, outputs, and actions
  • list_installations / show_installation — browse installations
  • list_runs / get_logs — inspect execution history and logs
  • list_outputs / get_output — read run outputs (sensitive values masked)
  • list_credentials / show_credential — browse credential sets
  • list_parameters / show_parameter — browse parameter sets
  • analyze_failure — one-shot failure triage: fetches the last (or a specific) failed run's logs and outputs in a single call

Write tools (opt-in via --allow-write):

  • install_bundle, upgrade_bundle, uninstall_bundle, invoke_bundle

Example usage with Claude Code:

$ porter mcp
# or, to enable write tools:
$ porter mcp --allow-write

Configure in Claude Code:

claude mcp add porter -- porter mcp

What issue does it fix

Closes # (no existing issue)

This capability was identified as a natural fit for Porter: LLM agents can already reason about CNAB bundles conceptually, and exposing Porter's CLI operations as MCP tools lets them act on real installations with full context (logs, outputs, failure analysis) rather than just generating shell commands.

Notes for the reviewer

  • The MCP server uses github.com/modelcontextprotocol/go-sdk (the official Go SDK, v1.4.1).
  • Porter's plugin system starts subprocesses via exec.CommandContext. The MCP SDK cancels per-request contexts after each tool call, which would kill plugin subprocesses between calls. This is fixed by storing the server-lifetime context in MCPServer.ctx and using it for all Porter API calls.
  • porter.Out defaults to os.Stdout, which would corrupt the stdio JSON-RPC stream. It is redirected to os.Stderr in NewMCPServer, with a captureOutput helper that temporarily swaps to a buffer when write-tool output should be returned to the client.

Checklist

  • Did you write tests?
  • Did you write documentation?
  • Did you change porter.yaml or a storage document record? Update the corresponding schema file.
  • If this is your first pull request, please add your name to the bottom of our Contributors list. Thank you for making Porter better! 🙇‍♀️

Signed-off-by: Kim Christensen <[email protected]>
License lives at repo root; subpackages have no own LICENSE
files, causing go-licenses to fail. Introduced transitively
via github.com/modelcontextprotocol/go-sdk.

Signed-off-by: Kim Christensen <[email protected]>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new porter mcp CLI subcommand that runs a Model Context Protocol (MCP) server over stdio, exposing Porter read-only (and optionally write) operations as MCP tools for AI clients.

Changes:

  • Introduces a new pkg/mcp package implementing the MCP server and tool handlers (runs/logs, outputs, credentials, parameters, bundle ops, failure analysis).
  • Wires the new porter mcp Cobra command into the CLI and adds documentation pages describing usage and client configuration.
  • Adds the MCP Go SDK dependency and updates license-check workflow ignores.

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
pkg/mcp/server.go Core MCP server wrapper, stdio transport, output capture/masking helpers.
pkg/mcp/tools_bundle.go Registers bundle + installation tools; adds opt-in write tools with output capture.
pkg/mcp/tools_runs.go Adds run listing and log retrieval tools.
pkg/mcp/tools_outputs.go Adds output listing/get tools, including masking/sensitive blocking.
pkg/mcp/tools_creds.go Adds credential set list/show tools.
pkg/mcp/tools_params.go Adds parameter set list/show tools.
pkg/mcp/tools_analyze.go Adds analyze_failure tool aggregating run/log/output context.
cmd/porter/mcp.go Implements the porter mcp subcommand and --allow-write flag.
cmd/porter/main.go Registers the new mcp subcommand on the root command.
docs/content/docs/references/cli/porter.md Adds porter mcp to the CLI reference index.
docs/content/docs/references/cli/mcp.md Adds CLI reference page for porter mcp.
docs/content/docs/how-to-guides/work-with-ai-agents.md Adds a how-to guide for configuring MCP clients with Porter.
docs/content/docs/how-to-guides/_index.md Adds “Working with AI Agents” to the how-to guide cards.
go.mod Adds github.com/modelcontextprotocol/go-sdk v1.4.1 dependency.
go.sum Updates checksums for new dependencies.
.github/workflows/check-licenses.yaml Updates ignored modules list for license checking.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +39 to +51
func (s *MCPServer) listRuns(_ context.Context, req *sdkmcp.CallToolRequest) (*sdkmcp.CallToolResult, error) {
var args struct {
Installation string `json:"installation"`
Namespace string `json:"namespace"`
}
if err := unmarshalArgs(req, &args); err != nil {
return toolErr(err), nil
}

opts := porter.RunListOptions{}
opts.Name = args.Installation
opts.Namespace = args.Namespace

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list_runs relies on the JSON schema to require "installation", but the handler doesn't enforce it. If arguments are omitted/empty, opts.Name stays empty and the call can return unexpected results. Add a server-side check that installation is non-empty and return a tool error when missing.

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +58
func (s *MCPServer) listOutputs(_ context.Context, req *sdkmcp.CallToolRequest) (*sdkmcp.CallToolResult, error) {
var args struct {
Installation string `json:"installation"`
Namespace string `json:"namespace"`
RunID string `json:"run_id"`
}
if err := unmarshalArgs(req, &args); err != nil {
return toolErr(err), nil
}

opts := &porter.OutputListOptions{}
opts.Name = args.Installation
opts.Namespace = args.Namespace
opts.RunID = args.RunID

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list_outputs requires "installation" in the schema, but the handler doesn't validate it. When installation is empty and run_id is also empty, Porter falls back to GetLastOutputs/GetLastRun using an empty installation name, which is likely an error or confusing no-op. Add an explicit check that installation is provided (unless you intend to support run_id-only here).

Copilot uses AI. Check for mistakes.
Comment on lines +66 to +83
func (s *MCPServer) getOutput(_ context.Context, req *sdkmcp.CallToolRequest) (*sdkmcp.CallToolResult, error) {
var args struct {
Installation string `json:"installation"`
OutputName string `json:"output_name"`
Namespace string `json:"namespace"`
}
if err := unmarshalArgs(req, &args); err != nil {
return toolErr(err), nil
}

// Check sensitivity via list before returning the raw value.
opts := &porter.OutputListOptions{}
opts.Name = args.Installation
opts.Namespace = args.Namespace
outputs, err := s.porter.ListBundleOutputs(s.ctx, opts)
if err != nil {
return toolErr(err), nil
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_output checks sensitivity by calling ListBundleOutputs with only installation/namespace (no run_id). That means it always checks the latest run, and also doesn't handle the case where "installation" is missing despite being required by the schema. Consider validating installation is non-empty, and (optionally) accept/propagate a run_id so sensitivity is checked against the same run whose value is being read.

Copilot uses AI. Check for mistakes.
// Fetch logs.
logsOpts := &porter.LogsShowOptions{}
logsOpts.RunID = targetRun.ID
logs, _, logsErr := s.porter.GetInstallationLogs(s.ctx, logsOpts)
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

analyze_failure fetches logs via GetInstallationLogs but ignores the returned ok flag. When ok is false (no logs found) and err is nil, this currently reports no error and returns an empty logs string, unlike get_logs which returns an explicit error. Handle !ok by adding a "no logs found" error (or reuse errNoLogs) so the result is accurate.

Suggested change
logs, _, logsErr := s.porter.GetInstallationLogs(s.ctx, logsOpts)
logs, ok, logsErr := s.porter.GetInstallationLogs(s.ctx, logsOpts)
if !ok && logsErr == nil {
logsErr = errors.New("no logs found")
}

Copilot uses AI. Check for mistakes.
results, err := s.porter.Installations.ListResults(s.ctx, args.RunID)
if err == nil && len(results) > 0 {
targetRun.Status = results[len(results)-1].Status
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When run_id is provided, analyze_failure ignores the required "installation"/"namespace" inputs and analyzes the run solely by ID. This is inconsistent with the tool schema/description and can allow fetching logs/outputs for a run that doesn't belong to the specified installation. Either (a) make installation optional when run_id is supplied, or (b) verify the fetched run's Namespace/Installation match args before returning results.

Suggested change
}
}
// Ensure the run belongs to the requested installation/namespace (when provided).
if args.Installation != "" && args.Installation != targetRun.Name {
return toolErr(fmt.Errorf("run %s does not belong to installation %q", args.RunID, args.Installation)), nil
}
if args.Namespace != "" && args.Namespace != targetRun.Namespace {
return toolErr(fmt.Errorf("run %s does not belong to namespace %q", args.RunID, args.Namespace)), nil
}

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +22
Configure your MCP client (e.g. Claude Desktop) with:
{"command": "porter", "args": ["mcp"]}


Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSON configuration example under "Configure your MCP client" isn't in a fenced code block, so it may render as normal text instead of a copy/paste snippet. Consider wrapping it in a ```json fenced block (or indenting 4 spaces) for consistent rendering with other CLI reference pages.

Copilot uses AI. Check for mistakes.
run: |
go-licenses check ./... \
--ignore github.com/xi2/xz,golang.org/x/sys/unix # https://github.com/xi2/xz/blob/master/LICENSE
--ignore github.com/xi2/xz,golang.org/x/sys/unix,github.com/segmentio/asm # https://github.com/xi2/xz/blob/master/LICENSE
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline comment on the --ignore list only references the xi2/xz license link, but the ignore list now also includes github.com/segmentio/asm. Update the comment to document why segmentio/asm is ignored (or split comments per entry) so future license checks remain auditable.

Suggested change
--ignore github.com/xi2/xz,golang.org/x/sys/unix,github.com/segmentio/asm # https://github.com/xi2/xz/blob/master/LICENSE
--ignore github.com/xi2/xz,golang.org/x/sys/unix,github.com/segmentio/asm # github.com/xi2/xz license: https://github.com/xi2/xz/blob/master/LICENSE; github.com/segmentio/asm license (MIT): https://github.com/segmentio/asm/blob/master/LICENSE

Copilot uses AI. Check for mistakes.
opts.Name = args.Installation
opts.RunID = args.RunID
opts.Namespace = args.Namespace

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_logs accepts both installation and run_id (or neither) without validation. This diverges from porter.LogsShowOptions.Validate (pkg/porter/logs.go:24-39), and can yield confusing behavior (RunID silently wins) or an empty installation lookup. Consider enforcing: exactly one of installation/run_id must be set, otherwise return a tool error with a clear message.

Suggested change
if err := opts.Validate(); err != nil {
return toolErr(err), nil
}

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants