Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ require (
github.com/jstemmer/go-junit-report/v2 v2.1.0
github.com/karrick/godirwalk v1.17.0
github.com/manifoldco/promptui v0.9.0
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/peterebden/go-cli-init/v5 v5.2.1
github.com/peterebden/go-deferred-regex v1.1.0
github.com/peterebden/go-sri v1.1.1
Expand Down Expand Up @@ -77,6 +78,7 @@ require (
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/golang/glog v1.2.5 // indirect
github.com/google/go-containerregistry v0.21.7 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.18 // indirect
github.com/googleapis/gax-go/v2 v2.23.0 // indirect
Expand All @@ -93,10 +95,13 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/shoenig/go-m1cpu v0.2.2 // indirect
github.com/sigstore/protobuf-specs v0.5.1 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
Expand Down
12 changes: 12 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
Expand Down Expand Up @@ -109,6 +111,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU=
github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
Expand Down Expand Up @@ -164,6 +168,8 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
Expand Down Expand Up @@ -206,6 +212,10 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs=
github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.2.2 h1:4nc55oVv7nygGnfI9bhLCLzUEs4794y0Bkqx4q2zy7Y=
Expand Down Expand Up @@ -243,6 +253,8 @@ github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqo
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
Expand Down
1 change: 1 addition & 0 deletions src/BUILD.plz
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ go_binary(
"//src/generate",
"//src/hashes",
"//src/help",
"//src/mcp",
"//src/metrics",
"//src/output",
"//src/plz",
Expand Down
32 changes: 32 additions & 0 deletions src/mcp/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
go_library(
name = "mcp",
srcs = [
"mcp.go",
"tools.go",
],
pgo_file = "//:pgo",
visibility = ["PUBLIC"],
deps = [
"///third_party/go/github.com_modelcontextprotocol_go-sdk//mcp",
"//src/cli/logging",
"//src/core",
"//src/parse",
"//src/plz",
"//src/query",
"//src/version",
],
)

go_test(
name = "mcp_test",
srcs = ["mcp_test.go"],
external = True,
deps = [
":mcp",
"///third_party/go/github.com_modelcontextprotocol_go-sdk//mcp",
"///third_party/go/github.com_stretchr_testify//assert",
"///third_party/go/github.com_stretchr_testify//require",
"//src/core",
"//src/fs",
],
)
205 changes: 205 additions & 0 deletions src/mcp/mcp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// Package mcp implements a Model Context Protocol server that exposes plz query
// functionality over stdio. The build graph is parsed once at startup and kept
// in memory between queries, so clients don't pay the graph construction cost
// on every query as they would invoking plz directly.
package mcp

import (
"context"
"fmt"
"io"
"os"
"strings"
"sync"

sdk "github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/thought-machine/please/src/cli/logging"
"github.com/thought-machine/please/src/core"
"github.com/thought-machine/please/src/parse"
"github.com/thought-machine/please/src/plz"
"github.com/thought-machine/please/src/version"
)

var log = logging.Log

// A Server holds the cached build state that queries are served from.
type Server struct {
// mu serialises queries and reloads; reloads swap out the state, and some
// queries (filter) temporarily mutate it.
mu sync.Mutex
state *core.BuildState
graph *core.BuildGraph
config *core.Configuration

transport sdk.Transport
}

// Option provides a mechanism to set options on the MCP server.
type Option func(*Server)

// WithTransport overrides the transport that the server receives requests on and
// sends responses over. The default is stdio. Tests can pass one half of
// sdk.NewInMemoryTransports() and drive the server with a real MCP client.
func WithTransport(t sdk.Transport) Option {
return func(s *Server) {
s.transport = t
}
}

// WithState supplies a pre-parsed build state to serve queries from, in place of
// parsing the build graph at startup.
func WithState(state *core.BuildState) Option {
return func(s *Server) {
s.state = state
}
}

// NewServer instantiates a new MCP server.
func NewServer(config *core.Configuration, opts ...Option) *Server {
s := &Server{
config: config,
graph: core.NewGraph(),
}

for _, opt := range opts {
opt(s)
}

return s
}

// Serve runs an MCP server until the client disconnects or ctx is cancelled.
// By default, the server uses lazy loading to parse the build graph on demand.
func (s *Server) Serve(ctx context.Context) error {
if s.transport == nil {
s.transport = stdioTransport()
}
if s.state == nil {
log.Notice("Serving MCP with lazy-loaded build graph...")
} else {
log.Notice("Serving MCP for %d targets", len(s.state.Graph.AllTargets()))
}

srv := sdk.NewServer(&sdk.Implementation{
Name: "please",
Title: "Please build system",
Version: version.PleaseVersion,
}, nil)
s.registerTools(srv)
return srv.Run(ctx, s.transport)
}

// stdioTransport returns a transport communicating over stdin and stdout.
// The MCP protocol runs over stdout, so anything else that writes there would
// corrupt the framing. Point os.Stdout at stderr for the life of the process
// and hand the real stdout to the transport; queries capture os.Stdout per-call.
// Stdout is wrapped so that the transport doesn't close it when the session ends.
func stdioTransport() sdk.Transport {
out := os.Stdout
os.Stdout = os.Stderr
return &sdk.IOTransport{Reader: os.Stdin, Writer: nopCloserWriter{out}}
}

// nopCloserWriter is an io.WriteCloser with a trivial Close method.
type nopCloserWriter struct {
io.Writer
}

func (nopCloserWriter) Close() error { return nil }

// parseGraph parses the entire build graph into a fresh build state.
// On success the new state replaces the current one; on failure the old state is kept.
// Callers must hold s.mu (except before the server has started).
func (s *Server) parseGraph() error {
state := core.NewBuildState(s.config)
state.NeedBuild = false
parse.InitParser(state)
plz.RunHost(core.WholeGraph, state)
if failed, _, _ := state.Failures(); failed {
return fmt.Errorf("failed to parse the build graph; see server logs for details")
}
s.state = state
return nil
}

// withState runs f against a build state under the server lock, converting panics
// into errors so a misbehaving query can't kill the server.
// Under lazy-loading, it parses the given targets on demand into the persistent graph.
func (s *Server) withState(targets []string, f func(state *core.BuildState) error) (err error) {
s.mu.Lock()
defer s.mu.Unlock()
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("query failed: %v", p)
}
}()

if s.state != nil {
return f(s.state)
}

state := core.NewBuildState(s.config)
state.NeedBuild = false
state.Graph = s.graph

if len(targets) == 0 {
return f(state)
}

labels := make([]core.BuildLabel, 0, len(targets))
for _, t := range targets {
l, err := core.TryParseBuildLabel(t, "", "")
if err != nil {
return fmt.Errorf("invalid build label %q: %w", t, err)
}
labels = append(labels, l)
}
plz.RunHost(labels, state)
if failed, _, _ := state.Failures(); failed {
var errs []string
for r := range state.Results() {
if r.Status.IsFailure() {
errs = append(errs, fmt.Sprintf("%s (%s): %s", r.Label, r.Status, r.Err))

Check failure on line 163 in src/mcp/mcp.go

View workflow job for this annotation

GitHub Actions / lint

printf: fmt.Sprintf format %s has arg r.Status of wrong type github.com/thought-machine/please/src/core.BuildResultStatus (govet)
}
}
return fmt.Errorf("failed to parse the build graph: %s", strings.Join(errs, "; "))
}

return f(state)
}

// resolveLabels parses a set of label strings, expands pseudo-targets (:all and /...)
// against the graph and verifies that every resulting target exists.
// Verification matters: the query functions call TargetOrDie on the labels they're
// given, which would kill the server on an unknown target.
func resolveLabels(state *core.BuildState, in []string) ([]core.BuildLabel, error) {
labels := make([]core.BuildLabel, 0, len(in))
for _, l := range in {
label, err := core.TryParseBuildLabel(l, "", "")
if err != nil {
return nil, fmt.Errorf("invalid build label %q: %w", l, err)
}
labels = append(labels, label)
}
expanded := state.ExpandLabels(labels)
for _, l := range expanded {
if state.Graph.Target(l) == nil {
return nil, fmt.Errorf("target %s not found in the build graph", l)
}
}
if len(expanded) == 0 {
return nil, fmt.Errorf("no targets matched the given labels")
}
return expanded, nil
}

// textResult wraps a string as an MCP tool result.
func textResult(text string) *sdk.CallToolResult {
if text == "" {
text = "(no output)"
}
return &sdk.CallToolResult{
Content: []sdk.Content{&sdk.TextContent{Text: text}},
}
}
Loading
Loading