Add flow mcp command for Cadence MCP server#2306
Conversation
Dependency ReviewThe following issues were found:
Snapshot WarningsEnsure that dependencies are being submitted on PR branches and consider enabling retry-on-snapshot-warnings. See the documentation for more information and troubleshooting advice. License Issuesgo.mod
OpenSSF Scorecard
Scanned Files
|
|
This pull request introduces dependencies with security vulnerabilities of moderate severity or higher. Vulnerable Dependencies:📦 github.com/buger/jsonparser@1.1.1 What to do next?
Security Engineering contact: #security on slack |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
| @@ -0,0 +1,203 @@ | |||
| /* | |||
There was a problem hiding this comment.
Adding auditing functionality is a good idea, it would be nice to have this usable outside of MCP.
Could we maybe move this into a new analyzer in the linter (https://github.com/onflow/cadence-tools/tree/master/lint), or a new tool in https://github.com/onflow/cadence-tools?
The rules should probably also not be regular expression-based, but instead be AST based. Some of these rules are also duplicates of existing linter analyzers (e.g. deprecated pre-1.0 code) and Cadence type checking diagnostics.
| } | ||
| } | ||
|
|
||
| func TestIntegration_GetContractSource(t *testing.T) { |
There was a problem hiding this comment.
Here and for the other tests in this PR: Maybe parallelize the tests with t.Parallel()
| } | ||
|
|
||
| // symbolKindName returns a human-readable name for a SymbolKind. | ||
| func symbolKindName(kind protocol.SymbolKind) string { |
There was a problem hiding this comment.
Maybe we can move this to github.com/onflow/cadence-tools/languageserver/protocol and use stringer?
There was a problem hiding this comment.
Pull request overview
This PR introduces a new flow mcp CLI subcommand that runs an MCP (Model Context Protocol) server over stdio, exposing Cadence LSP capabilities plus on-chain query and script execution tools for AI-assisted Cadence development.
Changes:
- Add
flow mcpCobra command that starts an MCP server over stdio. - Implement MCP tool handlers for Cadence LSP actions, contract queries, script execution, and regex-based code review.
- Add tests for LSP wrapper, tool handlers, code review rules, and network-backed integration flows; add
mcp-godependency.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
cmd/flow/main.go |
Wires the new mcp command into the root CLI. |
internal/mcp/mcp.go |
Implements flow mcp command startup and Flow network gateway resolution. |
internal/mcp/tools.go |
Defines MCP tool schemas and handler implementations (LSP + on-chain + review). |
internal/mcp/lsp.go |
Adds an in-process Cadence language server wrapper and formatting helpers. |
internal/mcp/audit.go |
Adds regex-based Cadence “code review” rule engine + formatting. |
internal/mcp/*_test.go |
Adds unit/integration coverage for the new MCP functionality. |
go.mod / go.sum |
Adds github.com/mark3labs/mcp-go and updates indirect deps. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func (c *diagConn) captureDiagnostics(diags []protocol.Diagnostic) { | ||
| c.mu.Lock() | ||
| defer c.mu.Unlock() | ||
| c.diagnostics = append(c.diagnostics, diags...) | ||
| } |
There was a problem hiding this comment.
diagConn.captureDiagnostics appends diagnostics, which can duplicate results if the server publishes diagnostics multiple times for the same document update. Consider replacing (not appending) the stored diagnostics per publish event, and (optionally) filtering by scratchURI to avoid capturing diagnostics for unrelated documents.
| // Try to load flow.json for custom network configs | ||
| loader := &afero.Afero{Fs: afero.NewOsFs()} | ||
| state, _ := flowkit.Load(config.DefaultPaths(), loader) | ||
|
|
There was a problem hiding this comment.
flowkit.Load errors are discarded here. If flow.json exists but is invalid/corrupt, MCP will silently fall back to defaults, which is hard to debug. Consider checking the returned error and printing a warning (except for config.ErrDoesNotExist).
| addr := flow.HexToAddress(address) | ||
| account, err := gw.GetAccount(ctx, addr) | ||
| if err != nil { | ||
| return mcplib.NewToolResultError(fmt.Sprintf("failed to get account: %v", err)), nil | ||
| } |
There was a problem hiding this comment.
flow.HexToAddress does not return an error; invalid input can silently become flow.EmptyAddress. To avoid querying the wrong account, validate the parsed address (e.g., check addr == flow.EmptyAddress) and return a tool error for invalid address values.
| addr := flow.HexToAddress(address) | ||
| account, err := gw.GetAccount(ctx, addr) | ||
| if err != nil { | ||
| return mcplib.NewToolResultError(fmt.Sprintf("failed to get account: %v", err)), nil | ||
| } |
There was a problem hiding this comment.
Same address-parsing issue as above: invalid address input can silently map to flow.EmptyAddress. Add validation after flow.HexToAddress and return a clear error if the address is invalid.
| match := rule.pattern.FindStringSubmatch(line) | ||
| if match != nil { | ||
| findings = append(findings, Finding{ | ||
| Rule: rule.id, | ||
| Severity: rule.severity, | ||
| Line: lineNum, | ||
| Message: rule.message(match), | ||
| }) | ||
| } |
There was a problem hiding this comment.
The review rules use FindStringSubmatch, so each rule can only emit at most one finding per line. This can miss multiple occurrences (e.g. multiple force-unwraps on one line). If you want complete findings, iterate matches with FindAllStringSubmatchIndex/FindAllStringSubmatch and emit one finding per match.
| // Check sends code to the LSP and returns any diagnostics. | ||
| func (w *LSPWrapper) Check(code, network string) ([]protocol.Diagnostic, error) { | ||
| w.mu.Lock() | ||
| defer w.mu.Unlock() | ||
|
|
||
| w.conn.reset() |
There was a problem hiding this comment.
The network parameter is accepted by Check/Hover/Definition/Symbols/Completion but never used in this wrapper, so the MCP tool network argument is currently ignored for all LSP-backed tools. Either wire network into the cadence-tools flow integration/address resolution (if supported) or remove the network argument from the LSP wrapper/tool schemas to avoid misleading behavior.
| net, err := resolveNetwork(state, network) | ||
| if err != nil { | ||
| return nil, err | ||
| } |
There was a problem hiding this comment.
createGateway always uses gateway.NewGrpcGateway and ignores network.Key. Elsewhere in the CLI, networks with a configured key use gateway.NewSecureGrpcGateway (see internal/command/command.go:createGateway). For consistency and to support custom networks, mirror that logic here (or reuse the existing helper).
| } | |
| } | |
| // Mirror CLI behavior: use a secure gateway when the network has a configured key. | |
| if net.Key != "" { | |
| return gateway.NewSecureGrpcGateway(*net, net.Key) | |
| } |
| network := req.GetString("network", "mainnet") | ||
|
|
||
| gw, err := createGateway(m.state, network) | ||
| if err != nil { | ||
| return mcplib.NewToolResultError(fmt.Sprintf("failed to create gateway: %v", err)), nil | ||
| } |
There was a problem hiding this comment.
Each tool invocation creates a new gRPC gateway (createGateway), which in a long-running MCP server can lead to repeated client/connection setup overhead. Consider caching/reusing gateways per network in mcpContext (and closing them on shutdown if the implementation requires it).
| // resolveCode returns Cadence source from either the "code" or "file" parameter. | ||
| // If "file" is provided, it reads the file contents. "code" takes precedence. | ||
| func resolveCode(req mcplib.CallToolRequest) (string, error) { | ||
| code := req.GetString("code", "") | ||
| if code != "" { | ||
| return code, nil | ||
| } | ||
| file := req.GetString("file", "") | ||
| if file == "" { | ||
| return "", fmt.Errorf("either 'code' or 'file' parameter is required") | ||
| } | ||
| data, err := os.ReadFile(file) | ||
| if err != nil { | ||
| return "", fmt.Errorf("reading file %q: %w", file, err) | ||
| } | ||
| return string(data), nil |
There was a problem hiding this comment.
In an MCP server context, accepting an arbitrary file path and reading it (os.ReadFile) allows the MCP client to request any readable file on the user's machine (e.g. SSH keys, env files). Consider restricting file to the current workspace (or requiring an explicit allowlist / --allow-file-access flag) to reduce accidental secret exfiltration via AI tools.
Summary
Adds a
flow mcpcommand that starts an MCP (Model Context Protocol) server over stdio for Cadence smart contract development. This enables AI coding tools like Claude Code, Cursor, and Claude Desktop to interact with the Cadence language server and Flow network directly.Inspired by cadence-lang.org PR #285, but implemented natively in Go with no extra runtime dependencies — if you have
flowinstalled, you have the MCP server.Tools (9 total)
LSP tools (in-process
cadence-tools/languageserver):cadence_check— Check code for syntax/type errorscadence_hover— Get type info at a positioncadence_definition— Find symbol definitionscadence_symbols— List all symbols in codecadence_completion— Get completions at a positionOn-chain query tools:
get_contract_source— Fetch contract manifest from an addressget_contract_code— Fetch contract source codecadence_execute_script— Execute read-only scriptsCode review:
cadence_code_review— Pattern-based review for common issuesUsage
Or in Cursor / Claude Desktop settings:
{ "mcpServers": { "cadence-mcp": { "command": "flow", "args": ["mcp"] } } }Design
server.Serverfromcadence-tools/languageserver, using a single virtual document URI as a scratch bufferflow.jsonif present, otherwise falls back to default mainnet/testnet/emulator endpointsflow.json— useful for ad-hoc queriesTest plan