Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ftl-mcp added for Goose #4786

Merged
merged 5 commits into from
Mar 5, 2025
Merged
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
1 change: 1 addition & 0 deletions .go-arch-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ components:
ftl-proxy-pg-cmd: { in: cmd/ftl-proxy-pg/** }
ftl-raft-tester-cmd: { in: cmd/raft-tester/** }
ftl-sqlc-cmd: { in: cmd/ftl-sqlc/** }
ftl-mcp-cmd: { in: cmd/ftl-mcp/** }

go2proto: { in: cmd/go2proto/** }

Expand Down
4 changes: 4 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ builds:
main: ./cmd/ftl-sqlc
binary: ftl-sqlc
<<: *settings
- id: ftl-mcp
main: ./cmd/ftl-mcp
binary: ftl-mcp
<<: *settings
- id: ftl-language-go
main: ./go-runtime/cmd/ftl-language-go
binary: ftl-language-go
Expand Down
2 changes: 1 addition & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ build-without-frontend +tools: build-protos build-zips capture-hermit-versions

# Build all backend binaries
build-backend:
just build ftl ftl-controller ftl-runner ftl-sqlc ftl-admin ftl-cron ftl-http-ingress ftl-lease ftl-provisioner ftl-schema ftl-timeline
just build ftl ftl-controller ftl-runner ftl-sqlc ftl-admin ftl-cron ftl-http-ingress ftl-lease ftl-provisioner ftl-schema ftl-timeline ftl-mcp

# Build all backend tests
build-backend-tests:
Expand Down
58 changes: 58 additions & 0 deletions cmd/ftl-mcp/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package main

import (
"context"
"net/url"
"os"

"github.com/alecthomas/kong"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"

"github.com/block/ftl"
"github.com/block/ftl/backend/protos/xyz/block/ftl/buildengine/v1/buildenginepbconnect"
"github.com/block/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect"
"github.com/block/ftl/internal/log"
_ "github.com/block/ftl/internal/prodinit"
"github.com/block/ftl/internal/rpc"
)

var cli struct {
Version kong.VersionFlag `help:"Show version."`
LogConfig log.Config `embed:"" prefix:"log-"`
AdminEndpoint *url.URL `help:"Admin endpoint." env:"FTL_ENDPOINT" default:"http://127.0.0.1:8892"`
UpdatesEndpoint *url.URL `help:"Socket to bind to." default:"http://127.0.0.1:8900" env:"FTL_BUILD_UPDATES_ENDPOINT"`
}

func main() {
kctx := kong.Parse(&cli,
kong.Description(`FTL - Model Context Protocol Server`),
kong.UsageOnError(),
kong.Vars{"version": ftl.FormattedVersion},
)

buildEngineClient := rpc.Dial(buildenginepbconnect.NewBuildEngineServiceClient, cli.UpdatesEndpoint.String(), log.Error)
adminClient := rpc.Dial(ftlv1connect.NewAdminServiceClient, cli.AdminEndpoint.String(), log.Error)

s := server.NewMCPServer(
"FTL",
ftl.Version,
server.WithResourceCapabilities(true, true),
server.WithLogging(),
)

s.AddTool(mcp.NewTool(
"Status",
mcp.WithDescription("Get the current status of each FTL module and the current schema"),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return statusTool(ctx, buildEngineClient, adminClient)
})

// Start the server
err := server.ServeStdio(s)
kctx.FatalIfErrorf(err, "failed to start mcp")
}

func contextFromServerContext(ctx context.Context) context.Context {
return log.ContextWithLogger(ctx, log.Configure(os.Stderr, cli.LogConfig).Scope("mcp"))
}
129 changes: 129 additions & 0 deletions cmd/ftl-mcp/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package main

import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strconv"
"strings"

"github.com/mark3labs/mcp-go/mcp"

"github.com/block/ftl/backend/protos/xyz/block/ftl/buildengine/v1/buildenginepbconnect"
"github.com/block/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect"
"github.com/block/ftl/common/reflect"
"github.com/block/ftl/common/schema"
"github.com/block/ftl/common/slices"
devstate "github.com/block/ftl/internal/devstate"
)

type statusOutput struct {
Modules []devstate.ModuleState
Schema string
}

func statusTool(ctx context.Context, buildEngineClient buildenginepbconnect.BuildEngineServiceClient, adminClient ftlv1connect.AdminServiceClient) (*mcp.CallToolResult, error) {
ctx = contextFromServerContext(ctx)
result, err := devstate.WaitForDevState(ctx, buildEngineClient, adminClient)
if err != nil {
return nil, fmt.Errorf("could not get status: %w", err)
}

sch := reflect.DeepCopy(result.Schema)
for _, module := range sch.Modules {
moduleState, ok := slices.Find(result.Modules, func(m devstate.ModuleState) bool {
return m.Name == module.Name
})
if !ok {
continue
}
for _, decl := range module.Decls {
switch decl := decl.(type) {
case *schema.Topic:
c, err := commentForPath(decl.Pos, moduleState.Path)
if err != nil {
return nil, err
}
decl.Comments = append(decl.Comments, c)
case *schema.Verb:
c, err := commentForPath(decl.Pos, moduleState.Path)
if err != nil {
return nil, err
}
decl.Comments = append(decl.Comments, c)
case *schema.Config:
c, err := commentForPath(decl.Pos, moduleState.Path)
if err != nil {
return nil, err
}
decl.Comments = append(decl.Comments, c)
case *schema.Secret:
c, err := commentForPath(decl.Pos, moduleState.Path)
if err != nil {
return nil, err
}
decl.Comments = append(decl.Comments, c)
case *schema.Database:
c, err := commentForPath(decl.Pos, moduleState.Path)
if err != nil {
return nil, err
}
decl.Comments = append(decl.Comments, c)
case *schema.Data:
c, err := commentForPath(decl.Pos, moduleState.Path)
if err != nil {
return nil, err
}
decl.Comments = append(decl.Comments, c)
case *schema.Enum:
c, err := commentForPath(decl.Pos, moduleState.Path)
if err != nil {
return nil, err
}
decl.Comments = append(decl.Comments, c)
case *schema.TypeAlias:
c, err := commentForPath(decl.Pos, moduleState.Path)
if err != nil {
return nil, err
}
decl.Comments = append(decl.Comments, c)
}
}
}

output := statusOutput{
Modules: result.Modules,
Schema: sch.String(),
}
data, err := json.Marshal(output)
if err != nil {
return nil, fmt.Errorf("could not marshal status: %w", err)
}
return mcp.NewToolResultText(string(data)), nil
}

func commentForPath(pos schema.Position, modulePath string) (string, error) {
if pos.Filename == "" {
return "", nil
}
// each position has a prefix of "ftl/modulename". We want to replace that with the module file path
parts := strings.SplitN(pos.Filename, string(filepath.Separator), 3)
if len(parts) > 2 {
parts = parts[1:]
parts[0] = modulePath
} else {
return "", fmt.Errorf("unexpected path format: %s", pos.Filename)
}
components := []string{
filepath.Join(parts...),
}

if pos.Line != 0 {
components = append(components, strconv.Itoa(pos.Line))
if pos.Column != 0 {
components = append(components, strconv.Itoa(pos.Column))
}
}
return "Code at " + strings.Join(components, ":"), nil
}
Loading
Loading