From d89eb2f75c2ce046fb26e42064def0af0cdeda2a Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 6 Mar 2025 09:31:14 +1100 Subject: [PATCH 1/5] begin mcp --- .goreleaser.yaml | 4 + Justfile | 2 +- cmd/ftl-mcp/main.go | 59 +++++ cmd/ftl-mcp/status.go | 128 +++++++++++ cmd/ftl/cmd_await_summary.go | 225 -------------------- cmd/ftl/cmd_goose.go | 2 +- cmd/ftl/goose_instructions_first_prompt.txt | 4 +- cmd/ftl/main.go | 5 +- go.mod | 1 + go.sum | 2 + internal/devstate/devstate.go | 125 +++++++++++ scripts/ftl-mcp | 21 ++ 12 files changed, 346 insertions(+), 232 deletions(-) create mode 100644 cmd/ftl-mcp/main.go create mode 100644 cmd/ftl-mcp/status.go delete mode 100644 cmd/ftl/cmd_await_summary.go create mode 100644 internal/devstate/devstate.go create mode 100755 scripts/ftl-mcp diff --git a/.goreleaser.yaml b/.goreleaser.yaml index f6a3c98b30..af3fc57f5e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -18,6 +18,10 @@ builds: main: ./cmd/ftl-sqlc binary: ftl-sqlc <<: *settings + - id: ftl-language-mcp + main: ./cmd/ftl-mcp + binary: ftl-mcp + <<: *settings - id: ftl-language-go main: ./go-runtime/cmd/ftl-language-go binary: ftl-language-go diff --git a/Justfile b/Justfile index 1635de3e88..0c1e5f7452 100644 --- a/Justfile +++ b/Justfile @@ -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: diff --git a/cmd/ftl-mcp/main.go b/cmd/ftl-mcp/main.go new file mode 100644 index 0000000000..1839030d3a --- /dev/null +++ b/cmd/ftl-mcp/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "net/url" + "os" + + "github.com/block/ftl/internal/rpc" + + "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" +) + +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, request, 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")) +} diff --git a/cmd/ftl-mcp/status.go b/cmd/ftl-mcp/status.go new file mode 100644 index 0000000000..6d0f87ce86 --- /dev/null +++ b/cmd/ftl-mcp/status.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strconv" + "strings" + + "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" + "github.com/mark3labs/mcp-go/mcp" +) + +type statusOutput struct { + Modules []devstate.ModuleState + Schema string +} + +func statusTool(ctx context.Context, request mcp.CallToolRequest, 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 +} diff --git a/cmd/ftl/cmd_await_summary.go b/cmd/ftl/cmd_await_summary.go deleted file mode 100644 index c9fd1a2189..0000000000 --- a/cmd/ftl/cmd_await_summary.go +++ /dev/null @@ -1,225 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "path/filepath" - "strconv" - "strings" - "time" - - "connectrpc.com/connect" - "github.com/alecthomas/types/optional" - - buildenginepb "github.com/block/ftl/backend/protos/xyz/block/ftl/buildengine/v1" - "github.com/block/ftl/backend/protos/xyz/block/ftl/buildengine/v1/buildenginepbconnect" - languagepb "github.com/block/ftl/backend/protos/xyz/block/ftl/language/v1" - ftlv1 "github.com/block/ftl/backend/protos/xyz/block/ftl/v1" - "github.com/block/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect" - "github.com/block/ftl/common/schema" - "github.com/block/ftl/common/slices" -) - -// awaitSummaryCmd waits for the engine to finish and prints a summary of the current module (paths and errors) and schema. -// It is useful for goose as it accommodates delays in detecting code changes, building and deploying. -type awaitSummaryCmd struct { -} - -func (c *awaitSummaryCmd) Run(ctx context.Context, buildEngineClient buildenginepbconnect.BuildEngineServiceClient, schemaClient ftlv1connect.AdminServiceClient) error { - stream, err := buildEngineClient.StreamEngineEvents(ctx, connect.NewRequest(&buildenginepb.StreamEngineEventsRequest{ - ReplayHistory: true, - })) - if err != nil { - return fmt.Errorf("failed to stream engine events: %w", err) - } - - start := time.Now() - idleDuration := 1500 * time.Millisecond - engineEnded := optional.Some(&buildenginepb.EngineEnded{}) - - streamChan := make(chan *buildenginepb.EngineEvent) - errChan := make(chan error) - go func() { - for { - if !stream.Receive() { - close(streamChan) - errChan <- stream.Err() - return - } - streamChan <- stream.Msg().Event - } - }() -streamLoop: - for { - // We want to wait for code changes in the module to be detected and for builds to start. - // So we wait for the engine to be idle after idleDuration. - var idleDeadline <-chan time.Time - if engineEnded.Ok() { - idleDeadline = time.After(idleDuration - time.Since(start)) - } - select { - case <-idleDeadline: - break streamLoop - case <-ctx.Done(): - return fmt.Errorf("did not complete build engine update stream: %w", ctx.Err()) - case event, ok := <-streamChan: - if !ok { - err = <-errChan - if errors.Is(err, context.Canceled) { - return nil - } - return fmt.Errorf("failed to stream engine events: %w", err) - } - - switch event := event.Event.(type) { - case *buildenginepb.EngineEvent_EngineStarted: - engineEnded = optional.None[*buildenginepb.EngineEnded]() - case *buildenginepb.EngineEvent_EngineEnded: - engineEnded = optional.Some(event.EngineEnded) - - default: - } - } - } - - engineEndedEvent, ok := engineEnded.Get() - if !ok { - return errors.New("engine did not end") - } - - schemaResp, err := schemaClient.GetSchema(ctx, connect.NewRequest(&ftlv1.GetSchemaRequest{})) - if err != nil { - return fmt.Errorf("failed to get schema: %w", err) - } - sch, err := schema.FromProto(schemaResp.Msg.Schema) - if err != nil { - return fmt.Errorf("failed to parse schema: %w", err) - } - - fmt.Printf("Module Overview:\n") - if len(engineEndedEvent.Modules) == 0 { - fmt.Println("No modules found.") - return nil - } - modulesPaths := map[string]string{} - for _, module := range engineEndedEvent.Modules { - modulesPaths[module.Module] = module.Path - fmt.Printf("%s (%s)\n", module.Module, module.Path) - if module.Errors == nil || len(module.Errors.Errors) == 0 { - fmt.Println(" Success with no warnings.") - continue - } - fmt.Print(strings.Join(slices.Map(module.Errors.Errors, func(e *languagepb.Error) string { - var errorType string - switch e.Level { - case languagepb.Error_ERROR_LEVEL_ERROR: - errorType = "Error" - case languagepb.Error_ERROR_LEVEL_WARN: - errorType = "Warn" - case languagepb.Error_ERROR_LEVEL_INFO: - errorType = "Info" - default: - panic(fmt.Sprintf("unknown error type: %v", e.Level)) - } - var posStr string - if e.Pos != nil { - posStr = fmt.Sprintf("%s:%d", e.Pos.Filename, e.Pos.StartColumn) - if e.Pos.EndColumn != e.Pos.StartColumn { - posStr += fmt.Sprintf(":%d", e.Pos.EndColumn) - } - posStr += ": " - } - errorMsg := strings.ReplaceAll(e.Msg, "\n", "\n ") - return fmt.Sprintf(" [%s] %s%s", errorType, posStr, errorMsg) - }), "\n"), "\n") - } - for _, module := range sch.Modules { - modulePath, ok := modulesPaths[module.Name] - if !ok { - continue - } - // TODO: add to decl interface to avoid this duplicated code - for _, decl := range module.Decls { - switch decl := decl.(type) { - case *schema.Topic: - c, err := commentForPath(decl.Pos, modulePath) - if err != nil { - return err - } - decl.Comments = append(decl.Comments, c) - case *schema.Verb: - c, err := commentForPath(decl.Pos, modulePath) - if err != nil { - return err - } - decl.Comments = append(decl.Comments, c) - case *schema.Config: - c, err := commentForPath(decl.Pos, modulePath) - if err != nil { - return err - } - decl.Comments = append(decl.Comments, c) - case *schema.Secret: - c, err := commentForPath(decl.Pos, modulePath) - if err != nil { - return err - } - decl.Comments = append(decl.Comments, c) - case *schema.Database: - c, err := commentForPath(decl.Pos, modulePath) - if err != nil { - return err - } - decl.Comments = append(decl.Comments, c) - case *schema.Data: - c, err := commentForPath(decl.Pos, modulePath) - if err != nil { - return err - } - decl.Comments = append(decl.Comments, c) - case *schema.Enum: - c, err := commentForPath(decl.Pos, modulePath) - if err != nil { - return err - } - decl.Comments = append(decl.Comments, c) - case *schema.TypeAlias: - c, err := commentForPath(decl.Pos, modulePath) - if err != nil { - return err - } - decl.Comments = append(decl.Comments, c) - } - } - } - fmt.Printf("\n\nSchema:%s\n", sch.String()) - // TODO: remove hack to ensure whole schema is printed - time.Sleep(100 * time.Millisecond) - return 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 -} diff --git a/cmd/ftl/cmd_goose.go b/cmd/ftl/cmd_goose.go index 82c3d4f357..a14d1a63d6 100644 --- a/cmd/ftl/cmd_goose.go +++ b/cmd/ftl/cmd_goose.go @@ -71,7 +71,7 @@ func (c *gooseCmd) Run(ctx context.Context) error { // Second command includes final instructions and the user's input data = gooseFirstPromptInstructions + data } - args = append(args, "--resume", "--text", data) + args = append(args, "--resume", "--with-extension", "ftl-mcp", "--text", data) cmd := exec.Command(ctx, log.Debug, ".", "goose", args...) out := &output{} diff --git a/cmd/ftl/goose_instructions_first_prompt.txt b/cmd/ftl/goose_instructions_first_prompt.txt index 06a818c112..18115e076e 100644 --- a/cmd/ftl/goose_instructions_first_prompt.txt +++ b/cmd/ftl/goose_instructions_first_prompt.txt @@ -29,7 +29,7 @@ When creating a database module, do so in this order: - Wait for the module to build to see the schema and if there any errors. - You MUST next read queries.ftl.go in the module directory to understand the go types to pass along to the generated db verbs - Write exported verbs and request/response types to expose the module's functionality but do not call those verbs from other modules yet. -- You MUST Check that the module builds successfully by calling `ftl await-summary` before continuing to any other needed changes. +- You MUST Check that the module builds successfully by getting FTL status from the FTL extension before continuing to any other needed changes. - It is IMPORTANT that you make sure the new module builds without errors before you proceed to change other modules. # Additional Documentation @@ -75,7 +75,7 @@ Kotlin Modules You can interact with the FTL cluster using the FTL command, run 'ftl --help' for more details. Do not call `ftl dev` or `ftl serve`. You are running while `ftl dev` is also running which will automatically build and deploy modules when their code changes (no need to call `ftl build` or `ftl deploy`). -Always call `ftl await-summary` after making any code changes, which will wait for all build and deploys to finish and then return a list of modules including the path to the module code, and if there were any errors or warnings. +Always ask for FTL status from the FTL extension after making any code changes, which will wait for all build and deploys to finish and then return a list of modules including the path to the module code, and if there were any errors or warnings. It will then print out the current schema of all modules that are currently deployed (if there are errors for a module then a previous version of the code may be included in the schema). Be proactive about calling this command, it is your job to know the state of the FTL system. diff --git a/cmd/ftl/main.go b/cmd/ftl/main.go index 7cc5257e7b..079f6f8e7e 100644 --- a/cmd/ftl/main.go +++ b/cmd/ftl/main.go @@ -87,9 +87,8 @@ type CLI struct { // Specify the 1Password vault to access secrets from. Vault string `name:"opvault" help:"1Password vault to be used for secrets. The name of the 1Password item will be the and the secret will be stored in the password field." placeholder:"VAULT"` - AwaitSummary awaitSummaryCmd `cmd:"" help:"Get summary of discovered modules and schema from build engine, waiting for all build and deploys to finish." hidden:""` - DumpHelp dumpHelpCmd `cmd:"" help:"Dump help for all commands." hidden:""` - LSP lspCmd `cmd:"" help:"Start the LSP server."` + DumpHelp dumpHelpCmd `cmd:"" help:"Dump help for all commands." hidden:""` + LSP lspCmd `cmd:"" help:"Start the LSP server."` } type DevModeCLI struct { diff --git a/go.mod b/go.mod index 763df6d201..2db2aedabd 100644 --- a/go.mod +++ b/go.mod @@ -168,6 +168,7 @@ require ( github.com/lni/vfs v0.2.1-0.20220616104132-8852fd867376 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mark3labs/mcp-go v0.10.3 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/miekg/dns v1.1.26 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect diff --git a/go.sum b/go.sum index 56ded67081..3c54033e9a 100644 --- a/go.sum +++ b/go.sum @@ -471,6 +471,8 @@ github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-go v0.10.3 h1:bBrvCCyzYXtOhkrxElbpXXsDFiTs+zvfzVnNZU1nfr4= +github.com/mark3labs/mcp-go v0.10.3/go.mod h1:cjMlBU0cv/cj9kjlgmRhoJ5JREdS7YX83xeIG9Ko/jE= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= diff --git a/internal/devstate/devstate.go b/internal/devstate/devstate.go new file mode 100644 index 0000000000..fde1e04ecf --- /dev/null +++ b/internal/devstate/devstate.go @@ -0,0 +1,125 @@ +package devstate + +import ( + "context" + "errors" + "fmt" + "time" + + "connectrpc.com/connect" + "github.com/alecthomas/types/optional" + + buildenginepb "github.com/block/ftl/backend/protos/xyz/block/ftl/buildengine/v1" + "github.com/block/ftl/backend/protos/xyz/block/ftl/buildengine/v1/buildenginepbconnect" + languagepb "github.com/block/ftl/backend/protos/xyz/block/ftl/language/v1" + ftlv1 "github.com/block/ftl/backend/protos/xyz/block/ftl/v1" + "github.com/block/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect" + "github.com/block/ftl/common/schema" + "github.com/block/ftl/common/slices" +) + +type DevState struct { + Modules []ModuleState + Schema *schema.Schema +} + +type ModuleState struct { + Name string + Path string + Errors []*languagepb.Error +} + +// WaitForDevState waits for the engine to finish and prints a summary of the current module (paths and errors) and schema. +// It is useful for synchronizing with FTL dev as it accommodates delays in detecting code changes, building and deploying. +func WaitForDevState(ctx context.Context, buildEngineClient buildenginepbconnect.BuildEngineServiceClient, schemaClient ftlv1connect.AdminServiceClient) (DevState, error) { + stream, err := buildEngineClient.StreamEngineEvents(ctx, connect.NewRequest(&buildenginepb.StreamEngineEventsRequest{ + ReplayHistory: true, + })) + if err != nil { + return DevState{}, fmt.Errorf("failed to stream engine events: %w", err) + } + + start := time.Now() + idleDuration := 1500 * time.Millisecond + engineEnded := optional.Some(&buildenginepb.EngineEnded{}) + + streamChan := make(chan *buildenginepb.EngineEvent) + errChan := make(chan error) + go func() { + for { + if !stream.Receive() { + close(streamChan) + errChan <- stream.Err() + return + } + streamChan <- stream.Msg().Event + } + }() +streamLoop: + for { + // We want to wait for code changes in the module to be detected and for builds to start. + // So we wait for the engine to be idle after idleDuration. + var idleDeadline <-chan time.Time + if engineEnded.Ok() { + idleDeadline = time.After(idleDuration - time.Since(start)) + } + select { + case <-idleDeadline: + break streamLoop + case <-ctx.Done(): + return DevState{}, fmt.Errorf("did not complete build engine update stream: %w", ctx.Err()) + case event, ok := <-streamChan: + if !ok { + err = <-errChan + if errors.Is(err, context.Canceled) { + return DevState{}, ctx.Err() // nolint:wrapcheck + } + return DevState{}, fmt.Errorf("failed to stream engine events: %w", err) + } + + switch event := event.Event.(type) { + case *buildenginepb.EngineEvent_EngineStarted: + engineEnded = optional.None[*buildenginepb.EngineEnded]() + case *buildenginepb.EngineEvent_EngineEnded: + engineEnded = optional.Some(event.EngineEnded) + + default: + } + } + } + + engineEndedEvent, ok := engineEnded.Get() + if !ok { + return DevState{}, errors.New("engine did not end") + } + + schemaResp, err := schemaClient.GetSchema(ctx, connect.NewRequest(&ftlv1.GetSchemaRequest{})) + if err != nil { + return DevState{}, fmt.Errorf("failed to get schema: %w", err) + } + sch, err := schema.FromProto(schemaResp.Msg.Schema) + if err != nil { + return DevState{}, fmt.Errorf("failed to parse schema: %w", err) + } + modulesPaths := map[string]string{} + for _, module := range engineEndedEvent.Modules { + modulesPaths[module.Module] = module.Path + } + if err != nil { + return DevState{}, fmt.Errorf("failed to visit schema: %w", err) + } + out := DevState{} + out.Modules = slices.Map(engineEndedEvent.Modules, func(m *buildenginepb.EngineEnded_Module) ModuleState { + var errs []*languagepb.Error + if m.Errors != nil { + errs = m.Errors.Errors + } + return ModuleState{ + Name: m.Module, + Path: m.Path, + Errors: errs, + } + }) + out.Schema = sch + return out, nil +} diff --git a/scripts/ftl-mcp b/scripts/ftl-mcp new file mode 100755 index 0000000000..1a98925ca9 --- /dev/null +++ b/scripts/ftl-mcp @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail + +# We only want to build this for the current platform +# This may get invoked as part of a build for a different platform +unset GOARCH +unset GOOS + +FTL_DIR="$(dirname "$(readlink -f "$0")")/.." +export FTL_DIR + +if [ ! "${HERMIT_ENV}" -ef "${FTL_DIR}" ]; then + # shellcheck disable=SC1091 + . "${FTL_DIR}/bin/activate-hermit" +fi + +name="$(basename "$0")" +dest="${FTL_DIR}/build/devel" +src="./cmd/${name}" +mkdir -p "$dest" +"${FTL_DIR}/bin/go" build -C "${FTL_DIR}/${src}" -ldflags="-s -w -buildid=" -o "$dest/${name}" . && exec "$dest/${name}" "$@" From 56337c0a218d56a19fd4045a19ae70fd65e856e7 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 6 Mar 2025 09:46:16 +1100 Subject: [PATCH 2/5] move integration test away from old cli command --- .go-arch-lint.yml | 1 + .goreleaser.yaml | 2 +- internal/integration/actions.go | 26 ++++++++++++++++++++------ internal/integration/harness.go | 22 ++++++++++++++++------ 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/.go-arch-lint.yml b/.go-arch-lint.yml index 4025631b76..31bce17a3a 100644 --- a/.go-arch-lint.yml +++ b/.go-arch-lint.yml @@ -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/** } diff --git a/.goreleaser.yaml b/.goreleaser.yaml index af3fc57f5e..642fa3f194 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -18,7 +18,7 @@ builds: main: ./cmd/ftl-sqlc binary: ftl-sqlc <<: *settings - - id: ftl-language-mcp + - id: ftl-mcp main: ./cmd/ftl-mcp binary: ftl-mcp <<: *settings diff --git a/internal/integration/actions.go b/internal/integration/actions.go index e176029a79..56852a51ac 100644 --- a/internal/integration/actions.go +++ b/internal/integration/actions.go @@ -27,10 +27,12 @@ import ( "github.com/block/scaffolder" + languagepb "github.com/block/ftl/backend/protos/xyz/block/ftl/language/v1" timelinepb "github.com/block/ftl/backend/protos/xyz/block/ftl/timeline/v1" ftlv1 "github.com/block/ftl/backend/protos/xyz/block/ftl/v1" schemapb "github.com/block/ftl/common/protos/xyz/block/ftl/schema/v1" "github.com/block/ftl/common/schema" + "github.com/block/ftl/internal/devstate" "github.com/block/ftl/internal/dsn" ftlexec "github.com/block/ftl/internal/exec" "github.com/block/ftl/internal/log" @@ -314,13 +316,25 @@ func WaitWithTimeout(module string, timeout time.Duration) Action { func WaitForDev(noErrors bool, msgAndArgs ...any) Action { return func(t testing.TB, ic TestContext) { - ExecWithOutput("ftl", []string{"await-summary"}, func(output string) { - if noErrors { - assert.NotContains(t, output, "[Error]", msgAndArgs...) - } else { - assert.Contains(t, output, "[Error]", msgAndArgs...) + if noErrors { + Infof("Waiting for FTL Dev state with no errors") + } else { + Infof("Waiting for FTL Dev state with errors") + } + result, err := devstate.WaitForDevState(ic.Context, ic.BuildEngine, ic.Admin) + assert.NoError(t, err) + + var errs []*languagepb.Error + for _, m := range result.Modules { + if m.Errors != nil { + errs = append(errs, m.Errors...) } - })(t, ic) + } + if noErrors { + assert.Zero(t, errs, msgAndArgs...) + } else { + assert.NotZero(t, errs, msgAndArgs...) + } } } diff --git a/internal/integration/harness.go b/internal/integration/harness.go index 85053c397a..d90e972341 100644 --- a/internal/integration/harness.go +++ b/internal/integration/harness.go @@ -7,7 +7,6 @@ import ( "context" "errors" "fmt" - "github.com/jpillora/backoff" "io" "os" "path/filepath" @@ -19,6 +18,8 @@ import ( "testing" "time" + "github.com/jpillora/backoff" + "github.com/IBM/sarama" "github.com/alecthomas/assert/v2" "github.com/alecthomas/types/optional" @@ -30,6 +31,7 @@ import ( "sigs.k8s.io/yaml" + "github.com/block/ftl/backend/protos/xyz/block/ftl/buildengine/v1/buildenginepbconnect" "github.com/block/ftl/backend/protos/xyz/block/ftl/console/v1/consolepbconnect" "github.com/block/ftl/backend/protos/xyz/block/ftl/timeline/v1/timelinepbconnect" "github.com/block/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect" @@ -378,6 +380,13 @@ func run(t *testing.T, actionsOrOptions ...ActionOrOption) { assert.NoError(t, rpc.Wait(ctx, backoff.Backoff{Max: time.Millisecond * 50}, time.Minute*2, ic.Timeline)) } + if opts.devMode { + ic.BuildEngine = rpc.Dial(buildenginepbconnect.NewBuildEngineServiceClient, "http://localhost:8900", log.Debug) + + Infof("Waiting for build engine client to be ready") + assert.NoError(t, rpc.Wait(ctx, backoff.Backoff{Max: time.Millisecond * 50}, time.Minute*2, ic.Timeline)) + } + if opts.resetPubSub { Infof("Resetting pubsub") envars := []string{"COMPOSE_IGNORE_ORPHANS=True"} @@ -491,11 +500,12 @@ type TestContext struct { kubeNamespace string devMode bool - Admin ftlv1connect.AdminServiceClient - Schema ftlv1connect.SchemaServiceClient - Console consolepbconnect.ConsoleServiceClient - Verbs ftlv1connect.VerbServiceClient - Timeline timelinepbconnect.TimelineServiceClient + Admin ftlv1connect.AdminServiceClient + Schema ftlv1connect.SchemaServiceClient + Console consolepbconnect.ConsoleServiceClient + Verbs ftlv1connect.VerbServiceClient + Timeline timelinepbconnect.TimelineServiceClient + BuildEngine buildenginepbconnect.BuildEngineServiceClient realT *testing.T } From ae9f77d2f3db8d62b7eac991f4e93bc978d9582f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 5 Mar 2025 22:51:57 +0000 Subject: [PATCH 3/5] chore(autofmt): Automated formatting --- cmd/ftl-mcp/main.go | 3 +-- cmd/ftl-mcp/status.go | 3 ++- go.mod | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/ftl-mcp/main.go b/cmd/ftl-mcp/main.go index 1839030d3a..0195caa4b0 100644 --- a/cmd/ftl-mcp/main.go +++ b/cmd/ftl-mcp/main.go @@ -5,8 +5,6 @@ import ( "net/url" "os" - "github.com/block/ftl/internal/rpc" - "github.com/alecthomas/kong" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -16,6 +14,7 @@ import ( "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 { diff --git a/cmd/ftl-mcp/status.go b/cmd/ftl-mcp/status.go index 6d0f87ce86..889baa92c5 100644 --- a/cmd/ftl-mcp/status.go +++ b/cmd/ftl-mcp/status.go @@ -8,13 +8,14 @@ import ( "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" - "github.com/mark3labs/mcp-go/mcp" ) type statusOutput struct { diff --git a/go.mod b/go.mod index 2db2aedabd..fd988209b7 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,7 @@ require ( github.com/jpillora/backoff v1.0.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/lni/dragonboat/v4 v4.0.0-20240618143154-6a1623140f27 + github.com/mark3labs/mcp-go v0.10.3 github.com/mattn/go-isatty v0.0.20 github.com/multiformats/go-base36 v0.2.0 github.com/opencontainers/go-digest v1.0.0 @@ -168,7 +169,6 @@ require ( github.com/lni/vfs v0.2.1-0.20220616104132-8852fd867376 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mark3labs/mcp-go v0.10.3 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/miekg/dns v1.1.26 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect From c3a9704e1a6fab52823ff872d5c7aab02ed3735b Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 6 Mar 2025 10:08:20 +1100 Subject: [PATCH 4/5] use builderrors not the proto version --- internal/devstate/devstate.go | 12 +++--------- internal/integration/actions.go | 4 ++-- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/internal/devstate/devstate.go b/internal/devstate/devstate.go index fde1e04ecf..67c5f08764 100644 --- a/internal/devstate/devstate.go +++ b/internal/devstate/devstate.go @@ -14,6 +14,7 @@ import ( languagepb "github.com/block/ftl/backend/protos/xyz/block/ftl/language/v1" ftlv1 "github.com/block/ftl/backend/protos/xyz/block/ftl/v1" "github.com/block/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect" + "github.com/block/ftl/common/builderrors" "github.com/block/ftl/common/schema" "github.com/block/ftl/common/slices" ) @@ -26,7 +27,7 @@ type DevState struct { type ModuleState struct { Name string Path string - Errors []*languagepb.Error + Errors []builderrors.Error } // WaitForDevState waits for the engine to finish and prints a summary of the current module (paths and errors) and schema. @@ -105,19 +106,12 @@ streamLoop: for _, module := range engineEndedEvent.Modules { modulesPaths[module.Module] = module.Path } - if err != nil { - return DevState{}, fmt.Errorf("failed to visit schema: %w", err) - } out := DevState{} out.Modules = slices.Map(engineEndedEvent.Modules, func(m *buildenginepb.EngineEnded_Module) ModuleState { - var errs []*languagepb.Error - if m.Errors != nil { - errs = m.Errors.Errors - } return ModuleState{ Name: m.Module, Path: m.Path, - Errors: errs, + Errors: languagepb.ErrorsFromProto(m.Errors), } }) out.Schema = sch diff --git a/internal/integration/actions.go b/internal/integration/actions.go index 56852a51ac..83f816985d 100644 --- a/internal/integration/actions.go +++ b/internal/integration/actions.go @@ -27,9 +27,9 @@ import ( "github.com/block/scaffolder" - languagepb "github.com/block/ftl/backend/protos/xyz/block/ftl/language/v1" timelinepb "github.com/block/ftl/backend/protos/xyz/block/ftl/timeline/v1" ftlv1 "github.com/block/ftl/backend/protos/xyz/block/ftl/v1" + "github.com/block/ftl/common/builderrors" schemapb "github.com/block/ftl/common/protos/xyz/block/ftl/schema/v1" "github.com/block/ftl/common/schema" "github.com/block/ftl/internal/devstate" @@ -324,7 +324,7 @@ func WaitForDev(noErrors bool, msgAndArgs ...any) Action { result, err := devstate.WaitForDevState(ic.Context, ic.BuildEngine, ic.Admin) assert.NoError(t, err) - var errs []*languagepb.Error + var errs []builderrors.Error for _, m := range result.Modules { if m.Errors != nil { errs = append(errs, m.Errors...) From 9ce2ac156893ab6fdd838021fd2d9e3388fb57dc Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 6 Mar 2025 10:12:41 +1100 Subject: [PATCH 5/5] lint --- cmd/ftl-mcp/main.go | 2 +- cmd/ftl-mcp/status.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/ftl-mcp/main.go b/cmd/ftl-mcp/main.go index 0195caa4b0..4039ae7165 100644 --- a/cmd/ftl-mcp/main.go +++ b/cmd/ftl-mcp/main.go @@ -45,7 +45,7 @@ func main() { "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, request, buildEngineClient, adminClient) + return statusTool(ctx, buildEngineClient, adminClient) }) // Start the server diff --git a/cmd/ftl-mcp/status.go b/cmd/ftl-mcp/status.go index 889baa92c5..f5f3127899 100644 --- a/cmd/ftl-mcp/status.go +++ b/cmd/ftl-mcp/status.go @@ -23,7 +23,7 @@ type statusOutput struct { Schema string } -func statusTool(ctx context.Context, request mcp.CallToolRequest, buildEngineClient buildenginepbconnect.BuildEngineServiceClient, adminClient ftlv1connect.AdminServiceClient) (*mcp.CallToolResult, error) { +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 {