From 12a70360f6e935a6b29d595c26dd7a0ec00a9984 Mon Sep 17 00:00:00 2001 From: Peter Argue <89119817+peterargue@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:15:10 -0700 Subject: [PATCH 01/14] Add design spec for flow mcp command --- .../2026-03-25-flow-mcp-server-design.md | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-25-flow-mcp-server-design.md diff --git a/docs/superpowers/specs/2026-03-25-flow-mcp-server-design.md b/docs/superpowers/specs/2026-03-25-flow-mcp-server-design.md new file mode 100644 index 000000000..38dd49512 --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-flow-mcp-server-design.md @@ -0,0 +1,214 @@ +# Flow MCP Server Design + +## Overview + +Add a `flow mcp` command to flow-cli that starts a Model Context Protocol (MCP) +server over stdio for Cadence smart contract development. The server exposes 9 +tools across two categories: LSP tools (in-process language server) and audit +tools (on-chain queries + static analysis). + +This replaces the need for a separate TypeScript MCP server (see +[cadence-lang.org PR #285](https://github.com/onflow/cadence-lang.org/pull/285)) +by integrating directly into the Go CLI with no extra runtime dependencies. + +## Tools + +### LSP Tools (5) + +These wrap the in-process `cadence-tools/languageserver` Server. + +| Tool | Description | Parameters | +|---|---|---| +| `cadence_check` | Check Cadence code for syntax and type errors | `code`, `filename?`, `network?` | +| `cadence_hover` | Get type info and docs for a symbol at a position | `code`, `line`, `character`, `filename?`, `network?` | +| `cadence_definition` | Find definition location of a symbol | `code`, `line`, `character`, `filename?`, `network?` | +| `cadence_symbols` | List all symbols (contracts, resources, functions, events) | `code`, `filename?`, `network?` | +| `cadence_completion` | Get completions at a position | `code`, `line`, `character`, `filename?`, `network?` | + +All LSP tools accept an optional `network` parameter (mainnet/testnet/emulator, +default mainnet) for resolving on-chain imports. + +### Audit Tools (4) + +These use flowkit gRPC gateways for on-chain data and pure Go for static analysis. + +| Tool | Description | Parameters | +|---|---|---| +| `get_contract_source` | Fetch on-chain contract manifest (names, sizes, imports, dependency graph) | `address`, `network?`, `recurse?` | +| `get_contract_code` | Fetch source code of contracts from an address | `address`, `contract_name?`, `network?` | +| `cadence_code_review` | Static security analysis of Cadence code | `code`, `network?` | +| `cadence_execute_script` | Execute a read-only Cadence script on-chain | `code`, `network?`, `args?` | + +## Architecture + +``` +flow mcp (stdio) + | + v ++-- MCP Server (mcp-go) ------------------------------------+ +| | +| LSP Tools --> LSPWrapper --> server.Server (cadence-tools) | +| (doc lifecycle (in-process) | +| management) | +| | +| Audit Tools --> flowkit gateway --> Flow network | +| | ++------------------------------------------------------------+ +``` + +### Package Structure + +``` +internal/mcp/ + mcp.go - Cobra command + MCP server setup, tool registration + lsp.go - LSP wrapper: server.Server lifecycle, diagnostic capture + audit.go - Security scan rules (cadence_code_review) + tools.go - Tool handler implementations (all 9 tools) +``` + +Registered in `cmd/flow/main.go` alongside other top-level commands. + +## Command + +```go +var Cmd = &cobra.Command{ + Use: "mcp", + Short: "Start the Cadence MCP server", +} +``` + +Uses `Run` (not `RunS`) so it works without a `flow.json`. If `flow.json` is +found, its network configurations are used (allowing custom host overrides). +Otherwise, hardcoded defaults are used for mainnet/testnet/emulator. + +The `--help` output includes installation instructions for Claude Code, Cursor, +and Claude Desktop, plus a summary of available tools. + +## LSP Wrapper + +### In-Process Server + +The wrapper manages a `server.Server` instance from `cadence-tools/languageserver`. + +```go +type LSPWrapper struct { + server *server.Server + mu sync.Mutex +} +``` + +Created at startup with: +1. `server.NewServer()` to create the LSP server +2. `integration.NewFlowIntegration(server, true)` to enable on-chain import resolution + +### Document Lifecycle + +Each tool call follows this pattern: +1. Use a fixed URI per network (e.g., `file:///mcp/mainnet.cdc`) +2. First call: `DidOpenTextDocument` to register the document +3. Subsequent calls: `DidChangeTextDocument` to update content +4. Call the LSP method (`Hover`, `Completion`, etc.) + +This avoids document accumulation since we reuse the same URI. + +### Diagnostic Capture + +`DidOpenTextDocument` and `DidChangeTextDocument` trigger type checking, which +pushes diagnostics via `conn.Notify("textDocument/publishDiagnostics", ...)`. + +A thin `protocol.Conn` adapter captures these: + +```go +type diagConn struct { + diagnostics []protocol.Diagnostic +} + +func (c *diagConn) Notify(_ context.Context, method string, params any) error { + if method == "textDocument/publishDiagnostics" { + // extract and store diagnostics + } + return nil +} +``` + +The `cadence_check` tool returns these captured diagnostics. Other tools +(hover, completion, etc.) ignore them. + +### Serialization + +All LSP operations are serialized via `sync.Mutex`. The LSP server is +single-threaded by design — concurrent document updates would corrupt state. + +## Network Configuration + +### flow.json Detection + +At startup: +1. Attempt `flowkit.Load()` to find and load `flow.json` +2. If found, use its network configurations (custom hosts, accounts, aliases) +3. If not found, proceed with defaults — the server still works + +### Gateway Creation + +```go +func (m *MCPServer) gatewayForNetwork(network string) (gateway.Gateway, error) { + if m.state != nil { + net, err := m.state.Networks().ByName(network) + if err == nil { + return gateway.NewGrpcGateway(net) + } + } + return gateway.NewGrpcGateway(defaultNetworks[network]) +} +``` + +Default network addresses: +- mainnet: `access.mainnet.nodes.onflow.org:9000` +- testnet: `access.devnet.nodes.onflow.org:9000` +- emulator: `127.0.0.1:3569` + +## cadence_code_review Rules + +Ported from the TypeScript PR's regex-based static analysis: + +| Rule | Severity | Pattern | +|---|---|---| +| overly-permissive-access | high | `access(all) var/let` on state fields | +| overly-permissive-function | medium | `access(all) fun` | +| deprecated-pub | info | `pub` keyword (deprecated in Cadence 1.0) | +| unsafe-force-unwrap | medium | Force-unwrap `!` | +| auth-account-exposure | high | `AuthAccount` or `auth(...) &Account` | +| hardcoded-address | low | Hardcoded `0x...` not in imports | +| unguarded-capability | high | `.publish(` calls | +| potential-reentrancy | medium | `.borrow` followed by `self.` mutation | +| resource-loss-destroy | high | `destroy()` calls | + +When the LSP is available, `cadence_code_review` also runs a full type check +and merges those diagnostics into the output. + +## Help Text + +`flow mcp --help` includes: + +- What the server does +- Installation for Claude Code: `claude mcp add cadence-mcp -- flow mcp` +- Configuration for Cursor / Claude Desktop (JSON snippet) +- List of all available tools with descriptions + +## Testing + +- **LSP wrapper:** Unit tests with real `server.Server` — check Cadence snippets + for expected diagnostics, hover info, completions +- **cadence_code_review:** Unit tests against known-vulnerable and clean Cadence + snippets, verify expected findings +- **Network tools:** Integration tests behind `SKIP_NETWORK_TESTS` for + `get_contract_source`, `get_contract_code`, `cadence_execute_script` +- **MCP server:** End-to-end tool call tests with mock inputs + +All tests in `internal/mcp/*_test.go`. + +## Dependencies + +- `github.com/mark3labs/mcp-go` — Go MCP SDK (stdio transport, tool registration) +- `github.com/onflow/cadence-tools/languageserver` — already in go.mod +- `github.com/onflow/flowkit/v2` — already in go.mod From 1ba847c255699af9f78cbdc6fbe7c7c2c2da5820 Mon Sep 17 00:00:00 2001 From: Peter Argue <89119817+peterargue@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:19:23 -0700 Subject: [PATCH 02/14] Soften code review language in MCP design spec --- .../2026-03-25-flow-mcp-server-design.md | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/specs/2026-03-25-flow-mcp-server-design.md b/docs/superpowers/specs/2026-03-25-flow-mcp-server-design.md index 38dd49512..e68989fb6 100644 --- a/docs/superpowers/specs/2026-03-25-flow-mcp-server-design.md +++ b/docs/superpowers/specs/2026-03-25-flow-mcp-server-design.md @@ -4,8 +4,8 @@ Add a `flow mcp` command to flow-cli that starts a Model Context Protocol (MCP) server over stdio for Cadence smart contract development. The server exposes 9 -tools across two categories: LSP tools (in-process language server) and audit -tools (on-chain queries + static analysis). +tools across two categories: LSP tools (in-process language server) and +on-chain query / code review tools. This replaces the need for a separate TypeScript MCP server (see [cadence-lang.org PR #285](https://github.com/onflow/cadence-lang.org/pull/285)) @@ -30,13 +30,13 @@ default mainnet) for resolving on-chain imports. ### Audit Tools (4) -These use flowkit gRPC gateways for on-chain data and pure Go for static analysis. +These use flowkit gRPC gateways for on-chain data and pattern matching for code review. | Tool | Description | Parameters | |---|---|---| | `get_contract_source` | Fetch on-chain contract manifest (names, sizes, imports, dependency graph) | `address`, `network?`, `recurse?` | | `get_contract_code` | Fetch source code of contracts from an address | `address`, `contract_name?`, `network?` | -| `cadence_code_review` | Static security analysis of Cadence code | `code`, `network?` | +| `cadence_code_review` | Review Cadence code for common issues and best practices | `code`, `network?` | | `cadence_execute_script` | Execute a read-only Cadence script on-chain | `code`, `network?`, `args?` | ## Architecture @@ -62,7 +62,7 @@ flow mcp (stdio) internal/mcp/ mcp.go - Cobra command + MCP server setup, tool registration lsp.go - LSP wrapper: server.Server lifecycle, diagnostic capture - audit.go - Security scan rules (cadence_code_review) + audit.go - Code review rules (cadence_code_review) tools.go - Tool handler implementations (all 9 tools) ``` @@ -169,19 +169,21 @@ Default network addresses: ## cadence_code_review Rules -Ported from the TypeScript PR's regex-based static analysis: +Regex-based pattern matching for common Cadence issues and best practices. +These are heuristic checks — not a substitute for a proper security audit. +Ported from the TypeScript PR: | Rule | Severity | Pattern | |---|---|---| -| overly-permissive-access | high | `access(all) var/let` on state fields | -| overly-permissive-function | medium | `access(all) fun` | +| overly-permissive-access | warning | `access(all) var/let` on state fields | +| overly-permissive-function | note | `access(all) fun` | | deprecated-pub | info | `pub` keyword (deprecated in Cadence 1.0) | -| unsafe-force-unwrap | medium | Force-unwrap `!` | -| auth-account-exposure | high | `AuthAccount` or `auth(...) &Account` | -| hardcoded-address | low | Hardcoded `0x...` not in imports | -| unguarded-capability | high | `.publish(` calls | -| potential-reentrancy | medium | `.borrow` followed by `self.` mutation | -| resource-loss-destroy | high | `destroy()` calls | +| unsafe-force-unwrap | note | Force-unwrap `!` | +| auth-account-exposure | warning | `AuthAccount` or `auth(...) &Account` | +| hardcoded-address | info | Hardcoded `0x...` not in imports | +| unguarded-capability | warning | `.publish(` calls | +| potential-reentrancy | note | `.borrow` followed by `self.` mutation | +| resource-loss-destroy | warning | `destroy()` calls | When the LSP is available, `cadence_code_review` also runs a full type check and merges those diagnostics into the output. From 69257af41bc99c9c33639dd7f06d3af1cf6c733a Mon Sep 17 00:00:00 2001 From: Peter Argue <89119817+peterargue@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:27:37 -0700 Subject: [PATCH 03/14] Clarify LSP document lifecycle in MCP design spec --- .../2026-03-25-flow-mcp-server-design.md | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-03-25-flow-mcp-server-design.md b/docs/superpowers/specs/2026-03-25-flow-mcp-server-design.md index e68989fb6..4c2c50eef 100644 --- a/docs/superpowers/specs/2026-03-25-flow-mcp-server-design.md +++ b/docs/superpowers/specs/2026-03-25-flow-mcp-server-design.md @@ -103,13 +103,21 @@ Created at startup with: ### Document Lifecycle -Each tool call follows this pattern: -1. Use a fixed URI per network (e.g., `file:///mcp/mainnet.cdc`) -2. First call: `DidOpenTextDocument` to register the document -3. Subsequent calls: `DidChangeTextDocument` to update content -4. Call the LSP method (`Hover`, `Completion`, etc.) +The LSP server stores documents in an in-memory map (`s.documents`), not on +disk. The `file:///` URI is purely virtual — no actual files are created. -This avoids document accumulation since we reuse the same URI. +Since the LSP server has no `DidCloseTextDocument` handler, opened documents +stay in the map forever. To avoid unbounded accumulation, we reuse a single +virtual URI (`file:///mcp/scratch.cdc`) as a scratch buffer: + +1. First call: `DidOpenTextDocument` with the virtual URI and the code string +2. Every subsequent call: `DidChangeTextDocument` to replace the content +3. The LSP runs the type checker on the updated content +4. Call the LSP method (`Hover`, `Completion`, etc.) and return the result + +Each MCP tool call is independent — it overwrites the scratch buffer with its +code, queries the LSP, and returns. Calls are serialized by the mutex so there +is no contention over the single URI. ### Diagnostic Capture From 3477f02a9aa51747c8941ceb1933a94e6dd65ce2 Mon Sep 17 00:00:00 2001 From: Peter Argue <89119817+peterargue@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:46:05 -0700 Subject: [PATCH 04/14] Add implementation plan for flow mcp command --- .../plans/2026-03-25-flow-mcp-server.md | 1734 +++++++++++++++++ 1 file changed, 1734 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-25-flow-mcp-server.md diff --git a/docs/superpowers/plans/2026-03-25-flow-mcp-server.md b/docs/superpowers/plans/2026-03-25-flow-mcp-server.md new file mode 100644 index 000000000..30b0aa827 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-flow-mcp-server.md @@ -0,0 +1,1734 @@ +# Flow MCP Server Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `flow mcp` command that starts an MCP server over stdio, exposing 9 tools for Cadence development (LSP + on-chain query + code review). + +**Architecture:** In-process LSP via `cadence-tools/languageserver`, on-chain queries via `flowkit` gRPC gateways, code review via regex rules. All wrapped in an MCP server using `mcp-go` with stdio transport. + +**Tech Stack:** Go, mcp-go, cadence-tools/languageserver, flowkit/v2 + +**Spec:** `docs/superpowers/specs/2026-03-25-flow-mcp-server-design.md` + +--- + +## File Structure + +``` +internal/mcp/ + mcp.go - Cobra command, MCP server creation, tool registration + mcp_test.go - End-to-end MCP tool call tests + lsp.go - LSPWrapper: in-process server.Server lifecycle, diagnostic capture + lsp_test.go - LSP wrapper unit tests + audit.go - Code review rules (cadence_code_review) + audit_test.go - Code review rule tests + tools.go - All 9 tool handler implementations + tools_test.go - Tool handler tests + +Modified: + cmd/flow/main.go - Register mcp.Cmd + go.mod / go.sum - Add mcp-go dependency +``` + +--- + +### Task 1: Add mcp-go dependency and scaffold the command + +**Files:** +- Create: `internal/mcp/mcp.go` +- Modify: `cmd/flow/main.go` +- Modify: `go.mod`, `go.sum` + +- [ ] **Step 1: Add mcp-go dependency** + +Run: +```bash +go get github.com/mark3labs/mcp-go@latest +``` + +- [ ] **Step 2: Create the MCP command with help text** + +Create `internal/mcp/mcp.go`: + +```go +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mcp + +import ( + "fmt" + "os" + + "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + "github.com/spf13/cobra" + "github.com/spf13/afero" + + "github.com/onflow/flowkit/v2" + "github.com/onflow/flowkit/v2/config" + "github.com/onflow/flowkit/v2/gateway" +) + +var Cmd = &cobra.Command{ + Use: "mcp", + Short: "Start the Cadence MCP server", + Long: `Start a Model Context Protocol (MCP) server for Cadence smart contract development. + +The server provides tools for checking Cadence code, inspecting types, +querying on-chain contracts, executing scripts, and reviewing code for +common issues. + +Claude Code: + claude mcp add cadence-mcp -- flow mcp + +Cursor / Claude Desktop (add to settings JSON): + { + "mcpServers": { + "cadence-mcp": { + "command": "flow", + "args": ["mcp"] + } + } + } + +Available tools: + cadence_check Check Cadence code for syntax and type errors + cadence_hover Get type info for a symbol at a position + cadence_definition Find where a symbol is defined + cadence_symbols List all symbols in Cadence code + cadence_completion Get completions at a position + get_contract_source Fetch on-chain contract manifest + get_contract_code Fetch contract source code from an address + cadence_code_review Review Cadence code for common issues + cadence_execute_script Execute a read-only Cadence script on-chain`, + Run: runMCP, +} + +func runMCP(cmd *cobra.Command, args []string) { + // Try to load flow.json for custom network configs + loader := &afero.Afero{Fs: afero.NewOsFs()} + state, _ := flowkit.Load(config.DefaultPaths(), loader) + + s := mcpserver.NewMCPServer("cadence-mcp", "1.0.0") + + // TODO: register tools in subsequent tasks + + if err := mcpserver.ServeStdio(s); err != nil { + fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err) + os.Exit(1) + } +} + +// resolveNetwork returns a config.Network for the given network name. +// Uses flow.json config if available, otherwise falls back to defaults. +func resolveNetwork(state *flowkit.State, network string) (*config.Network, error) { + if network == "" { + network = "mainnet" + } + + if state != nil { + net, err := state.Networks().ByName(network) + if err == nil { + return net, nil + } + } + + net, err := config.DefaultNetworks.ByName(network) + if err != nil { + return nil, fmt.Errorf("unknown network %q", network) + } + return net, nil +} + +// createGateway creates a gRPC gateway for the given network. +func createGateway(state *flowkit.State, network string) (gateway.Gateway, error) { + net, err := resolveNetwork(state, network) + if err != nil { + return nil, err + } + return gateway.NewGrpcGateway(*net) +} +``` + +- [ ] **Step 3: Register the command in main.go** + +In `cmd/flow/main.go`, add the import and registration: + +Add import: +```go +"github.com/onflow/flow-cli/internal/mcp" +``` + +Add after the `cmd.AddCommand(schedule.Cmd)` line: +```go +cmd.AddCommand(mcp.Cmd) +``` + +- [ ] **Step 4: Verify it builds** + +Run: +```bash +CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go build ./cmd/flow/... +``` +Expected: builds with no errors. + +- [ ] **Step 5: Verify help text** + +Run: +```bash +go run ./cmd/flow mcp --help +``` +Expected: prints the long description with installation instructions and tool list. + +- [ ] **Step 6: Commit** + +```bash +git add internal/mcp/mcp.go cmd/flow/main.go go.mod go.sum +git commit -m "Add flow mcp command scaffold with mcp-go dependency" +``` + +--- + +### Task 2: LSP wrapper — in-process server with diagnostic capture + +**Files:** +- Create: `internal/mcp/lsp.go` +- Create: `internal/mcp/lsp_test.go` + +- [ ] **Step 1: Write the failing test for LSP wrapper initialization and diagnostics** + +Create `internal/mcp/lsp_test.go`: + +```go +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLSPWrapper_Check_ValidCode(t *testing.T) { + lsp, err := NewLSPWrapper(false) + require.NoError(t, err) + + diags, err := lsp.Check("access(all) fun main() {}", "") + require.NoError(t, err) + assert.Empty(t, diags, "valid code should produce no diagnostics") +} + +func TestLSPWrapper_Check_InvalidCode(t *testing.T) { + lsp, err := NewLSPWrapper(false) + require.NoError(t, err) + + diags, err := lsp.Check("access(all) fun main() { let x: Int = \"hello\" }", "") + require.NoError(t, err) + assert.NotEmpty(t, diags, "type mismatch should produce diagnostics") +} + +func TestLSPWrapper_Check_SyntaxError(t *testing.T) { + lsp, err := NewLSPWrapper(false) + require.NoError(t, err) + + diags, err := lsp.Check("this is not valid cadence {{{", "") + require.NoError(t, err) + assert.NotEmpty(t, diags, "syntax error should produce diagnostics") +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go test ./internal/mcp/... -run TestLSPWrapper -v +``` +Expected: FAIL — `NewLSPWrapper` undefined. + +- [ ] **Step 3: Implement the LSP wrapper** + +Create `internal/mcp/lsp.go`: + +```go +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mcp + +import ( + "fmt" + "strings" + "sync" + + "github.com/onflow/cadence-tools/languageserver/integration" + "github.com/onflow/cadence-tools/languageserver/protocol" + "github.com/onflow/cadence-tools/languageserver/server" +) + +const scratchURI = protocol.DocumentURI("file:///mcp/scratch.cdc") + +// LSPWrapper manages an in-process cadence-tools language server. +// All operations are serialized — the LSP server is single-threaded. +type LSPWrapper struct { + server *server.Server + conn *diagConn + mu sync.Mutex + docVersion int32 + docOpen bool +} + +// NewLSPWrapper creates a new LSP wrapper with an in-process language server. +// enableFlowClient enables on-chain import resolution (requires network access). +func NewLSPWrapper(enableFlowClient bool) (*LSPWrapper, error) { + s, err := server.NewServer() + if err != nil { + return nil, fmt.Errorf("creating LSP server: %w", err) + } + + _, err = integration.NewFlowIntegration(s, enableFlowClient) + if err != nil { + return nil, fmt.Errorf("initializing Flow integration: %w", err) + } + + conn := &diagConn{} + + // Initialize the server (required before any LSP operations) + _, err = s.Initialize(conn, &protocol.InitializeParams{ + InitializationOptions: map[string]interface{}{ + "accessCheckMode": "strict", + }, + }) + if err != nil { + return nil, fmt.Errorf("initializing LSP: %w", err) + } + + return &LSPWrapper{ + server: s, + conn: conn, + }, nil +} + +// updateDocument opens or updates the scratch document with the given code. +// Must be called with w.mu held. +func (w *LSPWrapper) updateDocument(code string) { + w.docVersion++ + if !w.docOpen { + w.server.DidOpenTextDocument(w.conn, &protocol.DidOpenTextDocumentParams{ + TextDocument: protocol.TextDocumentItem{ + URI: scratchURI, + LanguageID: "cadence", + Version: w.docVersion, + Text: code, + }, + }) + w.docOpen = true + } else { + w.server.DidChangeTextDocument(w.conn, &protocol.DidChangeTextDocumentParams{ + TextDocument: protocol.VersionedTextDocumentIdentifier{ + TextDocumentIdentifier: protocol.TextDocumentIdentifier{URI: scratchURI}, + Version: w.docVersion, + }, + ContentChanges: []protocol.TextDocumentContentChangeEvent{ + {Text: code}, + }, + }) + } +} + +// Check analyzes Cadence code and returns diagnostics. +func (w *LSPWrapper) Check(code string, network string) ([]protocol.Diagnostic, error) { + w.mu.Lock() + defer w.mu.Unlock() + + w.conn.reset() + w.updateDocument(code) + return w.conn.getDiagnostics(), nil +} + +// Hover returns type information at the given position. +func (w *LSPWrapper) Hover(code string, line, character int, network string) (*protocol.Hover, error) { + w.mu.Lock() + defer w.mu.Unlock() + + w.conn.reset() + w.updateDocument(code) + return w.server.Hover(w.conn, &protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: scratchURI}, + Position: protocol.Position{Line: uint32(line), Character: uint32(character)}, + }) +} + +// Definition returns the definition location of a symbol at the given position. +func (w *LSPWrapper) Definition(code string, line, character int, network string) (*protocol.Location, error) { + w.mu.Lock() + defer w.mu.Unlock() + + w.conn.reset() + w.updateDocument(code) + return w.server.Definition(w.conn, &protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: scratchURI}, + Position: protocol.Position{Line: uint32(line), Character: uint32(character)}, + }) +} + +// Symbols returns all document symbols in the code. +func (w *LSPWrapper) Symbols(code string, network string) ([]*protocol.DocumentSymbol, error) { + w.mu.Lock() + defer w.mu.Unlock() + + w.conn.reset() + w.updateDocument(code) + return w.server.DocumentSymbol(w.conn, &protocol.DocumentSymbolParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: scratchURI}, + }) +} + +// Completion returns completion items at the given position. +func (w *LSPWrapper) Completion(code string, line, character int, network string) ([]*protocol.CompletionItem, error) { + w.mu.Lock() + defer w.mu.Unlock() + + w.conn.reset() + w.updateDocument(code) + return w.server.Completion(w.conn, &protocol.CompletionParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: scratchURI}, + Position: protocol.Position{Line: uint32(line), Character: uint32(character)}, + }, + }) +} + +// diagConn implements protocol.Conn to capture diagnostics pushed by the LSP. +type diagConn struct { + mu sync.Mutex + diagnostics []protocol.Diagnostic +} + +func (c *diagConn) reset() { + c.mu.Lock() + defer c.mu.Unlock() + c.diagnostics = nil +} + +func (c *diagConn) getDiagnostics() []protocol.Diagnostic { + c.mu.Lock() + defer c.mu.Unlock() + result := make([]protocol.Diagnostic, len(c.diagnostics)) + copy(result, c.diagnostics) + return result +} + +func (c *diagConn) Notify(method string, params any) error { + if method == "textDocument/publishDiagnostics" { + if p, ok := params.(*protocol.PublishDiagnosticsParams); ok { + c.mu.Lock() + c.diagnostics = append(c.diagnostics, p.Diagnostics...) + c.mu.Unlock() + } + } + return nil +} + +func (c *diagConn) ShowMessage(params *protocol.ShowMessageParams) {} + +func (c *diagConn) ShowMessageRequest(params *protocol.ShowMessageRequestParams) (*protocol.MessageActionItem, error) { + return nil, nil +} + +func (c *diagConn) LogMessage(params *protocol.LogMessageParams) {} + +func (c *diagConn) PublishDiagnostics(params *protocol.PublishDiagnosticsParams) error { + c.mu.Lock() + c.diagnostics = append(c.diagnostics, params.Diagnostics...) + c.mu.Unlock() + return nil +} + +func (c *diagConn) RegisterCapability(params *protocol.RegistrationParams) error { + return nil +} + +// formatDiagnostics formats LSP diagnostics into a human-readable string. +func formatDiagnostics(diagnostics []protocol.Diagnostic) string { + if len(diagnostics) == 0 { + return "No errors found." + } + + severityLabels := map[protocol.DiagnosticSeverity]string{ + protocol.SeverityError: "error", + protocol.SeverityWarning: "warning", + protocol.SeverityInformation: "info", + protocol.SeverityHint: "hint", + } + + var b strings.Builder + for _, d := range diagnostics { + label := severityLabels[d.Severity] + if label == "" { + label = "error" + } + fmt.Fprintf(&b, "[%s] line %d:%d: %s\n", + label, + d.Range.Start.Line+1, + d.Range.Start.Character+1, + d.Message, + ) + } + return b.String() +} + +// formatHover formats a hover result into readable text. +func formatHover(result *protocol.Hover) string { + if result == nil { + return "No information available." + } + return result.Contents.Value +} + +// formatSymbols formats document symbols into readable text. +func formatSymbols(symbols []*protocol.DocumentSymbol, indent int) string { + if len(symbols) == 0 { + return "No symbols found." + } + + var b strings.Builder + prefix := strings.Repeat(" ", indent) + for _, sym := range symbols { + detail := "" + if sym.Detail != "" { + detail = " — " + sym.Detail + } + fmt.Fprintf(&b, "%s%s %s%s\n", prefix, symbolKindName(sym.Kind), sym.Name, detail) + if len(sym.Children) > 0 { + b.WriteString(formatSymbols(sym.Children, indent+1)) + } + } + return b.String() +} + +func symbolKindName(kind protocol.SymbolKind) string { + names := map[protocol.SymbolKind]string{ + 1: "File", 2: "Module", 3: "Namespace", 4: "Package", + 5: "Class", 6: "Method", 7: "Property", 8: "Field", + 9: "Constructor", 10: "Enum", 11: "Interface", 12: "Function", + 13: "Variable", 14: "Constant", 15: "String", 16: "Number", + 17: "Boolean", 18: "Array", 19: "Object", 20: "Key", + 21: "Null", 22: "EnumMember", 23: "Struct", 24: "Event", + 25: "Operator", 26: "TypeParameter", + } + if name, ok := names[kind]; ok { + return name + } + return fmt.Sprintf("kind(%d)", kind) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go test ./internal/mcp/... -run TestLSPWrapper -v +``` +Expected: all 3 tests PASS. + +- [ ] **Step 5: Add tests for hover and symbols** + +Append to `internal/mcp/lsp_test.go`: + +```go +func TestLSPWrapper_Hover(t *testing.T) { + lsp, err := NewLSPWrapper(false) + require.NoError(t, err) + + // Hover over "Int" on line 0, character ~30 + code := "access(all) fun main(): Int { return 42 }" + result, err := lsp.Hover(code, 0, 24, "") + require.NoError(t, err) + assert.NotNil(t, result, "should get hover info for Int type") +} + +func TestLSPWrapper_Symbols(t *testing.T) { + lsp, err := NewLSPWrapper(false) + require.NoError(t, err) + + code := `access(all) contract Foo { + access(all) resource Bar {} + access(all) fun baz() {} + }` + symbols, err := lsp.Symbols(code, "") + require.NoError(t, err) + assert.NotEmpty(t, symbols, "should find symbols in contract") +} + +func TestLSPWrapper_Completion(t *testing.T) { + lsp, err := NewLSPWrapper(false) + require.NoError(t, err) + + // Get completions at empty position — should return at least some items + code := "access(all) fun main() {\n \n}" + items, err := lsp.Completion(code, 1, 2, "") + require.NoError(t, err) + assert.NotEmpty(t, items, "should get completion items") +} +``` + +- [ ] **Step 6: Run all LSP tests** + +Run: +```bash +CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go test ./internal/mcp/... -run TestLSPWrapper -v +``` +Expected: all 6 tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/mcp/lsp.go internal/mcp/lsp_test.go +git commit -m "Add LSP wrapper with in-process language server" +``` + +--- + +### Task 3: Code review rules (cadence_code_review) + +**Files:** +- Create: `internal/mcp/audit.go` +- Create: `internal/mcp/audit_test.go` + +- [ ] **Step 1: Write failing tests for code review rules** + +Create `internal/mcp/audit_test.go`: + +```go +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCodeReview_CleanCode(t *testing.T) { + code := `access(contract) let balance: UFix64 +access(contract) fun transfer() {}` + result := codeReview(code) + assert.Empty(t, result.Findings, "clean code should have no findings") +} + +func TestCodeReview_OverlyPermissiveAccess(t *testing.T) { + code := `access(all) var balance: UFix64` + result := codeReview(code) + assert.NotEmpty(t, result.Findings) + assert.Equal(t, "overly-permissive-access", result.Findings[0].Rule) + assert.Equal(t, "warning", string(result.Findings[0].Severity)) +} + +func TestCodeReview_DeprecatedPub(t *testing.T) { + code := `pub fun doSomething() {}` + result := codeReview(code) + found := false + for _, f := range result.Findings { + if f.Rule == "deprecated-pub" { + found = true + } + } + assert.True(t, found, "should detect deprecated pub keyword") +} + +func TestCodeReview_ForceUnwrap(t *testing.T) { + code := `access(all) fun main() { let x = optional! }` + result := codeReview(code) + found := false + for _, f := range result.Findings { + if f.Rule == "unsafe-force-unwrap" { + found = true + } + } + assert.True(t, found, "should detect force-unwrap") +} + +func TestCodeReview_HardcodedAddress(t *testing.T) { + code := `let addr = 0xf233dcee88fe0abe` + result := codeReview(code) + found := false + for _, f := range result.Findings { + if f.Rule == "hardcoded-address" { + found = true + } + } + assert.True(t, found, "should detect hardcoded address") +} + +func TestCodeReview_AddressImportNotFlagged(t *testing.T) { + code := `import FungibleToken from 0xf233dcee88fe0abe` + result := codeReview(code) + for _, f := range result.Findings { + assert.NotEqual(t, "hardcoded-address", f.Rule, + "address imports should not be flagged as hardcoded addresses") + } +} + +func TestCodeReview_FormatResult(t *testing.T) { + result := ReviewResult{ + Findings: []Finding{ + {Rule: "test", Severity: "warning", Line: 1, Message: "test message"}, + }, + Summary: map[string]int{"warning": 1}, + } + text := formatReviewResult(result) + assert.Contains(t, text, "1 issue(s)") + assert.Contains(t, text, "[WARNING]") + assert.Contains(t, text, "test message") +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go test ./internal/mcp/... -run TestCodeReview -v +``` +Expected: FAIL — `codeReview` undefined. + +- [ ] **Step 3: Implement code review rules** + +Create `internal/mcp/audit.go`: + +```go +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mcp + +import ( + "fmt" + "regexp" + "strings" +) + +// Severity represents the severity level of a finding. +type Severity string + +const ( + SeverityWarning Severity = "warning" + SeverityNote Severity = "note" + SeverityInfo Severity = "info" +) + +// Finding represents a single code review finding. +type Finding struct { + Rule string `json:"rule"` + Severity Severity `json:"severity"` + Line int `json:"line"` + Message string `json:"message"` +} + +// ReviewResult contains all findings from a code review. +type ReviewResult struct { + Findings []Finding `json:"findings"` + Summary map[string]int `json:"summary"` +} + +type rule struct { + id string + severity Severity + pattern *regexp.Regexp + message string // static message, or empty if messageFunc is set + msgFunc func([]string) string + perLine bool // true = match per line (default), false = full text +} + +var rules = []rule{ + { + id: "overly-permissive-access", + severity: SeverityWarning, + pattern: regexp.MustCompile(`access\(all\)\s+(var|let)\s+`), + message: "State field with access(all) — consider restricting access with entitlements", + perLine: true, + }, + { + id: "overly-permissive-function", + severity: SeverityNote, + pattern: regexp.MustCompile(`access\(all\)\s+fun\s+(\w+)`), + msgFunc: func(m []string) string { + name := "unknown" + if len(m) > 1 { + name = m[1] + } + return fmt.Sprintf("Function '%s' has access(all) — review if public access is intended", name) + }, + perLine: true, + }, + { + id: "deprecated-pub", + severity: SeverityInfo, + pattern: regexp.MustCompile(`\bpub\s+(var|let|fun|resource|struct|event|contract|enum)\b`), + message: "`pub` is deprecated in Cadence 1.0 — use `access(all)` or a more restrictive access modifier", + perLine: true, + }, + { + id: "unsafe-force-unwrap", + severity: SeverityNote, + pattern: regexp.MustCompile(`[)\w]\s*!`), + message: "Force-unwrap (!) used — consider nil-coalescing (??) or optional binding for safer handling", + perLine: true, + }, + { + id: "auth-account-exposure", + severity: SeverityWarning, + pattern: regexp.MustCompile(`\bAuthAccount\b`), + message: "AuthAccount reference found — passing AuthAccount gives full account access, use capabilities instead", + perLine: true, + }, + { + id: "auth-reference-exposure", + severity: SeverityWarning, + pattern: regexp.MustCompile(`\bauth\s*\(.*?\)\s*&Account\b`), + message: "auth(…) &Account reference found — this grants broad account access, prefer scoped capabilities", + perLine: true, + }, + { + id: "hardcoded-address", + severity: SeverityInfo, + pattern: regexp.MustCompile(`0x[0-9a-fA-F]{8,16}\b`), + message: "Hardcoded address detected — consider using named address imports for portability", + perLine: true, + }, + { + id: "unguarded-capability", + severity: SeverityWarning, + pattern: regexp.MustCompile(`\.publish\s*\(`), + message: "Capability published — verify that proper entitlements guard this capability", + perLine: true, + }, + { + id: "resource-loss-destroy", + severity: SeverityWarning, + pattern: regexp.MustCompile(`destroy\s*\(`), + message: "Explicit destroy call — ensure the resource is intentionally being destroyed and not lost", + perLine: true, + }, +} + +// isAddressImportLine returns true if the line is an import-from-address statement. +var addressImportPattern = regexp.MustCompile(`^\s*import\s+\w[\w, ]*\s+from\s+0x`) + +func isAddressImportLine(line string) bool { + return addressImportPattern.MatchString(line) +} + +// codeReview runs static analysis rules against Cadence source code. +func codeReview(code string) ReviewResult { + var findings []Finding + lines := strings.Split(code, "\n") + + for _, r := range rules { + if !r.perLine { + continue // multi-line rules handled below + } + for i, line := range lines { + // Skip address import lines for hardcoded-address rule + if r.id == "hardcoded-address" && isAddressImportLine(line) { + continue + } + + match := r.pattern.FindStringSubmatch(line) + if match == nil { + continue + } + + msg := r.message + if r.msgFunc != nil { + msg = r.msgFunc(match) + } + + findings = append(findings, Finding{ + Rule: r.id, + Severity: r.severity, + Line: i + 1, + Message: msg, + }) + } + } + + summary := map[string]int{} + for _, f := range findings { + summary[string(f.Severity)]++ + } + + return ReviewResult{ + Findings: findings, + Summary: summary, + } +} + +// formatReviewResult formats a ReviewResult into a human-readable string. +func formatReviewResult(result ReviewResult) string { + var b strings.Builder + + total := len(result.Findings) + fmt.Fprintf(&b, "## Code Review Results\n") + fmt.Fprintf(&b, "Found %d issue(s): %d warning, %d note, %d info\n\n", + total, + result.Summary["warning"], + result.Summary["note"], + result.Summary["info"], + ) + + if total == 0 { + b.WriteString("No issues detected.\n") + return b.String() + } + + for _, f := range result.Findings { + fmt.Fprintf(&b, "- [%s] Line %d: (%s) %s\n", + strings.ToUpper(string(f.Severity)), + f.Line, + f.Rule, + f.Message, + ) + } + + return b.String() +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go test ./internal/mcp/... -run TestCodeReview -v +``` +Expected: all 7 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/mcp/audit.go internal/mcp/audit_test.go +git commit -m "Add code review rules for cadence_code_review tool" +``` + +--- + +### Task 4: Tool handler implementations + +**Files:** +- Create: `internal/mcp/tools.go` +- Modify: `internal/mcp/mcp.go` (wire tools into server) + +- [ ] **Step 1: Implement all 9 tool handlers** + +Create `internal/mcp/tools.go`: + +```go +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/onflow/flow-go-sdk" + "github.com/onflow/flowkit/v2" + "github.com/onflow/flowkit/v2/arguments" +) + +// mcpContext holds shared state for all tool handlers. +type mcpContext struct { + lsp *LSPWrapper + state *flowkit.State // may be nil if no flow.json +} + +// --- LSP Tool Handlers --- + +func (m *mcpContext) cadenceCheck(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + code, err := req.RequireString("code") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + network, _ := req.GetString("network", "mainnet") + + diags, err := m.lsp.Check(code, network) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("LSP error: %v", err)), nil + } + return mcp.NewToolResultText(formatDiagnostics(diags)), nil +} + +func (m *mcpContext) cadenceHover(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + code, err := req.RequireString("code") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + line, err := req.RequireInt("line") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + character, err := req.RequireInt("character") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + network, _ := req.GetString("network", "mainnet") + + result, err := m.lsp.Hover(code, line, character, network) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("LSP error: %v", err)), nil + } + return mcp.NewToolResultText(formatHover(result)), nil +} + +func (m *mcpContext) cadenceDefinition(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + code, err := req.RequireString("code") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + line, err := req.RequireInt("line") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + character, err := req.RequireInt("character") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + network, _ := req.GetString("network", "mainnet") + + loc, err := m.lsp.Definition(code, line, character, network) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("LSP error: %v", err)), nil + } + if loc == nil { + return mcp.NewToolResultText("No definition found."), nil + } + return mcp.NewToolResultText(fmt.Sprintf("Definition: %s at line %d:%d", + loc.URI, + loc.Range.Start.Line+1, + loc.Range.Start.Character+1, + )), nil +} + +func (m *mcpContext) cadenceSymbols(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + code, err := req.RequireString("code") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + network, _ := req.GetString("network", "mainnet") + + symbols, err := m.lsp.Symbols(code, network) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("LSP error: %v", err)), nil + } + return mcp.NewToolResultText(formatSymbols(symbols, 0)), nil +} + +func (m *mcpContext) cadenceCompletion(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + code, err := req.RequireString("code") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + line, err := req.RequireInt("line") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + character, err := req.RequireInt("character") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + network, _ := req.GetString("network", "mainnet") + + items, err := m.lsp.Completion(code, line, character, network) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("LSP error: %v", err)), nil + } + if len(items) == 0 { + return mcp.NewToolResultText("No completions available."), nil + } + + var b strings.Builder + for _, item := range items { + detail := "" + if item.Detail != "" { + detail = " — " + item.Detail + } + fmt.Fprintf(&b, "%s%s\n", item.Label, detail) + } + return mcp.NewToolResultText(b.String()), nil +} + +// --- Audit Tool Handlers --- + +func (m *mcpContext) getContractSource(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + address, err := req.RequireString("address") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + network, _ := req.GetString("network", "mainnet") + + gw, err := createGateway(m.state, network) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Gateway error: %v", err)), nil + } + + addr := flow.HexToAddress(address) + account, err := gw.GetAccount(ctx, addr) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Error fetching account: %v", err)), nil + } + + type contractEntry struct { + Name string `json:"name"` + Size int `json:"size"` + Imports []string `json:"imports,omitempty"` + } + + var entries []contractEntry + for name, code := range account.Contracts { + entries = append(entries, contractEntry{ + Name: name, + Size: len(code), + }) + } + + result, err := json.MarshalIndent(map[string]any{ + "address": address, + "network": network, + "contracts": entries, + }, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("JSON error: %v", err)), nil + } + return mcp.NewToolResultText(string(result)), nil +} + +func (m *mcpContext) getContractCode(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + address, err := req.RequireString("address") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + contractName, _ := req.GetString("contract_name", "") + network, _ := req.GetString("network", "mainnet") + + gw, err := createGateway(m.state, network) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Gateway error: %v", err)), nil + } + + addr := flow.HexToAddress(address) + account, err := gw.GetAccount(ctx, addr) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Error fetching account: %v", err)), nil + } + + var parts []string + for name, code := range account.Contracts { + if contractName != "" && name != contractName { + continue + } + parts = append(parts, fmt.Sprintf("// === %s (%s) ===\n\n%s", name, address, string(code))) + } + + if len(parts) == 0 { + if contractName != "" { + names := make([]string, 0, len(account.Contracts)) + for name := range account.Contracts { + names = append(names, name) + } + return mcp.NewToolResultError(fmt.Sprintf( + "Contract '%s' not found on %s. Available: %s", + contractName, address, strings.Join(names, ", "), + )), nil + } + return mcp.NewToolResultText("No contracts found on this address."), nil + } + + return mcp.NewToolResultText(strings.Join(parts, "\n\n")), nil +} + +func (m *mcpContext) cadenceCodeReview(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + code, err := req.RequireString("code") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + network, _ := req.GetString("network", "mainnet") + + // Run static analysis rules + result := codeReview(code) + text := formatReviewResult(result) + + // Also run LSP type check if available + if m.lsp != nil { + diags, err := m.lsp.Check(code, network) + if err == nil && len(diags) > 0 { + text += "\n## Type Check (LSP)\n" + formatDiagnostics(diags) + } + } + + return mcp.NewToolResultText(text), nil +} + +func (m *mcpContext) cadenceExecuteScript(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + code, err := req.RequireString("code") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + network, _ := req.GetString("network", "mainnet") + argsJSON, _ := req.GetString("args", "[]") + + gw, err := createGateway(m.state, network) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Gateway error: %v", err)), nil + } + + // Parse script arguments + var argStrings []string + if err := json.Unmarshal([]byte(argsJSON), &argStrings); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid args format: %v", err)), nil + } + + cadenceArgs, err := arguments.ParseWithoutType(argStrings) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Argument parse error: %v", err)), nil + } + + value, err := gw.ExecuteScript(ctx, flowkit.Script{ + Code: []byte(code), + Args: cadenceArgs, + }, flowkit.LatestScriptQuery) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Script execution failed:\n%v", err)), nil + } + + return mcp.NewToolResultText(value.String()), nil +} +``` + +- [ ] **Step 2: Wire tools into the MCP server** + +Update `internal/mcp/mcp.go` — replace the `runMCP` function: + +```go +func runMCP(cmd *cobra.Command, args []string) { + // Try to load flow.json for custom network configs + loader := &afero.Afero{Fs: afero.NewOsFs()} + state, _ := flowkit.Load(config.DefaultPaths(), loader) + + // Initialize LSP wrapper (disable flow client for now — network queries + // use gateways directly, and the LSP's flow client would prompt for + // flow.json interactively which doesn't work over MCP stdio) + lsp, err := NewLSPWrapper(false) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: LSP initialization failed: %v\n", err) + fmt.Fprintf(os.Stderr, "LSP tools will be unavailable.\n") + } + + mctx := &mcpContext{ + lsp: lsp, + state: state, + } + + s := mcpserver.NewMCPServer("cadence-mcp", "1.0.0") + registerTools(s, mctx) + + if err := mcpserver.ServeStdio(s); err != nil { + fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err) + os.Exit(1) + } +} + +func registerTools(s *mcpserver.MCPServer, mctx *mcpContext) { + networkParam := mcp.WithString("network", + mcp.Description("Flow network: mainnet, testnet, or emulator (default: mainnet)"), + mcp.Enum("mainnet", "testnet", "emulator"), + ) + + // --- LSP Tools --- + + if mctx.lsp != nil { + s.AddTool(mcp.NewTool("cadence_check", + mcp.WithDescription("Check Cadence smart contract code for syntax and type errors. Returns diagnostics."), + mcp.WithString("code", mcp.Required(), mcp.Description("Cadence source code to check")), + mcp.WithString("filename", mcp.Description("Virtual filename (default: check.cdc)")), + networkParam, + ), mctx.cadenceCheck) + + s.AddTool(mcp.NewTool("cadence_hover", + mcp.WithDescription("Get type information and documentation for a symbol at a given position in Cadence code."), + mcp.WithString("code", mcp.Required(), mcp.Description("Cadence source code")), + mcp.WithNumber("line", mcp.Required(), mcp.Description("0-based line number")), + mcp.WithNumber("character", mcp.Required(), mcp.Description("0-based column number")), + mcp.WithString("filename", mcp.Description("Virtual filename")), + networkParam, + ), mctx.cadenceHover) + + s.AddTool(mcp.NewTool("cadence_definition", + mcp.WithDescription("Find the definition location of a symbol at a given position in Cadence code."), + mcp.WithString("code", mcp.Required(), mcp.Description("Cadence source code")), + mcp.WithNumber("line", mcp.Required(), mcp.Description("0-based line number")), + mcp.WithNumber("character", mcp.Required(), mcp.Description("0-based column number")), + mcp.WithString("filename", mcp.Description("Virtual filename")), + networkParam, + ), mctx.cadenceDefinition) + + s.AddTool(mcp.NewTool("cadence_symbols", + mcp.WithDescription("List all symbols (contracts, resources, functions, events, etc.) in Cadence code."), + mcp.WithString("code", mcp.Required(), mcp.Description("Cadence source code")), + mcp.WithString("filename", mcp.Description("Virtual filename")), + networkParam, + ), mctx.cadenceSymbols) + + s.AddTool(mcp.NewTool("cadence_completion", + mcp.WithDescription("Get code completions at a position in Cadence code. Returns available members, methods, and keywords."), + mcp.WithString("code", mcp.Required(), mcp.Description("Cadence source code")), + mcp.WithNumber("line", mcp.Required(), mcp.Description("0-based line number")), + mcp.WithNumber("character", mcp.Required(), mcp.Description("0-based column number")), + mcp.WithString("filename", mcp.Description("Virtual filename")), + networkParam, + ), mctx.cadenceCompletion) + } + + // --- Audit Tools --- + + s.AddTool(mcp.NewTool("get_contract_source", + mcp.WithDescription("Fetch on-chain contract manifest from a Flow address: lists all contracts with names and sizes."), + mcp.WithString("address", mcp.Required(), mcp.Description("Flow address (0x...)")), + networkParam, + ), mctx.getContractSource) + + s.AddTool(mcp.NewTool("get_contract_code", + mcp.WithDescription("Fetch the source code of contracts from a Flow address."), + mcp.WithString("address", mcp.Required(), mcp.Description("Flow address (0x...)")), + mcp.WithString("contract_name", mcp.Description("Name of specific contract to fetch. If omitted, returns all.")), + networkParam, + ), mctx.getContractCode) + + s.AddTool(mcp.NewTool("cadence_code_review", + mcp.WithDescription("Review Cadence code for common issues and best practices. Uses pattern matching to flag potential problems — not a substitute for a proper audit."), + mcp.WithString("code", mcp.Required(), mcp.Description("Cadence source code to review")), + networkParam, + ), mctx.cadenceCodeReview) + + s.AddTool(mcp.NewTool("cadence_execute_script", + mcp.WithDescription("Execute a read-only Cadence script on the Flow network. Scripts can query on-chain state. Cannot modify state."), + mcp.WithString("code", mcp.Required(), mcp.Description("Cadence script code (must have `access(all) fun main()` entry point)")), + mcp.WithString("args", mcp.Description(`Script arguments as JSON array of strings in "Type:Value" format, e.g. ["Address:0x1654653399040a61", "UFix64:10.0"]`)), + networkParam, + ), mctx.cadenceExecuteScript) +} +``` + +- [ ] **Step 3: Verify it builds** + +Run: +```bash +CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go build ./cmd/flow/... +``` +Expected: builds with no errors. + +- [ ] **Step 4: Commit** + +```bash +git add internal/mcp/tools.go internal/mcp/mcp.go +git commit -m "Add tool handler implementations and wire into MCP server" +``` + +--- + +### Task 5: Tool handler tests + +**Files:** +- Create: `internal/mcp/tools_test.go` + +- [ ] **Step 1: Write tool handler tests** + +Create `internal/mcp/tools_test.go`: + +```go +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mcp + +import ( + "context" + "encoding/json" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestContext(t *testing.T) *mcpContext { + t.Helper() + lsp, err := NewLSPWrapper(false) + require.NoError(t, err) + return &mcpContext{lsp: lsp} +} + +func makeRequest(args map[string]any) mcp.CallToolRequest { + raw, _ := json.Marshal(args) + var params struct { + Arguments map[string]any `json:"arguments"` + } + params.Arguments = args + rawParams, _ := json.Marshal(params) + var req mcp.CallToolRequest + json.Unmarshal(rawParams, &req) + // mcp-go uses req.Params.Arguments + req.Params.Arguments = args + return req +} + +func TestTool_CadenceCheck_Valid(t *testing.T) { + mctx := newTestContext(t) + req := makeRequest(map[string]any{ + "code": "access(all) fun main() {}", + }) + + result, err := mctx.cadenceCheck(context.Background(), req) + require.NoError(t, err) + assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "No errors found") +} + +func TestTool_CadenceCheck_Invalid(t *testing.T) { + mctx := newTestContext(t) + req := makeRequest(map[string]any{ + "code": "access(all) fun main() { let x: Int = \"bad\" }", + }) + + result, err := mctx.cadenceCheck(context.Background(), req) + require.NoError(t, err) + text := result.Content[0].(mcp.TextContent).Text + assert.Contains(t, text, "error") +} + +func TestTool_CadenceCheck_MissingCode(t *testing.T) { + mctx := newTestContext(t) + req := makeRequest(map[string]any{}) + + result, err := mctx.cadenceCheck(context.Background(), req) + require.NoError(t, err) + assert.True(t, result.IsError) +} + +func TestTool_CadenceSymbols(t *testing.T) { + mctx := newTestContext(t) + req := makeRequest(map[string]any{ + "code": `access(all) contract Foo { + access(all) fun bar() {} + }`, + }) + + result, err := mctx.cadenceSymbols(context.Background(), req) + require.NoError(t, err) + text := result.Content[0].(mcp.TextContent).Text + assert.Contains(t, text, "Foo") +} + +func TestTool_CadenceCodeReview(t *testing.T) { + mctx := newTestContext(t) + req := makeRequest(map[string]any{ + "code": "access(all) var balance: UFix64", + }) + + result, err := mctx.cadenceCodeReview(context.Background(), req) + require.NoError(t, err) + text := result.Content[0].(mcp.TextContent).Text + assert.Contains(t, text, "overly-permissive-access") +} +``` + +- [ ] **Step 2: Run tests** + +Run: +```bash +CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go test ./internal/mcp/... -run TestTool -v +``` +Expected: all tests PASS. + +Note: The `makeRequest` helper may need adjustment based on the exact mcp-go `CallToolRequest` struct. Check the struct definition after adding the dependency and adjust accordingly. + +- [ ] **Step 3: Commit** + +```bash +git add internal/mcp/tools_test.go +git commit -m "Add tool handler tests" +``` + +--- + +### Task 6: End-to-end verification and license headers + +**Files:** +- Modify: all files in `internal/mcp/` + +- [ ] **Step 1: Verify license headers on all files** + +Run: +```bash +make check-headers +``` +Expected: PASS. All Go files in `internal/mcp/` already have the Apache 2.0 header from the code above. If any are flagged, add the header. + +- [ ] **Step 2: Run linter** + +Run: +```bash +make lint +``` +Expected: PASS with no new lint issues. + +- [ ] **Step 3: Run full test suite** + +Run: +```bash +CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go test ./internal/mcp/... -v +``` +Expected: all tests PASS. + +- [ ] **Step 4: Manual smoke test** + +Run the MCP server interactively to verify it starts and responds: + +```bash +echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | go run ./cmd/flow mcp +``` + +Expected: JSON response with server capabilities including the tool list. + +- [ ] **Step 5: Commit any fixes** + +If any fixes were needed: +```bash +git add internal/mcp/ +git commit -m "Fix lint and license header issues" +``` + +--- + +### Task 7: Integration tests for network tools (optional, behind flag) + +**Files:** +- Create: `internal/mcp/integration_test.go` + +These tests hit the real network and are skipped unless `SKIP_NETWORK_TESTS` is unset. + +- [ ] **Step 1: Write integration tests** + +Create `internal/mcp/integration_test.go`: + +```go +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mcp + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func skipIfNoNetwork(t *testing.T) { + t.Helper() + if os.Getenv("SKIP_NETWORK_TESTS") != "" { + t.Skip("Skipping network test (SKIP_NETWORK_TESTS is set)") + } +} + +func TestIntegration_GetContractSource(t *testing.T) { + skipIfNoNetwork(t) + mctx := &mcpContext{state: nil} + + req := makeRequest(map[string]any{ + "address": "0x1654653399040a61", + "network": "mainnet", + }) + + result, err := mctx.getContractSource(context.Background(), req) + require.NoError(t, err) + text := result.Content[0].(mcp.TextContent).Text + assert.Contains(t, text, "FungibleToken") +} + +func TestIntegration_GetContractCode(t *testing.T) { + skipIfNoNetwork(t) + mctx := &mcpContext{state: nil} + + req := makeRequest(map[string]any{ + "address": "0x1654653399040a61", + "contract_name": "FungibleToken", + "network": "mainnet", + }) + + result, err := mctx.getContractCode(context.Background(), req) + require.NoError(t, err) + text := result.Content[0].(mcp.TextContent).Text + assert.Contains(t, text, "FungibleToken") + assert.Contains(t, text, "access(all) contract interface") +} + +func TestIntegration_ExecuteScript(t *testing.T) { + skipIfNoNetwork(t) + mctx := &mcpContext{state: nil} + + req := makeRequest(map[string]any{ + "code": `access(all) fun main(): Int { return 42 }`, + "network": "mainnet", + }) + + result, err := mctx.cadenceExecuteScript(context.Background(), req) + require.NoError(t, err) + text := result.Content[0].(mcp.TextContent).Text + assert.Contains(t, text, "42") +} +``` + +- [ ] **Step 2: Run integration tests (if network available)** + +Run: +```bash +CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go test ./internal/mcp/... -run TestIntegration -v +``` +Expected: PASS (or skip if `SKIP_NETWORK_TESTS` is set). + +- [ ] **Step 3: Commit** + +```bash +git add internal/mcp/integration_test.go +git commit -m "Add integration tests for network tools" +``` From 02c572364a00f71c974be2ca8b9ddbdd8d10dc20 Mon Sep 17 00:00:00 2001 From: Peter Argue <89119817+peterargue@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:48:48 -0700 Subject: [PATCH 05/14] Add flow mcp command scaffold with mcp-go dependency --- cmd/flow/main.go | 2 + go.mod | 9 +++- go.sum | 20 ++++++-- internal/mcp/mcp.go | 114 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 internal/mcp/mcp.go diff --git a/cmd/flow/main.go b/cmd/flow/main.go index 446876c9b..cdf71871a 100644 --- a/cmd/flow/main.go +++ b/cmd/flow/main.go @@ -35,6 +35,7 @@ import ( "github.com/onflow/flow-cli/internal/events" evm "github.com/onflow/flow-cli/internal/evm" "github.com/onflow/flow-cli/internal/keys" + "github.com/onflow/flow-cli/internal/mcp" "github.com/onflow/flow-cli/internal/project" "github.com/onflow/flow-cli/internal/quick" "github.com/onflow/flow-cli/internal/schedule" @@ -92,6 +93,7 @@ func main() { cmd.AddCommand(dependencymanager.Cmd) cmd.AddCommand(evm.Cmd) cmd.AddCommand(schedule.Cmd) + cmd.AddCommand(mcp.Cmd) command.InitFlags(cmd) cmd.AddGroup(&cobra.Group{ diff --git a/go.mod b/go.mod index 8709ea4a3..96a09a131 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/getsentry/sentry-go v0.43.0 github.com/gosuri/uilive v0.0.4 github.com/logrusorgru/aurora/v4 v4.0.0 + github.com/mark3labs/mcp-go v0.45.0 github.com/onflow/cadence v1.9.10 github.com/onflow/cadence-tools/languageserver v1.9.6 github.com/onflow/cadence-tools/lint v1.7.6 @@ -60,11 +61,13 @@ require ( github.com/VictoriaMetrics/fastcache v1.13.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.0.3 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/c-bata/go-prompt v0.2.6 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash v1.1.0 // indirect @@ -149,10 +152,9 @@ require ( github.com/huandu/go-clone v1.6.0 // indirect github.com/huandu/go-clone/generic v1.7.2 // indirect github.com/huin/goupnp v1.3.0 // indirect - github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 // indirect github.com/improbable-eng/grpc-web v0.15.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/invopop/jsonschema v0.7.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect github.com/ipfs/bbloom v0.0.4 // indirect github.com/ipfs/boxo v0.17.1-0.20240131173518-89bceff34bf1 // indirect github.com/ipfs/go-block-format v0.2.0 // indirect @@ -179,6 +181,7 @@ require ( github.com/lmars/go-slip10 v0.0.0-20190606092855-400ba44fee12 // indirect github.com/logrusorgru/aurora v2.0.3+incompatible // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -263,9 +266,11 @@ require ( github.com/tyler-smith/go-bip39 v1.1.0 // indirect github.com/vmihailenco/msgpack/v4 v4.3.11 // indirect github.com/vmihailenco/tagparser v0.1.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect diff --git a/go.sum b/go.sum index 5e2755282..b363f7cbb 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -106,6 +108,8 @@ github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurT github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.3 h1:SDlJ7bAm4ewvrmZtR0DaiYbQGdKPeaaIm7bM+qRhFeU= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.3/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/sonic v1.11.5 h1:G00FYjjqll5iQ1PYXynbg/hyzqBqavH8Mo9/oTopd9k= github.com/bytedance/sonic v1.11.5/go.mod h1:X2PC2giUdj/Cv2lliWFLk6c/DUQok5rViJSemeB0wDw= github.com/bytedance/sonic/loader v0.1.0 h1:skjHJ2Bi9ibbq3Dwzh1w42MQ7wZJrXmEZr/uqUn3f0Q= @@ -530,8 +534,6 @@ github.com/huandu/go-clone/generic v1.7.2/go.mod h1:xgd9ZebcMsBWWcBx5mVMCoqMX24g github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= -github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 h1:i462o439ZjprVSFSZLZxcsoAe592sZB1rci2Z8j4wdk= -github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0/go.mod h1:N0Wam8K1arqPXNWjMo21EXnBPOPp36vB07FNRdD2geA= github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/improbable-eng/grpc-web v0.15.0 h1:BN+7z6uNXZ1tQGcNAuaU1YjsLTApzkjt2tzCixLaUPQ= github.com/improbable-eng/grpc-web v0.15.0/go.mod h1:1sy9HKV4Jt9aEs9JSnkWlRJPuPtwNr0l57L4f878wP8= @@ -539,8 +541,8 @@ github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANyt github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= -github.com/invopop/jsonschema v0.7.0 h1:2vgQcBz1n256N+FpX3Jq7Y17AjYt46Ig3zIWyy770So= -github.com/invopop/jsonschema v0.7.0/go.mod h1:O9uiLokuu0+MGFlyiaqtWxwqJm41/+8Nj0lD7A36YH0= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= github.com/ipfs/boxo v0.17.1-0.20240131173518-89bceff34bf1 h1:5H/HYvdmbxp09+sAvdqJzyrWoyCS6OroeW9Ym06Tb+0= @@ -575,6 +577,7 @@ github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/jordanschalm/lockctx v0.1.0 h1:2ZziSl5zejl5VSRUjl+UtYV94QPFQgO9bekqWPOKUQw= github.com/jordanschalm/lockctx v0.1.0/go.mod h1:qsnXMryYP9X7JbzskIn0+N40sE6XNXLr9kYRRP6rwXU= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -660,6 +663,10 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= 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.45.0 h1:s0S8qR/9fWaQ3pHxz7pm1uQ0DrswoSnRIxKIjbiQtkc= +github.com/mark3labs/mcp-go v0.45.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd h1:br0buuQ854V8u83wA0rVZ8ttrq5CpaPZdvrK0LP2lOk= github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs1Nt24+FYQEqAAncTDPJIuGs+LxK1MCiFL25pMU= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= @@ -1080,7 +1087,6 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v0.0.0-20170601210322-f6abca593680/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -1127,6 +1133,8 @@ github.com/vmihailenco/msgpack/v4 v4.3.11 h1:Q47CePddpNGNhk4GCnAx9DDtASi2rasatE0 github.com/vmihailenco/msgpack/v4 v4.3.11/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= github.com/vmihailenco/tagparser v0.1.1 h1:quXMXlA39OCbd2wAdTsGDlK9RkOk6Wuw+x37wVyIuWY= github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= @@ -1136,6 +1144,8 @@ github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +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/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go new file mode 100644 index 000000000..0e8213502 --- /dev/null +++ b/internal/mcp/mcp.go @@ -0,0 +1,114 @@ +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mcp + +import ( + "fmt" + "os" + + mcpserver "github.com/mark3labs/mcp-go/server" + "github.com/spf13/afero" + "github.com/spf13/cobra" + + "github.com/onflow/flowkit/v2" + "github.com/onflow/flowkit/v2/config" + "github.com/onflow/flowkit/v2/gateway" +) + +var Cmd = &cobra.Command{ + Use: "mcp", + Short: "Start the Cadence MCP server", + Long: `Start a Model Context Protocol (MCP) server for Cadence smart contract development. + +The server provides tools for checking Cadence code, inspecting types, +querying on-chain contracts, executing scripts, and reviewing code for +common issues. + +Claude Code: + claude mcp add cadence-mcp -- flow mcp + +Cursor / Claude Desktop (add to settings JSON): + { + "mcpServers": { + "cadence-mcp": { + "command": "flow", + "args": ["mcp"] + } + } + } + +Available tools: + cadence_check Check Cadence code for syntax and type errors + cadence_hover Get type info for a symbol at a position + cadence_definition Find where a symbol is defined + cadence_symbols List all symbols in Cadence code + cadence_completion Get completions at a position + get_contract_source Fetch on-chain contract manifest + get_contract_code Fetch contract source code from an address + cadence_code_review Review Cadence code for common issues + cadence_execute_script Execute a read-only Cadence script on-chain`, + Run: runMCP, +} + +func runMCP(cmd *cobra.Command, args []string) { + // Try to load flow.json for custom network configs + loader := &afero.Afero{Fs: afero.NewOsFs()} + state, _ := flowkit.Load(config.DefaultPaths(), loader) + + _ = state // will be used by tool handlers in subsequent tasks + + s := mcpserver.NewMCPServer("cadence-mcp", "1.0.0") + + // TODO: register tools in subsequent tasks + + if err := mcpserver.ServeStdio(s); err != nil { + fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err) + os.Exit(1) + } +} + +// resolveNetwork returns a config.Network for the given network name. +// Uses flow.json config if available, otherwise falls back to defaults. +func resolveNetwork(state *flowkit.State, network string) (*config.Network, error) { + if network == "" { + network = "mainnet" + } + + if state != nil { + net, err := state.Networks().ByName(network) + if err == nil { + return net, nil + } + } + + net, err := config.DefaultNetworks.ByName(network) + if err != nil { + return nil, fmt.Errorf("unknown network %q", network) + } + return net, nil +} + +// createGateway creates a gRPC gateway for the given network. +func createGateway(state *flowkit.State, network string) (gateway.Gateway, error) { + net, err := resolveNetwork(state, network) + if err != nil { + return nil, err + } + return gateway.NewGrpcGateway(*net) +} From 67b6f24905877060d2e04204cc59d095a1e2cae2 Mon Sep 17 00:00:00 2001 From: Peter Argue <89119817+peterargue@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:54:28 -0700 Subject: [PATCH 06/14] Add LSP wrapper for in-process Cadence language server --- internal/mcp/lsp.go | 377 +++++++++++++++++++++++++++++++++++++++ internal/mcp/lsp_test.go | 125 +++++++++++++ 2 files changed, 502 insertions(+) create mode 100644 internal/mcp/lsp.go create mode 100644 internal/mcp/lsp_test.go diff --git a/internal/mcp/lsp.go b/internal/mcp/lsp.go new file mode 100644 index 000000000..24f27a95b --- /dev/null +++ b/internal/mcp/lsp.go @@ -0,0 +1,377 @@ +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mcp + +import ( + "encoding/json" + "fmt" + "strings" + "sync" + + "github.com/onflow/cadence-tools/languageserver/integration" + "github.com/onflow/cadence-tools/languageserver/protocol" + "github.com/onflow/cadence-tools/languageserver/server" +) + +const scratchURI = protocol.DocumentURI("file:///mcp/scratch.cdc") + +// diagConn implements protocol.Conn and captures diagnostics published by the LSP server. +type diagConn struct { + mu sync.Mutex + diagnostics []protocol.Diagnostic +} + +func (c *diagConn) Notify(method string, params any) error { + if method == "textDocument/publishDiagnostics" { + // params may be encoded as JSON or as *protocol.PublishDiagnosticsParams + switch p := params.(type) { + case *protocol.PublishDiagnosticsParams: + c.captureDiagnostics(p.Diagnostics) + default: + // Try JSON round-trip for map types + data, err := json.Marshal(p) + if err == nil { + var pdp protocol.PublishDiagnosticsParams + if json.Unmarshal(data, &pdp) == nil { + c.captureDiagnostics(pdp.Diagnostics) + } + } + } + } + return nil +} + +func (c *diagConn) ShowMessage(_ *protocol.ShowMessageParams) {} + +func (c *diagConn) ShowMessageRequest(_ *protocol.ShowMessageRequestParams) (*protocol.MessageActionItem, error) { + return nil, nil +} + +func (c *diagConn) LogMessage(_ *protocol.LogMessageParams) {} + +func (c *diagConn) PublishDiagnostics(params *protocol.PublishDiagnosticsParams) error { + if params != nil { + c.captureDiagnostics(params.Diagnostics) + } + return nil +} + +func (c *diagConn) RegisterCapability(_ *protocol.RegistrationParams) error { + return nil +} + +func (c *diagConn) captureDiagnostics(diags []protocol.Diagnostic) { + c.mu.Lock() + defer c.mu.Unlock() + c.diagnostics = append(c.diagnostics, diags...) +} + +func (c *diagConn) reset() { + c.mu.Lock() + defer c.mu.Unlock() + c.diagnostics = nil +} + +func (c *diagConn) getDiagnostics() []protocol.Diagnostic { + c.mu.Lock() + defer c.mu.Unlock() + result := make([]protocol.Diagnostic, len(c.diagnostics)) + copy(result, c.diagnostics) + return result +} + +// LSPWrapper manages an in-process cadence-tools LSP server, +// handling document lifecycle and diagnostic capture. +type LSPWrapper struct { + server *server.Server + conn *diagConn + mu sync.Mutex + docVersion int32 + docOpen bool +} + +// NewLSPWrapper creates a new LSP wrapper with an in-process Cadence language server. +func NewLSPWrapper(enableFlowClient bool) (*LSPWrapper, error) { + s, err := server.NewServer() + if err != nil { + return nil, fmt.Errorf("creating LSP server: %w", err) + } + + _, err = integration.NewFlowIntegration(s, enableFlowClient) + if err != nil { + return nil, fmt.Errorf("creating flow integration: %w", err) + } + + conn := &diagConn{} + + _, err = s.Initialize(conn, &protocol.InitializeParams{ + XInitializeParams: protocol.XInitializeParams{ + InitializationOptions: map[string]any{ + "accessCheckMode": "strict", + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("initializing LSP server: %w", err) + } + + return &LSPWrapper{ + server: s, + conn: conn, + }, nil +} + +// updateDocument sends the code to the LSP server as a virtual document. +// Must be called with w.mu held. +func (w *LSPWrapper) updateDocument(code string) error { + w.docVersion++ + version := w.docVersion + + if !w.docOpen { + w.docOpen = true + return w.server.DidOpenTextDocument(w.conn, &protocol.DidOpenTextDocumentParams{ + TextDocument: protocol.TextDocumentItem{ + URI: scratchURI, + LanguageID: "cadence", + Version: version, + Text: code, + }, + }) + } + + return w.server.DidChangeTextDocument(w.conn, &protocol.DidChangeTextDocumentParams{ + TextDocument: protocol.VersionedTextDocumentIdentifier{ + TextDocumentIdentifier: protocol.TextDocumentIdentifier{ + URI: scratchURI, + }, + Version: version, + }, + ContentChanges: []protocol.TextDocumentContentChangeEvent{ + {Text: code}, + }, + }) +} + +// 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() + + if err := w.updateDocument(code); err != nil { + return nil, fmt.Errorf("updating document: %w", err) + } + + return w.conn.getDiagnostics(), nil +} + +// Hover returns hover information at the given position. +func (w *LSPWrapper) Hover(code string, line, character int, network string) (*protocol.Hover, error) { + w.mu.Lock() + defer w.mu.Unlock() + + w.conn.reset() + + if err := w.updateDocument(code); err != nil { + return nil, fmt.Errorf("updating document: %w", err) + } + + return w.server.Hover(w.conn, &protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: scratchURI}, + Position: protocol.Position{Line: uint32(line), Character: uint32(character)}, + }) +} + +// Definition returns the definition location for the symbol at the given position. +func (w *LSPWrapper) Definition(code string, line, character int, network string) (*protocol.Location, error) { + w.mu.Lock() + defer w.mu.Unlock() + + w.conn.reset() + + if err := w.updateDocument(code); err != nil { + return nil, fmt.Errorf("updating document: %w", err) + } + + return w.server.Definition(w.conn, &protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: scratchURI}, + Position: protocol.Position{Line: uint32(line), Character: uint32(character)}, + }) +} + +// Symbols returns the document symbols for the given code. +func (w *LSPWrapper) Symbols(code, network string) ([]*protocol.DocumentSymbol, error) { + w.mu.Lock() + defer w.mu.Unlock() + + w.conn.reset() + + if err := w.updateDocument(code); err != nil { + return nil, fmt.Errorf("updating document: %w", err) + } + + return w.server.DocumentSymbol(w.conn, &protocol.DocumentSymbolParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: scratchURI}, + }) +} + +// Completion returns completion items at the given position. +func (w *LSPWrapper) Completion(code string, line, character int, network string) ([]*protocol.CompletionItem, error) { + w.mu.Lock() + defer w.mu.Unlock() + + w.conn.reset() + + if err := w.updateDocument(code); err != nil { + return nil, fmt.Errorf("updating document: %w", err) + } + + return w.server.Completion(w.conn, &protocol.CompletionParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: scratchURI}, + Position: protocol.Position{Line: uint32(line), Character: uint32(character)}, + }, + }) +} + +// formatDiagnostics formats diagnostics as human-readable text. +func formatDiagnostics(diagnostics []protocol.Diagnostic) string { + if len(diagnostics) == 0 { + return "No errors found." + } + + var b strings.Builder + for _, d := range diagnostics { + severity := "error" + switch d.Severity { + case protocol.SeverityWarning: + severity = "warning" + case protocol.SeverityInformation: + severity = "info" + case protocol.SeverityHint: + severity = "hint" + } + fmt.Fprintf(&b, "[%s] line %d:%d: %s\n", severity, d.Range.Start.Line+1, d.Range.Start.Character+1, d.Message) + } + return b.String() +} + +// formatHover formats a hover result as human-readable text. +func formatHover(result *protocol.Hover) string { + if result == nil { + return "No hover information available." + } + return result.Contents.Value +} + +// formatSymbols formats document symbols as an indented tree. +// Accepts []*protocol.DocumentSymbol (from the server API). +func formatSymbols(symbols []*protocol.DocumentSymbol, indent int) string { + var b strings.Builder + prefix := strings.Repeat(" ", indent) + for _, s := range symbols { + fmt.Fprintf(&b, "%s%s %s", prefix, symbolKindName(s.Kind), s.Name) + if s.Detail != "" { + fmt.Fprintf(&b, " — %s", s.Detail) + } + b.WriteString("\n") + if len(s.Children) > 0 { + b.WriteString(formatSymbolValues(s.Children, indent+1)) + } + } + return b.String() +} + +// formatSymbolValues formats []protocol.DocumentSymbol (value type, used for Children). +func formatSymbolValues(symbols []protocol.DocumentSymbol, indent int) string { + var b strings.Builder + prefix := strings.Repeat(" ", indent) + for _, s := range symbols { + fmt.Fprintf(&b, "%s%s %s", prefix, symbolKindName(s.Kind), s.Name) + if s.Detail != "" { + fmt.Fprintf(&b, " — %s", s.Detail) + } + b.WriteString("\n") + if len(s.Children) > 0 { + b.WriteString(formatSymbolValues(s.Children, indent+1)) + } + } + return b.String() +} + +// symbolKindName returns a human-readable name for a SymbolKind. +func symbolKindName(kind protocol.SymbolKind) string { + switch kind { + case protocol.File: + return "File" + case protocol.Module: + return "Module" + case protocol.Namespace: + return "Namespace" + case protocol.Package: + return "Package" + case protocol.Class: + return "Class" + case protocol.Method: + return "Method" + case protocol.Property: + return "Property" + case protocol.Field: + return "Field" + case protocol.Constructor: + return "Constructor" + case protocol.Enum: + return "Enum" + case protocol.Interface: + return "Interface" + case protocol.Function: + return "Function" + case protocol.Variable: + return "Variable" + case protocol.Constant: + return "Constant" + case protocol.String: + return "String" + case protocol.Number: + return "Number" + case protocol.Boolean: + return "Boolean" + case protocol.Array: + return "Array" + case protocol.Object: + return "Object" + case protocol.Key: + return "Key" + case protocol.Null: + return "Null" + case protocol.EnumMember: + return "EnumMember" + case protocol.Struct: + return "Struct" + case protocol.Event: + return "Event" + case protocol.Operator: + return "Operator" + case protocol.TypeParameter: + return "TypeParameter" + default: + return fmt.Sprintf("SymbolKind(%d)", kind) + } +} diff --git a/internal/mcp/lsp_test.go b/internal/mcp/lsp_test.go new file mode 100644 index 000000000..ee919cd27 --- /dev/null +++ b/internal/mcp/lsp_test.go @@ -0,0 +1,125 @@ +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestWrapper(t *testing.T) *LSPWrapper { + t.Helper() + w, err := NewLSPWrapper(false) + require.NoError(t, err) + require.NotNil(t, w) + return w +} + +func TestLSPWrapper_Check_ValidCode(t *testing.T) { + w := newTestWrapper(t) + + code := ` + access(all) fun hello(): String { + return "hello" + } + ` + diags, err := w.Check(code, "") + require.NoError(t, err) + assert.Empty(t, diags, "valid code should produce no diagnostics") +} + +func TestLSPWrapper_Check_InvalidCode(t *testing.T) { + w := newTestWrapper(t) + + // Type mismatch: returning Int from a String function + code := ` + access(all) fun hello(): String { + return 42 + } + ` + diags, err := w.Check(code, "") + require.NoError(t, err) + assert.NotEmpty(t, diags, "type mismatch should produce diagnostics") +} + +func TestLSPWrapper_Check_SyntaxError(t *testing.T) { + w := newTestWrapper(t) + + code := ` + access(all) fun hello( { + ` + diags, err := w.Check(code, "") + require.NoError(t, err) + assert.NotEmpty(t, diags, "syntax error should produce diagnostics") +} + +func TestLSPWrapper_Hover(t *testing.T) { + w := newTestWrapper(t) + + code := ` +access(all) fun hello(): String { + return "hello" +} +` + // Hover over "String" return type — line 1 (0-based), find the position of "String" + result, err := w.Hover(code, 1, 25, "") + require.NoError(t, err) + // Hover may or may not return a result depending on the position; + // we just verify it doesn't error. If non-nil, it should have contents. + if result != nil { + assert.NotEmpty(t, result.Contents.Value) + } +} + +func TestLSPWrapper_Symbols(t *testing.T) { + w := newTestWrapper(t) + + code := ` +access(all) contract MyContract { + access(all) fun greet(): String { + return "hi" + } +} +` + symbols, err := w.Symbols(code, "") + require.NoError(t, err) + require.NotEmpty(t, symbols, "contract with members should have symbols") + + // The top-level symbol should be the contract + assert.Equal(t, "MyContract", symbols[0].Name) +} + +func TestLSPWrapper_Completion(t *testing.T) { + w := newTestWrapper(t) + + // Inside a function body, the LSP should offer completions + code := ` +access(all) fun main() { + let x: String = "hello" + x. +} +` + // Position right after "x." — line 3, character 3 + items, err := w.Completion(code, 3, 3, "") + require.NoError(t, err) + // String methods should appear as completions + assert.NotEmpty(t, items, "should get completion items for String methods") +} From 773a2bf2845d3d8c3b693f9c7da344f40b64cfd4 Mon Sep 17 00:00:00 2001 From: Peter Argue <89119817+peterargue@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:01:55 -0700 Subject: [PATCH 07/14] Add tool handler implementations and wire into MCP server --- internal/mcp/mcp.go | 16 +- internal/mcp/tools.go | 403 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 416 insertions(+), 3 deletions(-) create mode 100644 internal/mcp/tools.go diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 0e8213502..7a5153da0 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -71,11 +71,21 @@ func runMCP(cmd *cobra.Command, args []string) { loader := &afero.Afero{Fs: afero.NewOsFs()} state, _ := flowkit.Load(config.DefaultPaths(), loader) - _ = state // will be used by tool handlers in subsequent tasks + // Initialize the LSP wrapper (without flow client for MCP use). + var lsp *LSPWrapper + if w, err := NewLSPWrapper(false); err == nil { + lsp = w + } else { + fmt.Fprintf(os.Stderr, "Warning: LSP initialization failed, LSP tools will be unavailable: %v\n", err) + } - s := mcpserver.NewMCPServer("cadence-mcp", "1.0.0") + mctx := &mcpContext{ + lsp: lsp, + state: state, + } - // TODO: register tools in subsequent tasks + s := mcpserver.NewMCPServer("cadence-mcp", "1.0.0") + registerTools(s, mctx) if err := mcpserver.ServeStdio(s); err != nil { fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err) diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go new file mode 100644 index 000000000..aacb535bc --- /dev/null +++ b/internal/mcp/tools.go @@ -0,0 +1,403 @@ +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + mcplib "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + "github.com/onflow/cadence" + flow "github.com/onflow/flow-go-sdk" + + "github.com/onflow/flowkit/v2" + "github.com/onflow/flowkit/v2/arguments" +) + +// mcpContext holds shared dependencies for all MCP tool handlers. +type mcpContext struct { + lsp *LSPWrapper + state *flowkit.State // may be nil +} + +// registerTools registers all MCP tools on the given server. +func registerTools(s *mcpserver.MCPServer, mctx *mcpContext) { + // LSP tools — only register if the LSP wrapper is available. + if mctx.lsp != nil { + s.AddTool( + mcplib.NewTool("cadence_check", + mcplib.WithDescription("Check Cadence code for syntax and type errors"), + mcplib.WithString("code", mcplib.Required(), mcplib.Description("Cadence source code to check")), + mcplib.WithString("network", mcplib.Description("Flow network for address resolution"), mcplib.Enum("mainnet", "testnet", "emulator")), + ), + mctx.cadenceCheck, + ) + + s.AddTool( + mcplib.NewTool("cadence_hover", + mcplib.WithDescription("Get type information for a symbol at a position in Cadence code"), + mcplib.WithString("code", mcplib.Required(), mcplib.Description("Cadence source code")), + mcplib.WithNumber("line", mcplib.Required(), mcplib.Description("0-based line number")), + mcplib.WithNumber("character", mcplib.Required(), mcplib.Description("0-based column number")), + mcplib.WithString("network", mcplib.Description("Flow network for address resolution"), mcplib.Enum("mainnet", "testnet", "emulator")), + ), + mctx.cadenceHover, + ) + + s.AddTool( + mcplib.NewTool("cadence_definition", + mcplib.WithDescription("Find where a symbol is defined in Cadence code"), + mcplib.WithString("code", mcplib.Required(), mcplib.Description("Cadence source code")), + mcplib.WithNumber("line", mcplib.Required(), mcplib.Description("0-based line number")), + mcplib.WithNumber("character", mcplib.Required(), mcplib.Description("0-based column number")), + mcplib.WithString("network", mcplib.Description("Flow network for address resolution"), mcplib.Enum("mainnet", "testnet", "emulator")), + ), + mctx.cadenceDefinition, + ) + + s.AddTool( + mcplib.NewTool("cadence_symbols", + mcplib.WithDescription("List all symbols in Cadence code"), + mcplib.WithString("code", mcplib.Required(), mcplib.Description("Cadence source code")), + mcplib.WithString("network", mcplib.Description("Flow network for address resolution"), mcplib.Enum("mainnet", "testnet", "emulator")), + ), + mctx.cadenceSymbols, + ) + + s.AddTool( + mcplib.NewTool("cadence_completion", + mcplib.WithDescription("Get completion suggestions at a position in Cadence code"), + mcplib.WithString("code", mcplib.Required(), mcplib.Description("Cadence source code")), + mcplib.WithNumber("line", mcplib.Required(), mcplib.Description("0-based line number")), + mcplib.WithNumber("character", mcplib.Required(), mcplib.Description("0-based column number")), + mcplib.WithString("network", mcplib.Description("Flow network for address resolution"), mcplib.Enum("mainnet", "testnet", "emulator")), + ), + mctx.cadenceCompletion, + ) + } + + // Audit / network tools — always registered. + s.AddTool( + mcplib.NewTool("get_contract_source", + mcplib.WithDescription("Fetch on-chain contract manifest (names and sizes) for a Flow account"), + mcplib.WithString("address", mcplib.Required(), mcplib.Description("Flow account address (hex, with or without 0x prefix)")), + mcplib.WithString("network", mcplib.Description("Flow network to query"), mcplib.Enum("mainnet", "testnet", "emulator")), + ), + mctx.getContractSource, + ) + + s.AddTool( + mcplib.NewTool("get_contract_code", + mcplib.WithDescription("Fetch contract source code from a Flow account"), + mcplib.WithString("address", mcplib.Required(), mcplib.Description("Flow account address (hex, with or without 0x prefix)")), + mcplib.WithString("contract_name", mcplib.Description("Specific contract name to retrieve; omit for all contracts")), + mcplib.WithString("network", mcplib.Description("Flow network to query"), mcplib.Enum("mainnet", "testnet", "emulator")), + ), + mctx.getContractCode, + ) + + s.AddTool( + mcplib.NewTool("cadence_code_review", + mcplib.WithDescription("Review Cadence code for common issues and anti-patterns"), + mcplib.WithString("code", mcplib.Required(), mcplib.Description("Cadence source code to review")), + mcplib.WithString("network", mcplib.Description("Flow network for address resolution"), mcplib.Enum("mainnet", "testnet", "emulator")), + ), + mctx.cadenceCodeReview, + ) + + s.AddTool( + mcplib.NewTool("cadence_execute_script", + mcplib.WithDescription("Execute a read-only Cadence script on-chain"), + mcplib.WithString("code", mcplib.Required(), mcplib.Description("Cadence script source code")), + mcplib.WithString("network", mcplib.Description("Flow network to execute against"), mcplib.Enum("mainnet", "testnet", "emulator")), + mcplib.WithString("arguments", mcplib.Description("JSON array of arguments as strings, e.g. [\"String:hello\", \"UFix64:1.0\"]")), + ), + mctx.cadenceExecuteScript, + ) +} + +// --------------------------------------------------------------------------- +// LSP tool handlers +// --------------------------------------------------------------------------- + +func (m *mcpContext) cadenceCheck(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + code, err := req.RequireString("code") + if err != nil { + return mcplib.NewToolResultError(err.Error()), nil + } + network := req.GetString("network", "mainnet") + + diags, err := m.lsp.Check(code, network) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("LSP check failed: %v", err)), nil + } + return mcplib.NewToolResultText(formatDiagnostics(diags)), nil +} + +func (m *mcpContext) cadenceHover(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + code, err := req.RequireString("code") + if err != nil { + return mcplib.NewToolResultError(err.Error()), nil + } + line, err := req.RequireInt("line") + if err != nil { + return mcplib.NewToolResultError(err.Error()), nil + } + character, err := req.RequireInt("character") + if err != nil { + return mcplib.NewToolResultError(err.Error()), nil + } + network := req.GetString("network", "mainnet") + + result, err := m.lsp.Hover(code, line, character, network) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("LSP hover failed: %v", err)), nil + } + return mcplib.NewToolResultText(formatHover(result)), nil +} + +func (m *mcpContext) cadenceDefinition(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + code, err := req.RequireString("code") + if err != nil { + return mcplib.NewToolResultError(err.Error()), nil + } + line, err := req.RequireInt("line") + if err != nil { + return mcplib.NewToolResultError(err.Error()), nil + } + character, err := req.RequireInt("character") + if err != nil { + return mcplib.NewToolResultError(err.Error()), nil + } + network := req.GetString("network", "mainnet") + + loc, err := m.lsp.Definition(code, line, character, network) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("LSP definition failed: %v", err)), nil + } + if loc == nil { + return mcplib.NewToolResultText("No definition found."), nil + } + return mcplib.NewToolResultText(fmt.Sprintf("%s line %d:%d", + loc.URI, loc.Range.Start.Line+1, loc.Range.Start.Character+1)), nil +} + +func (m *mcpContext) cadenceSymbols(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + code, err := req.RequireString("code") + if err != nil { + return mcplib.NewToolResultError(err.Error()), nil + } + network := req.GetString("network", "mainnet") + + symbols, err := m.lsp.Symbols(code, network) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("LSP symbols failed: %v", err)), nil + } + return mcplib.NewToolResultText(formatSymbols(symbols, 0)), nil +} + +func (m *mcpContext) cadenceCompletion(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + code, err := req.RequireString("code") + if err != nil { + return mcplib.NewToolResultError(err.Error()), nil + } + line, err := req.RequireInt("line") + if err != nil { + return mcplib.NewToolResultError(err.Error()), nil + } + character, err := req.RequireInt("character") + if err != nil { + return mcplib.NewToolResultError(err.Error()), nil + } + network := req.GetString("network", "mainnet") + + items, err := m.lsp.Completion(code, line, character, network) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("LSP completion failed: %v", err)), nil + } + + var b strings.Builder + for _, item := range items { + b.WriteString(item.Label) + if item.Detail != "" { + fmt.Fprintf(&b, " — %s", item.Detail) + } + b.WriteString("\n") + } + if b.Len() == 0 { + return mcplib.NewToolResultText("No completions available."), nil + } + return mcplib.NewToolResultText(b.String()), nil +} + +// --------------------------------------------------------------------------- +// Audit / network tool handlers +// --------------------------------------------------------------------------- + +func (m *mcpContext) getContractSource(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + address, err := req.RequireString("address") + if err != nil { + return mcplib.NewToolResultError(err.Error()), nil + } + 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 + } + + 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 + } + + type contractInfo struct { + Name string `json:"name"` + Size int `json:"size"` + } + + contracts := make([]contractInfo, 0, len(account.Contracts)) + for name, code := range account.Contracts { + contracts = append(contracts, contractInfo{Name: name, Size: len(code)}) + } + sort.Slice(contracts, func(i, j int) bool { + return contracts[i].Name < contracts[j].Name + }) + + result := struct { + Address string `json:"address"` + Contracts []contractInfo `json:"contracts"` + }{ + Address: addr.String(), + Contracts: contracts, + } + + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + } + return mcplib.NewToolResultText(string(data)), nil +} + +func (m *mcpContext) getContractCode(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + address, err := req.RequireString("address") + if err != nil { + return mcplib.NewToolResultError(err.Error()), nil + } + contractName := req.GetString("contract_name", "") + 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 + } + + 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 + } + + if contractName != "" { + code, ok := account.Contracts[contractName] + if !ok { + return mcplib.NewToolResultError(fmt.Sprintf("contract %q not found on account %s", contractName, addr.String())), nil + } + return mcplib.NewToolResultText(string(code)), nil + } + + // Return all contracts. + var b strings.Builder + names := make([]string, 0, len(account.Contracts)) + for name := range account.Contracts { + names = append(names, name) + } + sort.Strings(names) + + for i, name := range names { + if i > 0 { + b.WriteString("\n\n") + } + fmt.Fprintf(&b, "// === %s ===\n%s", name, string(account.Contracts[name])) + } + if b.Len() == 0 { + return mcplib.NewToolResultText("No contracts found on this account."), nil + } + return mcplib.NewToolResultText(b.String()), nil +} + +func (m *mcpContext) cadenceCodeReview(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + code, err := req.RequireString("code") + if err != nil { + return mcplib.NewToolResultError(err.Error()), nil + } + network := req.GetString("network", "mainnet") + + result := codeReview(code) + text := formatReviewResult(result) + + // If LSP is available, also run a check and append diagnostics. + if m.lsp != nil { + diags, lspErr := m.lsp.Check(code, network) + if lspErr == nil && len(diags) > 0 { + text += "\nLSP diagnostics:\n" + formatDiagnostics(diags) + } + } + + return mcplib.NewToolResultText(text), nil +} + +func (m *mcpContext) cadenceExecuteScript(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + code, err := req.RequireString("code") + if err != nil { + return mcplib.NewToolResultError(err.Error()), nil + } + network := req.GetString("network", "mainnet") + argsJSON := req.GetString("arguments", "") + + gw, err := createGateway(m.state, network) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("failed to create gateway: %v", err)), nil + } + + var cadenceArgs []cadence.Value + if argsJSON != "" { + var argStrings []string + if jsonErr := json.Unmarshal([]byte(argsJSON), &argStrings); jsonErr != nil { + return mcplib.NewToolResultError(fmt.Sprintf("failed to parse arguments JSON: %v", jsonErr)), nil + } + parsed, parseErr := arguments.ParseWithoutType(argStrings, []byte(code), "") + if parseErr != nil { + return mcplib.NewToolResultError(fmt.Sprintf("failed to parse arguments: %v", parseErr)), nil + } + cadenceArgs = parsed + } + + val, err := gw.ExecuteScript(ctx, []byte(code), cadenceArgs) + if err != nil { + return mcplib.NewToolResultError(fmt.Sprintf("script execution failed: %v", err)), nil + } + + return mcplib.NewToolResultText(val.String()), nil +} From 75fac3efce6e60fe305b6ba9d8cdc84c9400259d Mon Sep 17 00:00:00 2001 From: Peter Argue <89119817+peterargue@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:03:52 -0700 Subject: [PATCH 08/14] Add tool handler tests --- internal/mcp/tools_test.go | 124 +++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 internal/mcp/tools_test.go diff --git a/internal/mcp/tools_test.go b/internal/mcp/tools_test.go new file mode 100644 index 000000000..a16309a52 --- /dev/null +++ b/internal/mcp/tools_test.go @@ -0,0 +1,124 @@ +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mcp + +import ( + "context" + "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestContext(t *testing.T) *mcpContext { + t.Helper() + lsp, err := NewLSPWrapper(false) + require.NoError(t, err) + return &mcpContext{lsp: lsp} +} + +func TestTool_CadenceCheck_Valid(t *testing.T) { + mctx := newTestContext(t) + + req := mcplib.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "code": `access(all) fun hello(): String { return "hello" }`, + } + + result, err := mctx.cadenceCheck(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) + + textContent := result.Content[0].(mcplib.TextContent) + assert.Contains(t, textContent.Text, "No errors found") +} + +func TestTool_CadenceCheck_Invalid(t *testing.T) { + mctx := newTestContext(t) + + req := mcplib.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "code": `access(all) fun hello(): String { return 42 }`, + } + + result, err := mctx.cadenceCheck(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, result) + + textContent := result.Content[0].(mcplib.TextContent) + assert.Contains(t, textContent.Text, "error") +} + +func TestTool_CadenceCheck_MissingCode(t *testing.T) { + mctx := newTestContext(t) + + req := mcplib.CallToolRequest{} + req.Params.Arguments = map[string]any{} + + result, err := mctx.cadenceCheck(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) +} + +func TestTool_CadenceSymbols(t *testing.T) { + mctx := newTestContext(t) + + req := mcplib.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "code": ` +access(all) contract MyContract { + access(all) fun greet(): String { + return "hi" + } +} +`, + } + + result, err := mctx.cadenceSymbols(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) + + textContent := result.Content[0].(mcplib.TextContent) + assert.Contains(t, textContent.Text, "MyContract") +} + +func TestTool_CadenceCodeReview(t *testing.T) { + mctx := newTestContext(t) + + req := mcplib.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "code": ` +access(all) contract MyContract { + access(all) var balance: UFix64 +} +`, + } + + result, err := mctx.cadenceCodeReview(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) + + textContent := result.Content[0].(mcplib.TextContent) + assert.Contains(t, textContent.Text, "overly-permissive-access") +} From 14bf6b0f4c50f9cb3a42c39f7c10cef442a6e61b Mon Sep 17 00:00:00 2001 From: Peter Argue <89119817+peterargue@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:05:56 -0700 Subject: [PATCH 09/14] Add integration tests for network tools --- internal/mcp/integration_test.go | 88 ++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 internal/mcp/integration_test.go diff --git a/internal/mcp/integration_test.go b/internal/mcp/integration_test.go new file mode 100644 index 000000000..0ea963f42 --- /dev/null +++ b/internal/mcp/integration_test.go @@ -0,0 +1,88 @@ +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mcp + +import ( + "context" + "os" + "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func skipIfNoNetwork(t *testing.T) { + t.Helper() + if os.Getenv("SKIP_NETWORK_TESTS") != "" { + t.Skip("Skipping network test (SKIP_NETWORK_TESTS is set)") + } +} + +func TestIntegration_GetContractSource(t *testing.T) { + skipIfNoNetwork(t) + + mctx := &mcpContext{state: nil} + req := mcplib.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "address": "0x1654653399040a61", + "network": "mainnet", + } + + result, err := mctx.getContractSource(context.Background(), req) + require.NoError(t, err) + assert.False(t, result.IsError) + text := result.Content[0].(mcplib.TextContent).Text + assert.Contains(t, text, "FungibleToken") +} + +func TestIntegration_GetContractCode(t *testing.T) { + skipIfNoNetwork(t) + + mctx := &mcpContext{state: nil} + req := mcplib.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "address": "0x1654653399040a61", + "contract_name": "FungibleToken", + "network": "mainnet", + } + + result, err := mctx.getContractCode(context.Background(), req) + require.NoError(t, err) + assert.False(t, result.IsError) + text := result.Content[0].(mcplib.TextContent).Text + assert.Contains(t, text, "FungibleToken") +} + +func TestIntegration_ExecuteScript(t *testing.T) { + skipIfNoNetwork(t) + + mctx := &mcpContext{state: nil} + req := mcplib.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "code": `access(all) fun main(): Int { return 42 }`, + "network": "mainnet", + } + + result, err := mctx.cadenceExecuteScript(context.Background(), req) + require.NoError(t, err) + assert.False(t, result.IsError) + text := result.Content[0].(mcplib.TextContent).Text + assert.Contains(t, text, "42") +} From 24683bb2564f47f544bf1eefa42ac9b15de4c211 Mon Sep 17 00:00:00 2001 From: Peter Argue <89119817+peterargue@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:06:44 -0700 Subject: [PATCH 10/14] Fix integration test expectations for FlowToken address --- internal/mcp/integration_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/mcp/integration_test.go b/internal/mcp/integration_test.go index 0ea963f42..f6d409234 100644 --- a/internal/mcp/integration_test.go +++ b/internal/mcp/integration_test.go @@ -49,7 +49,7 @@ func TestIntegration_GetContractSource(t *testing.T) { require.NoError(t, err) assert.False(t, result.IsError) text := result.Content[0].(mcplib.TextContent).Text - assert.Contains(t, text, "FungibleToken") + assert.Contains(t, text, "FlowToken") } func TestIntegration_GetContractCode(t *testing.T) { @@ -59,7 +59,7 @@ func TestIntegration_GetContractCode(t *testing.T) { req := mcplib.CallToolRequest{} req.Params.Arguments = map[string]any{ "address": "0x1654653399040a61", - "contract_name": "FungibleToken", + "contract_name": "FlowToken", "network": "mainnet", } @@ -67,7 +67,7 @@ func TestIntegration_GetContractCode(t *testing.T) { require.NoError(t, err) assert.False(t, result.IsError) text := result.Content[0].(mcplib.TextContent).Text - assert.Contains(t, text, "FungibleToken") + assert.Contains(t, text, "FlowToken") } func TestIntegration_ExecuteScript(t *testing.T) { From f6743fd53661aa0a030cf51a0e5e40c5da1122b5 Mon Sep 17 00:00:00 2001 From: Peter Argue <89119817+peterargue@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:07:21 -0700 Subject: [PATCH 11/14] Add code review rules for cadence_code_review tool --- internal/mcp/audit.go | 203 +++++++++++++++++++++++++++++++++++++ internal/mcp/audit_test.go | 171 +++++++++++++++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 internal/mcp/audit.go create mode 100644 internal/mcp/audit_test.go diff --git a/internal/mcp/audit.go b/internal/mcp/audit.go new file mode 100644 index 000000000..6600776dc --- /dev/null +++ b/internal/mcp/audit.go @@ -0,0 +1,203 @@ +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mcp + +import ( + "fmt" + "regexp" + "sort" + "strings" +) + +// Severity represents the severity level of a code review finding. +type Severity string + +const ( + SeverityWarning Severity = "warning" + SeverityNote Severity = "note" + SeverityInfo Severity = "info" +) + +// Finding represents a single code review finding. +type Finding struct { + Rule string `json:"rule"` + Severity Severity `json:"severity"` + Line int `json:"line"` + Message string `json:"message"` +} + +// ReviewResult holds all findings from a code review along with a summary. +type ReviewResult struct { + Findings []Finding `json:"findings"` + Summary map[string]int `json:"summary"` +} + +// reviewRule defines a single regex-based code review rule. +type reviewRule struct { + id string + severity Severity + pattern *regexp.Regexp + message func(match []string) string +} + +var addressImportPattern = regexp.MustCompile(`^\s*import\s+\w[\w, ]*\s+from\s+0x`) + +var reviewRules = []reviewRule{ + { + id: "overly-permissive-access", + severity: SeverityWarning, + pattern: regexp.MustCompile(`access\(all\)\s+(var|let)\s+`), + message: func(_ []string) string { + return "State field with access(all) — consider restricting access with entitlements" + }, + }, + { + id: "overly-permissive-function", + severity: SeverityNote, + pattern: regexp.MustCompile(`access\(all\)\s+fun\s+(\w+)`), + message: func(match []string) string { + name := "" + if len(match) > 1 { + name = match[1] + } + return fmt.Sprintf("Function '%s' has access(all) — review if public access is intended", name) + }, + }, + { + id: "deprecated-pub", + severity: SeverityInfo, + pattern: regexp.MustCompile(`\bpub\s+(var|let|fun|resource|struct|event|contract|enum)\b`), + message: func(_ []string) string { + return "`pub` is deprecated in Cadence 1.0 — use `access(all)` or a more restrictive access modifier" + }, + }, + { + id: "unsafe-force-unwrap", + severity: SeverityNote, + pattern: regexp.MustCompile(`[)\w]\s*!`), + message: func(_ []string) string { + return "Force-unwrap (!) used — consider nil-coalescing (??) or optional binding for safer handling" + }, + }, + { + id: "auth-account-exposure", + severity: SeverityWarning, + pattern: regexp.MustCompile(`\bAuthAccount\b`), + message: func(_ []string) string { + return "AuthAccount reference found — passing AuthAccount gives full account access, use capabilities instead" + }, + }, + { + id: "auth-reference-exposure", + severity: SeverityWarning, + pattern: regexp.MustCompile(`\bauth\s*\(.*?\)\s*&Account\b`), + message: func(_ []string) string { + return "auth(…) &Account reference found — this grants broad account access, prefer scoped capabilities" + }, + }, + { + id: "hardcoded-address", + severity: SeverityInfo, + pattern: regexp.MustCompile(`0x[0-9a-fA-F]{8,16}\b`), + message: func(_ []string) string { + return "Hardcoded address detected — consider using named address imports for portability" + }, + }, + { + id: "unguarded-capability", + severity: SeverityWarning, + pattern: regexp.MustCompile(`\.publish\s*\(`), + message: func(_ []string) string { + return "Capability published — verify that proper entitlements guard this capability" + }, + }, + { + id: "resource-loss-destroy", + severity: SeverityWarning, + pattern: regexp.MustCompile(`destroy\s*\(`), + message: func(_ []string) string { + return "Explicit destroy call — ensure the resource is intentionally being destroyed and not lost" + }, + }, +} + +// codeReview runs all rules against the provided Cadence source code and returns +// a ReviewResult with findings sorted by line number. +func codeReview(code string) ReviewResult { + lines := strings.Split(code, "\n") + var findings []Finding + + for lineIdx, line := range lines { + lineNum := lineIdx + 1 + for _, rule := range reviewRules { + // Special case: skip hardcoded-address on import-from-address lines. + if rule.id == "hardcoded-address" && addressImportPattern.MatchString(line) { + continue + } + + match := rule.pattern.FindStringSubmatch(line) + if match != nil { + findings = append(findings, Finding{ + Rule: rule.id, + Severity: rule.severity, + Line: lineNum, + Message: rule.message(match), + }) + } + } + } + + sort.Slice(findings, func(i, j int) bool { + return findings[i].Line < findings[j].Line + }) + + summary := map[string]int{ + string(SeverityWarning): 0, + string(SeverityNote): 0, + string(SeverityInfo): 0, + } + for _, f := range findings { + summary[string(f.Severity)]++ + } + + return ReviewResult{ + Findings: findings, + Summary: summary, + } +} + +// formatReviewResult formats a ReviewResult as human-readable text. +func formatReviewResult(result ReviewResult) string { + if len(result.Findings) == 0 { + return "No findings.\n" + } + + var sb strings.Builder + for _, f := range result.Findings { + sb.WriteString(fmt.Sprintf("[%s] line %d (%s): %s\n", f.Severity, f.Line, f.Rule, f.Message)) + } + + sb.WriteString(fmt.Sprintf("\nSummary: %d warning(s), %d note(s), %d info(s)\n", + result.Summary[string(SeverityWarning)], + result.Summary[string(SeverityNote)], + result.Summary[string(SeverityInfo)], + )) + + return sb.String() +} diff --git a/internal/mcp/audit_test.go b/internal/mcp/audit_test.go new file mode 100644 index 000000000..0bc19ff0f --- /dev/null +++ b/internal/mcp/audit_test.go @@ -0,0 +1,171 @@ +/* + * Flow CLI + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mcp + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCodeReview_CleanCode(t *testing.T) { + code := ` +access(all) contract MyContract { + access(self) var counter: Int + + init() { + self.counter = 0 + } + + access(all) fun getCounter(): Int { + return self.counter + } +} +` + result := codeReview(code) + // overly-permissive-function will fire for "getCounter", so we only check + // that no warnings or hard-error-class findings exist from the truly + // problematic rules. + for _, f := range result.Findings { + assert.NotEqual(t, "overly-permissive-access", f.Rule) + assert.NotEqual(t, "deprecated-pub", f.Rule) + assert.NotEqual(t, "unsafe-force-unwrap", f.Rule) + assert.NotEqual(t, "auth-account-exposure", f.Rule) + assert.NotEqual(t, "hardcoded-address", f.Rule) + } +} + +func TestCodeReview_OverlyPermissiveAccess(t *testing.T) { + code := ` +access(all) contract MyContract { + access(all) var balance: UFix64 + access(all) let name: String +} +` + result := codeReview(code) + + var found []Finding + for _, f := range result.Findings { + if f.Rule == "overly-permissive-access" { + found = append(found, f) + } + } + require.Len(t, found, 2) + assert.Equal(t, SeverityWarning, found[0].Severity) + assert.Contains(t, found[0].Message, "access(all)") +} + +func TestCodeReview_DeprecatedPub(t *testing.T) { + code := ` +pub fun greet(): String { + return "hello" +} +` + result := codeReview(code) + + var found []Finding + for _, f := range result.Findings { + if f.Rule == "deprecated-pub" { + found = append(found, f) + } + } + require.Len(t, found, 1) + assert.Equal(t, SeverityInfo, found[0].Severity) + assert.Equal(t, 2, found[0].Line) + assert.Contains(t, found[0].Message, "pub") +} + +func TestCodeReview_ForceUnwrap(t *testing.T) { + code := ` +let value = someOptional! +let other = foo()! +` + result := codeReview(code) + + var found []Finding + for _, f := range result.Findings { + if f.Rule == "unsafe-force-unwrap" { + found = append(found, f) + } + } + require.GreaterOrEqual(t, len(found), 1) + assert.Equal(t, SeverityNote, found[0].Severity) + assert.Contains(t, found[0].Message, "Force-unwrap") +} + +func TestCodeReview_HardcodedAddress(t *testing.T) { + code := ` +let addr: Address = 0x1234567890abcdef +` + result := codeReview(code) + + var found []Finding + for _, f := range result.Findings { + if f.Rule == "hardcoded-address" { + found = append(found, f) + } + } + require.Len(t, found, 1) + assert.Equal(t, SeverityInfo, found[0].Severity) + assert.Contains(t, found[0].Message, "Hardcoded address") +} + +func TestCodeReview_AddressImportNotFlagged(t *testing.T) { + code := ` +import FungibleToken from 0xf233dcee88fe0abe +import NonFungibleToken from 0x1d7e57aa55817448 +` + result := codeReview(code) + + for _, f := range result.Findings { + assert.NotEqual(t, "hardcoded-address", f.Rule, + "import-from-address lines should not trigger hardcoded-address rule") + } +} + +func TestCodeReview_FormatResult(t *testing.T) { + result := ReviewResult{ + Findings: []Finding{ + {Rule: "overly-permissive-access", Severity: SeverityWarning, Line: 3, Message: "State field with access(all) — consider restricting access with entitlements"}, + {Rule: "deprecated-pub", Severity: SeverityInfo, Line: 7, Message: "`pub` is deprecated in Cadence 1.0 — use `access(all)` or a more restrictive access modifier"}, + }, + Summary: map[string]int{ + string(SeverityWarning): 1, + string(SeverityNote): 0, + string(SeverityInfo): 1, + }, + } + + output := formatReviewResult(result) + + assert.Contains(t, output, "[warning]") + assert.Contains(t, output, "line 3") + assert.Contains(t, output, "overly-permissive-access") + assert.Contains(t, output, "[info]") + assert.Contains(t, output, "line 7") + assert.Contains(t, output, "deprecated-pub") + assert.Contains(t, output, "Summary:") + assert.Contains(t, output, "1 warning(s)") + assert.Contains(t, output, "1 info(s)") + + lines := strings.Split(strings.TrimSpace(output), "\n") + assert.GreaterOrEqual(t, len(lines), 3) +} From 8d2882705aacc3f5b1da509f854aebc793a6a0a9 Mon Sep 17 00:00:00 2001 From: Peter Argue <89119817+peterargue@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:10:19 -0700 Subject: [PATCH 12/14] Upgrade buger/jsonparser to v1.1.2 to fix CVE --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 96a09a131..bde81a941 100644 --- a/go.mod +++ b/go.mod @@ -67,7 +67,7 @@ require ( github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.0.3 // indirect - github.com/buger/jsonparser v1.1.1 // indirect + github.com/buger/jsonparser v1.1.2 // indirect github.com/c-bata/go-prompt v0.2.6 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash v1.1.0 // indirect diff --git a/go.sum b/go.sum index b363f7cbb..ffc7f369a 100644 --- a/go.sum +++ b/go.sum @@ -110,6 +110,8 @@ github.com/btcsuite/btcd/chaincfg/chainhash v1.0.3 h1:SDlJ7bAm4ewvrmZtR0DaiYbQGd github.com/btcsuite/btcd/chaincfg/chainhash v1.0.3/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/sonic v1.11.5 h1:G00FYjjqll5iQ1PYXynbg/hyzqBqavH8Mo9/oTopd9k= github.com/bytedance/sonic v1.11.5/go.mod h1:X2PC2giUdj/Cv2lliWFLk6c/DUQok5rViJSemeB0wDw= github.com/bytedance/sonic/loader v0.1.0 h1:skjHJ2Bi9ibbq3Dwzh1w42MQ7wZJrXmEZr/uqUn3f0Q= From 4d204aed6249865933158f7023169744f79a872c Mon Sep 17 00:00:00 2001 From: Peter Argue <89119817+peterargue@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:11:02 -0700 Subject: [PATCH 13/14] Remove planning docs from PR --- .../plans/2026-03-25-flow-mcp-server.md | 1734 ----------------- .../2026-03-25-flow-mcp-server-design.md | 224 --- 2 files changed, 1958 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-25-flow-mcp-server.md delete mode 100644 docs/superpowers/specs/2026-03-25-flow-mcp-server-design.md diff --git a/docs/superpowers/plans/2026-03-25-flow-mcp-server.md b/docs/superpowers/plans/2026-03-25-flow-mcp-server.md deleted file mode 100644 index 30b0aa827..000000000 --- a/docs/superpowers/plans/2026-03-25-flow-mcp-server.md +++ /dev/null @@ -1,1734 +0,0 @@ -# Flow MCP Server Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a `flow mcp` command that starts an MCP server over stdio, exposing 9 tools for Cadence development (LSP + on-chain query + code review). - -**Architecture:** In-process LSP via `cadence-tools/languageserver`, on-chain queries via `flowkit` gRPC gateways, code review via regex rules. All wrapped in an MCP server using `mcp-go` with stdio transport. - -**Tech Stack:** Go, mcp-go, cadence-tools/languageserver, flowkit/v2 - -**Spec:** `docs/superpowers/specs/2026-03-25-flow-mcp-server-design.md` - ---- - -## File Structure - -``` -internal/mcp/ - mcp.go - Cobra command, MCP server creation, tool registration - mcp_test.go - End-to-end MCP tool call tests - lsp.go - LSPWrapper: in-process server.Server lifecycle, diagnostic capture - lsp_test.go - LSP wrapper unit tests - audit.go - Code review rules (cadence_code_review) - audit_test.go - Code review rule tests - tools.go - All 9 tool handler implementations - tools_test.go - Tool handler tests - -Modified: - cmd/flow/main.go - Register mcp.Cmd - go.mod / go.sum - Add mcp-go dependency -``` - ---- - -### Task 1: Add mcp-go dependency and scaffold the command - -**Files:** -- Create: `internal/mcp/mcp.go` -- Modify: `cmd/flow/main.go` -- Modify: `go.mod`, `go.sum` - -- [ ] **Step 1: Add mcp-go dependency** - -Run: -```bash -go get github.com/mark3labs/mcp-go@latest -``` - -- [ ] **Step 2: Create the MCP command with help text** - -Create `internal/mcp/mcp.go`: - -```go -/* - * Flow CLI - * - * Copyright Flow Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package mcp - -import ( - "fmt" - "os" - - "github.com/mark3labs/mcp-go/mcp" - mcpserver "github.com/mark3labs/mcp-go/server" - "github.com/spf13/cobra" - "github.com/spf13/afero" - - "github.com/onflow/flowkit/v2" - "github.com/onflow/flowkit/v2/config" - "github.com/onflow/flowkit/v2/gateway" -) - -var Cmd = &cobra.Command{ - Use: "mcp", - Short: "Start the Cadence MCP server", - Long: `Start a Model Context Protocol (MCP) server for Cadence smart contract development. - -The server provides tools for checking Cadence code, inspecting types, -querying on-chain contracts, executing scripts, and reviewing code for -common issues. - -Claude Code: - claude mcp add cadence-mcp -- flow mcp - -Cursor / Claude Desktop (add to settings JSON): - { - "mcpServers": { - "cadence-mcp": { - "command": "flow", - "args": ["mcp"] - } - } - } - -Available tools: - cadence_check Check Cadence code for syntax and type errors - cadence_hover Get type info for a symbol at a position - cadence_definition Find where a symbol is defined - cadence_symbols List all symbols in Cadence code - cadence_completion Get completions at a position - get_contract_source Fetch on-chain contract manifest - get_contract_code Fetch contract source code from an address - cadence_code_review Review Cadence code for common issues - cadence_execute_script Execute a read-only Cadence script on-chain`, - Run: runMCP, -} - -func runMCP(cmd *cobra.Command, args []string) { - // Try to load flow.json for custom network configs - loader := &afero.Afero{Fs: afero.NewOsFs()} - state, _ := flowkit.Load(config.DefaultPaths(), loader) - - s := mcpserver.NewMCPServer("cadence-mcp", "1.0.0") - - // TODO: register tools in subsequent tasks - - if err := mcpserver.ServeStdio(s); err != nil { - fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err) - os.Exit(1) - } -} - -// resolveNetwork returns a config.Network for the given network name. -// Uses flow.json config if available, otherwise falls back to defaults. -func resolveNetwork(state *flowkit.State, network string) (*config.Network, error) { - if network == "" { - network = "mainnet" - } - - if state != nil { - net, err := state.Networks().ByName(network) - if err == nil { - return net, nil - } - } - - net, err := config.DefaultNetworks.ByName(network) - if err != nil { - return nil, fmt.Errorf("unknown network %q", network) - } - return net, nil -} - -// createGateway creates a gRPC gateway for the given network. -func createGateway(state *flowkit.State, network string) (gateway.Gateway, error) { - net, err := resolveNetwork(state, network) - if err != nil { - return nil, err - } - return gateway.NewGrpcGateway(*net) -} -``` - -- [ ] **Step 3: Register the command in main.go** - -In `cmd/flow/main.go`, add the import and registration: - -Add import: -```go -"github.com/onflow/flow-cli/internal/mcp" -``` - -Add after the `cmd.AddCommand(schedule.Cmd)` line: -```go -cmd.AddCommand(mcp.Cmd) -``` - -- [ ] **Step 4: Verify it builds** - -Run: -```bash -CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go build ./cmd/flow/... -``` -Expected: builds with no errors. - -- [ ] **Step 5: Verify help text** - -Run: -```bash -go run ./cmd/flow mcp --help -``` -Expected: prints the long description with installation instructions and tool list. - -- [ ] **Step 6: Commit** - -```bash -git add internal/mcp/mcp.go cmd/flow/main.go go.mod go.sum -git commit -m "Add flow mcp command scaffold with mcp-go dependency" -``` - ---- - -### Task 2: LSP wrapper — in-process server with diagnostic capture - -**Files:** -- Create: `internal/mcp/lsp.go` -- Create: `internal/mcp/lsp_test.go` - -- [ ] **Step 1: Write the failing test for LSP wrapper initialization and diagnostics** - -Create `internal/mcp/lsp_test.go`: - -```go -/* - * Flow CLI - * - * Copyright Flow Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package mcp - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestLSPWrapper_Check_ValidCode(t *testing.T) { - lsp, err := NewLSPWrapper(false) - require.NoError(t, err) - - diags, err := lsp.Check("access(all) fun main() {}", "") - require.NoError(t, err) - assert.Empty(t, diags, "valid code should produce no diagnostics") -} - -func TestLSPWrapper_Check_InvalidCode(t *testing.T) { - lsp, err := NewLSPWrapper(false) - require.NoError(t, err) - - diags, err := lsp.Check("access(all) fun main() { let x: Int = \"hello\" }", "") - require.NoError(t, err) - assert.NotEmpty(t, diags, "type mismatch should produce diagnostics") -} - -func TestLSPWrapper_Check_SyntaxError(t *testing.T) { - lsp, err := NewLSPWrapper(false) - require.NoError(t, err) - - diags, err := lsp.Check("this is not valid cadence {{{", "") - require.NoError(t, err) - assert.NotEmpty(t, diags, "syntax error should produce diagnostics") -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: -```bash -CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go test ./internal/mcp/... -run TestLSPWrapper -v -``` -Expected: FAIL — `NewLSPWrapper` undefined. - -- [ ] **Step 3: Implement the LSP wrapper** - -Create `internal/mcp/lsp.go`: - -```go -/* - * Flow CLI - * - * Copyright Flow Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package mcp - -import ( - "fmt" - "strings" - "sync" - - "github.com/onflow/cadence-tools/languageserver/integration" - "github.com/onflow/cadence-tools/languageserver/protocol" - "github.com/onflow/cadence-tools/languageserver/server" -) - -const scratchURI = protocol.DocumentURI("file:///mcp/scratch.cdc") - -// LSPWrapper manages an in-process cadence-tools language server. -// All operations are serialized — the LSP server is single-threaded. -type LSPWrapper struct { - server *server.Server - conn *diagConn - mu sync.Mutex - docVersion int32 - docOpen bool -} - -// NewLSPWrapper creates a new LSP wrapper with an in-process language server. -// enableFlowClient enables on-chain import resolution (requires network access). -func NewLSPWrapper(enableFlowClient bool) (*LSPWrapper, error) { - s, err := server.NewServer() - if err != nil { - return nil, fmt.Errorf("creating LSP server: %w", err) - } - - _, err = integration.NewFlowIntegration(s, enableFlowClient) - if err != nil { - return nil, fmt.Errorf("initializing Flow integration: %w", err) - } - - conn := &diagConn{} - - // Initialize the server (required before any LSP operations) - _, err = s.Initialize(conn, &protocol.InitializeParams{ - InitializationOptions: map[string]interface{}{ - "accessCheckMode": "strict", - }, - }) - if err != nil { - return nil, fmt.Errorf("initializing LSP: %w", err) - } - - return &LSPWrapper{ - server: s, - conn: conn, - }, nil -} - -// updateDocument opens or updates the scratch document with the given code. -// Must be called with w.mu held. -func (w *LSPWrapper) updateDocument(code string) { - w.docVersion++ - if !w.docOpen { - w.server.DidOpenTextDocument(w.conn, &protocol.DidOpenTextDocumentParams{ - TextDocument: protocol.TextDocumentItem{ - URI: scratchURI, - LanguageID: "cadence", - Version: w.docVersion, - Text: code, - }, - }) - w.docOpen = true - } else { - w.server.DidChangeTextDocument(w.conn, &protocol.DidChangeTextDocumentParams{ - TextDocument: protocol.VersionedTextDocumentIdentifier{ - TextDocumentIdentifier: protocol.TextDocumentIdentifier{URI: scratchURI}, - Version: w.docVersion, - }, - ContentChanges: []protocol.TextDocumentContentChangeEvent{ - {Text: code}, - }, - }) - } -} - -// Check analyzes Cadence code and returns diagnostics. -func (w *LSPWrapper) Check(code string, network string) ([]protocol.Diagnostic, error) { - w.mu.Lock() - defer w.mu.Unlock() - - w.conn.reset() - w.updateDocument(code) - return w.conn.getDiagnostics(), nil -} - -// Hover returns type information at the given position. -func (w *LSPWrapper) Hover(code string, line, character int, network string) (*protocol.Hover, error) { - w.mu.Lock() - defer w.mu.Unlock() - - w.conn.reset() - w.updateDocument(code) - return w.server.Hover(w.conn, &protocol.TextDocumentPositionParams{ - TextDocument: protocol.TextDocumentIdentifier{URI: scratchURI}, - Position: protocol.Position{Line: uint32(line), Character: uint32(character)}, - }) -} - -// Definition returns the definition location of a symbol at the given position. -func (w *LSPWrapper) Definition(code string, line, character int, network string) (*protocol.Location, error) { - w.mu.Lock() - defer w.mu.Unlock() - - w.conn.reset() - w.updateDocument(code) - return w.server.Definition(w.conn, &protocol.TextDocumentPositionParams{ - TextDocument: protocol.TextDocumentIdentifier{URI: scratchURI}, - Position: protocol.Position{Line: uint32(line), Character: uint32(character)}, - }) -} - -// Symbols returns all document symbols in the code. -func (w *LSPWrapper) Symbols(code string, network string) ([]*protocol.DocumentSymbol, error) { - w.mu.Lock() - defer w.mu.Unlock() - - w.conn.reset() - w.updateDocument(code) - return w.server.DocumentSymbol(w.conn, &protocol.DocumentSymbolParams{ - TextDocument: protocol.TextDocumentIdentifier{URI: scratchURI}, - }) -} - -// Completion returns completion items at the given position. -func (w *LSPWrapper) Completion(code string, line, character int, network string) ([]*protocol.CompletionItem, error) { - w.mu.Lock() - defer w.mu.Unlock() - - w.conn.reset() - w.updateDocument(code) - return w.server.Completion(w.conn, &protocol.CompletionParams{ - TextDocumentPositionParams: protocol.TextDocumentPositionParams{ - TextDocument: protocol.TextDocumentIdentifier{URI: scratchURI}, - Position: protocol.Position{Line: uint32(line), Character: uint32(character)}, - }, - }) -} - -// diagConn implements protocol.Conn to capture diagnostics pushed by the LSP. -type diagConn struct { - mu sync.Mutex - diagnostics []protocol.Diagnostic -} - -func (c *diagConn) reset() { - c.mu.Lock() - defer c.mu.Unlock() - c.diagnostics = nil -} - -func (c *diagConn) getDiagnostics() []protocol.Diagnostic { - c.mu.Lock() - defer c.mu.Unlock() - result := make([]protocol.Diagnostic, len(c.diagnostics)) - copy(result, c.diagnostics) - return result -} - -func (c *diagConn) Notify(method string, params any) error { - if method == "textDocument/publishDiagnostics" { - if p, ok := params.(*protocol.PublishDiagnosticsParams); ok { - c.mu.Lock() - c.diagnostics = append(c.diagnostics, p.Diagnostics...) - c.mu.Unlock() - } - } - return nil -} - -func (c *diagConn) ShowMessage(params *protocol.ShowMessageParams) {} - -func (c *diagConn) ShowMessageRequest(params *protocol.ShowMessageRequestParams) (*protocol.MessageActionItem, error) { - return nil, nil -} - -func (c *diagConn) LogMessage(params *protocol.LogMessageParams) {} - -func (c *diagConn) PublishDiagnostics(params *protocol.PublishDiagnosticsParams) error { - c.mu.Lock() - c.diagnostics = append(c.diagnostics, params.Diagnostics...) - c.mu.Unlock() - return nil -} - -func (c *diagConn) RegisterCapability(params *protocol.RegistrationParams) error { - return nil -} - -// formatDiagnostics formats LSP diagnostics into a human-readable string. -func formatDiagnostics(diagnostics []protocol.Diagnostic) string { - if len(diagnostics) == 0 { - return "No errors found." - } - - severityLabels := map[protocol.DiagnosticSeverity]string{ - protocol.SeverityError: "error", - protocol.SeverityWarning: "warning", - protocol.SeverityInformation: "info", - protocol.SeverityHint: "hint", - } - - var b strings.Builder - for _, d := range diagnostics { - label := severityLabels[d.Severity] - if label == "" { - label = "error" - } - fmt.Fprintf(&b, "[%s] line %d:%d: %s\n", - label, - d.Range.Start.Line+1, - d.Range.Start.Character+1, - d.Message, - ) - } - return b.String() -} - -// formatHover formats a hover result into readable text. -func formatHover(result *protocol.Hover) string { - if result == nil { - return "No information available." - } - return result.Contents.Value -} - -// formatSymbols formats document symbols into readable text. -func formatSymbols(symbols []*protocol.DocumentSymbol, indent int) string { - if len(symbols) == 0 { - return "No symbols found." - } - - var b strings.Builder - prefix := strings.Repeat(" ", indent) - for _, sym := range symbols { - detail := "" - if sym.Detail != "" { - detail = " — " + sym.Detail - } - fmt.Fprintf(&b, "%s%s %s%s\n", prefix, symbolKindName(sym.Kind), sym.Name, detail) - if len(sym.Children) > 0 { - b.WriteString(formatSymbols(sym.Children, indent+1)) - } - } - return b.String() -} - -func symbolKindName(kind protocol.SymbolKind) string { - names := map[protocol.SymbolKind]string{ - 1: "File", 2: "Module", 3: "Namespace", 4: "Package", - 5: "Class", 6: "Method", 7: "Property", 8: "Field", - 9: "Constructor", 10: "Enum", 11: "Interface", 12: "Function", - 13: "Variable", 14: "Constant", 15: "String", 16: "Number", - 17: "Boolean", 18: "Array", 19: "Object", 20: "Key", - 21: "Null", 22: "EnumMember", 23: "Struct", 24: "Event", - 25: "Operator", 26: "TypeParameter", - } - if name, ok := names[kind]; ok { - return name - } - return fmt.Sprintf("kind(%d)", kind) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: -```bash -CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go test ./internal/mcp/... -run TestLSPWrapper -v -``` -Expected: all 3 tests PASS. - -- [ ] **Step 5: Add tests for hover and symbols** - -Append to `internal/mcp/lsp_test.go`: - -```go -func TestLSPWrapper_Hover(t *testing.T) { - lsp, err := NewLSPWrapper(false) - require.NoError(t, err) - - // Hover over "Int" on line 0, character ~30 - code := "access(all) fun main(): Int { return 42 }" - result, err := lsp.Hover(code, 0, 24, "") - require.NoError(t, err) - assert.NotNil(t, result, "should get hover info for Int type") -} - -func TestLSPWrapper_Symbols(t *testing.T) { - lsp, err := NewLSPWrapper(false) - require.NoError(t, err) - - code := `access(all) contract Foo { - access(all) resource Bar {} - access(all) fun baz() {} - }` - symbols, err := lsp.Symbols(code, "") - require.NoError(t, err) - assert.NotEmpty(t, symbols, "should find symbols in contract") -} - -func TestLSPWrapper_Completion(t *testing.T) { - lsp, err := NewLSPWrapper(false) - require.NoError(t, err) - - // Get completions at empty position — should return at least some items - code := "access(all) fun main() {\n \n}" - items, err := lsp.Completion(code, 1, 2, "") - require.NoError(t, err) - assert.NotEmpty(t, items, "should get completion items") -} -``` - -- [ ] **Step 6: Run all LSP tests** - -Run: -```bash -CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go test ./internal/mcp/... -run TestLSPWrapper -v -``` -Expected: all 6 tests PASS. - -- [ ] **Step 7: Commit** - -```bash -git add internal/mcp/lsp.go internal/mcp/lsp_test.go -git commit -m "Add LSP wrapper with in-process language server" -``` - ---- - -### Task 3: Code review rules (cadence_code_review) - -**Files:** -- Create: `internal/mcp/audit.go` -- Create: `internal/mcp/audit_test.go` - -- [ ] **Step 1: Write failing tests for code review rules** - -Create `internal/mcp/audit_test.go`: - -```go -/* - * Flow CLI - * - * Copyright Flow Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package mcp - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestCodeReview_CleanCode(t *testing.T) { - code := `access(contract) let balance: UFix64 -access(contract) fun transfer() {}` - result := codeReview(code) - assert.Empty(t, result.Findings, "clean code should have no findings") -} - -func TestCodeReview_OverlyPermissiveAccess(t *testing.T) { - code := `access(all) var balance: UFix64` - result := codeReview(code) - assert.NotEmpty(t, result.Findings) - assert.Equal(t, "overly-permissive-access", result.Findings[0].Rule) - assert.Equal(t, "warning", string(result.Findings[0].Severity)) -} - -func TestCodeReview_DeprecatedPub(t *testing.T) { - code := `pub fun doSomething() {}` - result := codeReview(code) - found := false - for _, f := range result.Findings { - if f.Rule == "deprecated-pub" { - found = true - } - } - assert.True(t, found, "should detect deprecated pub keyword") -} - -func TestCodeReview_ForceUnwrap(t *testing.T) { - code := `access(all) fun main() { let x = optional! }` - result := codeReview(code) - found := false - for _, f := range result.Findings { - if f.Rule == "unsafe-force-unwrap" { - found = true - } - } - assert.True(t, found, "should detect force-unwrap") -} - -func TestCodeReview_HardcodedAddress(t *testing.T) { - code := `let addr = 0xf233dcee88fe0abe` - result := codeReview(code) - found := false - for _, f := range result.Findings { - if f.Rule == "hardcoded-address" { - found = true - } - } - assert.True(t, found, "should detect hardcoded address") -} - -func TestCodeReview_AddressImportNotFlagged(t *testing.T) { - code := `import FungibleToken from 0xf233dcee88fe0abe` - result := codeReview(code) - for _, f := range result.Findings { - assert.NotEqual(t, "hardcoded-address", f.Rule, - "address imports should not be flagged as hardcoded addresses") - } -} - -func TestCodeReview_FormatResult(t *testing.T) { - result := ReviewResult{ - Findings: []Finding{ - {Rule: "test", Severity: "warning", Line: 1, Message: "test message"}, - }, - Summary: map[string]int{"warning": 1}, - } - text := formatReviewResult(result) - assert.Contains(t, text, "1 issue(s)") - assert.Contains(t, text, "[WARNING]") - assert.Contains(t, text, "test message") -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: -```bash -CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go test ./internal/mcp/... -run TestCodeReview -v -``` -Expected: FAIL — `codeReview` undefined. - -- [ ] **Step 3: Implement code review rules** - -Create `internal/mcp/audit.go`: - -```go -/* - * Flow CLI - * - * Copyright Flow Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package mcp - -import ( - "fmt" - "regexp" - "strings" -) - -// Severity represents the severity level of a finding. -type Severity string - -const ( - SeverityWarning Severity = "warning" - SeverityNote Severity = "note" - SeverityInfo Severity = "info" -) - -// Finding represents a single code review finding. -type Finding struct { - Rule string `json:"rule"` - Severity Severity `json:"severity"` - Line int `json:"line"` - Message string `json:"message"` -} - -// ReviewResult contains all findings from a code review. -type ReviewResult struct { - Findings []Finding `json:"findings"` - Summary map[string]int `json:"summary"` -} - -type rule struct { - id string - severity Severity - pattern *regexp.Regexp - message string // static message, or empty if messageFunc is set - msgFunc func([]string) string - perLine bool // true = match per line (default), false = full text -} - -var rules = []rule{ - { - id: "overly-permissive-access", - severity: SeverityWarning, - pattern: regexp.MustCompile(`access\(all\)\s+(var|let)\s+`), - message: "State field with access(all) — consider restricting access with entitlements", - perLine: true, - }, - { - id: "overly-permissive-function", - severity: SeverityNote, - pattern: regexp.MustCompile(`access\(all\)\s+fun\s+(\w+)`), - msgFunc: func(m []string) string { - name := "unknown" - if len(m) > 1 { - name = m[1] - } - return fmt.Sprintf("Function '%s' has access(all) — review if public access is intended", name) - }, - perLine: true, - }, - { - id: "deprecated-pub", - severity: SeverityInfo, - pattern: regexp.MustCompile(`\bpub\s+(var|let|fun|resource|struct|event|contract|enum)\b`), - message: "`pub` is deprecated in Cadence 1.0 — use `access(all)` or a more restrictive access modifier", - perLine: true, - }, - { - id: "unsafe-force-unwrap", - severity: SeverityNote, - pattern: regexp.MustCompile(`[)\w]\s*!`), - message: "Force-unwrap (!) used — consider nil-coalescing (??) or optional binding for safer handling", - perLine: true, - }, - { - id: "auth-account-exposure", - severity: SeverityWarning, - pattern: regexp.MustCompile(`\bAuthAccount\b`), - message: "AuthAccount reference found — passing AuthAccount gives full account access, use capabilities instead", - perLine: true, - }, - { - id: "auth-reference-exposure", - severity: SeverityWarning, - pattern: regexp.MustCompile(`\bauth\s*\(.*?\)\s*&Account\b`), - message: "auth(…) &Account reference found — this grants broad account access, prefer scoped capabilities", - perLine: true, - }, - { - id: "hardcoded-address", - severity: SeverityInfo, - pattern: regexp.MustCompile(`0x[0-9a-fA-F]{8,16}\b`), - message: "Hardcoded address detected — consider using named address imports for portability", - perLine: true, - }, - { - id: "unguarded-capability", - severity: SeverityWarning, - pattern: regexp.MustCompile(`\.publish\s*\(`), - message: "Capability published — verify that proper entitlements guard this capability", - perLine: true, - }, - { - id: "resource-loss-destroy", - severity: SeverityWarning, - pattern: regexp.MustCompile(`destroy\s*\(`), - message: "Explicit destroy call — ensure the resource is intentionally being destroyed and not lost", - perLine: true, - }, -} - -// isAddressImportLine returns true if the line is an import-from-address statement. -var addressImportPattern = regexp.MustCompile(`^\s*import\s+\w[\w, ]*\s+from\s+0x`) - -func isAddressImportLine(line string) bool { - return addressImportPattern.MatchString(line) -} - -// codeReview runs static analysis rules against Cadence source code. -func codeReview(code string) ReviewResult { - var findings []Finding - lines := strings.Split(code, "\n") - - for _, r := range rules { - if !r.perLine { - continue // multi-line rules handled below - } - for i, line := range lines { - // Skip address import lines for hardcoded-address rule - if r.id == "hardcoded-address" && isAddressImportLine(line) { - continue - } - - match := r.pattern.FindStringSubmatch(line) - if match == nil { - continue - } - - msg := r.message - if r.msgFunc != nil { - msg = r.msgFunc(match) - } - - findings = append(findings, Finding{ - Rule: r.id, - Severity: r.severity, - Line: i + 1, - Message: msg, - }) - } - } - - summary := map[string]int{} - for _, f := range findings { - summary[string(f.Severity)]++ - } - - return ReviewResult{ - Findings: findings, - Summary: summary, - } -} - -// formatReviewResult formats a ReviewResult into a human-readable string. -func formatReviewResult(result ReviewResult) string { - var b strings.Builder - - total := len(result.Findings) - fmt.Fprintf(&b, "## Code Review Results\n") - fmt.Fprintf(&b, "Found %d issue(s): %d warning, %d note, %d info\n\n", - total, - result.Summary["warning"], - result.Summary["note"], - result.Summary["info"], - ) - - if total == 0 { - b.WriteString("No issues detected.\n") - return b.String() - } - - for _, f := range result.Findings { - fmt.Fprintf(&b, "- [%s] Line %d: (%s) %s\n", - strings.ToUpper(string(f.Severity)), - f.Line, - f.Rule, - f.Message, - ) - } - - return b.String() -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: -```bash -CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go test ./internal/mcp/... -run TestCodeReview -v -``` -Expected: all 7 tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/mcp/audit.go internal/mcp/audit_test.go -git commit -m "Add code review rules for cadence_code_review tool" -``` - ---- - -### Task 4: Tool handler implementations - -**Files:** -- Create: `internal/mcp/tools.go` -- Modify: `internal/mcp/mcp.go` (wire tools into server) - -- [ ] **Step 1: Implement all 9 tool handlers** - -Create `internal/mcp/tools.go`: - -```go -/* - * Flow CLI - * - * Copyright Flow Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package mcp - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/mark3labs/mcp-go/mcp" - "github.com/onflow/flow-go-sdk" - "github.com/onflow/flowkit/v2" - "github.com/onflow/flowkit/v2/arguments" -) - -// mcpContext holds shared state for all tool handlers. -type mcpContext struct { - lsp *LSPWrapper - state *flowkit.State // may be nil if no flow.json -} - -// --- LSP Tool Handlers --- - -func (m *mcpContext) cadenceCheck(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - code, err := req.RequireString("code") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - network, _ := req.GetString("network", "mainnet") - - diags, err := m.lsp.Check(code, network) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("LSP error: %v", err)), nil - } - return mcp.NewToolResultText(formatDiagnostics(diags)), nil -} - -func (m *mcpContext) cadenceHover(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - code, err := req.RequireString("code") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - line, err := req.RequireInt("line") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - character, err := req.RequireInt("character") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - network, _ := req.GetString("network", "mainnet") - - result, err := m.lsp.Hover(code, line, character, network) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("LSP error: %v", err)), nil - } - return mcp.NewToolResultText(formatHover(result)), nil -} - -func (m *mcpContext) cadenceDefinition(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - code, err := req.RequireString("code") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - line, err := req.RequireInt("line") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - character, err := req.RequireInt("character") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - network, _ := req.GetString("network", "mainnet") - - loc, err := m.lsp.Definition(code, line, character, network) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("LSP error: %v", err)), nil - } - if loc == nil { - return mcp.NewToolResultText("No definition found."), nil - } - return mcp.NewToolResultText(fmt.Sprintf("Definition: %s at line %d:%d", - loc.URI, - loc.Range.Start.Line+1, - loc.Range.Start.Character+1, - )), nil -} - -func (m *mcpContext) cadenceSymbols(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - code, err := req.RequireString("code") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - network, _ := req.GetString("network", "mainnet") - - symbols, err := m.lsp.Symbols(code, network) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("LSP error: %v", err)), nil - } - return mcp.NewToolResultText(formatSymbols(symbols, 0)), nil -} - -func (m *mcpContext) cadenceCompletion(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - code, err := req.RequireString("code") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - line, err := req.RequireInt("line") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - character, err := req.RequireInt("character") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - network, _ := req.GetString("network", "mainnet") - - items, err := m.lsp.Completion(code, line, character, network) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("LSP error: %v", err)), nil - } - if len(items) == 0 { - return mcp.NewToolResultText("No completions available."), nil - } - - var b strings.Builder - for _, item := range items { - detail := "" - if item.Detail != "" { - detail = " — " + item.Detail - } - fmt.Fprintf(&b, "%s%s\n", item.Label, detail) - } - return mcp.NewToolResultText(b.String()), nil -} - -// --- Audit Tool Handlers --- - -func (m *mcpContext) getContractSource(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - address, err := req.RequireString("address") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - network, _ := req.GetString("network", "mainnet") - - gw, err := createGateway(m.state, network) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Gateway error: %v", err)), nil - } - - addr := flow.HexToAddress(address) - account, err := gw.GetAccount(ctx, addr) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Error fetching account: %v", err)), nil - } - - type contractEntry struct { - Name string `json:"name"` - Size int `json:"size"` - Imports []string `json:"imports,omitempty"` - } - - var entries []contractEntry - for name, code := range account.Contracts { - entries = append(entries, contractEntry{ - Name: name, - Size: len(code), - }) - } - - result, err := json.MarshalIndent(map[string]any{ - "address": address, - "network": network, - "contracts": entries, - }, "", " ") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("JSON error: %v", err)), nil - } - return mcp.NewToolResultText(string(result)), nil -} - -func (m *mcpContext) getContractCode(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - address, err := req.RequireString("address") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - contractName, _ := req.GetString("contract_name", "") - network, _ := req.GetString("network", "mainnet") - - gw, err := createGateway(m.state, network) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Gateway error: %v", err)), nil - } - - addr := flow.HexToAddress(address) - account, err := gw.GetAccount(ctx, addr) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Error fetching account: %v", err)), nil - } - - var parts []string - for name, code := range account.Contracts { - if contractName != "" && name != contractName { - continue - } - parts = append(parts, fmt.Sprintf("// === %s (%s) ===\n\n%s", name, address, string(code))) - } - - if len(parts) == 0 { - if contractName != "" { - names := make([]string, 0, len(account.Contracts)) - for name := range account.Contracts { - names = append(names, name) - } - return mcp.NewToolResultError(fmt.Sprintf( - "Contract '%s' not found on %s. Available: %s", - contractName, address, strings.Join(names, ", "), - )), nil - } - return mcp.NewToolResultText("No contracts found on this address."), nil - } - - return mcp.NewToolResultText(strings.Join(parts, "\n\n")), nil -} - -func (m *mcpContext) cadenceCodeReview(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - code, err := req.RequireString("code") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - network, _ := req.GetString("network", "mainnet") - - // Run static analysis rules - result := codeReview(code) - text := formatReviewResult(result) - - // Also run LSP type check if available - if m.lsp != nil { - diags, err := m.lsp.Check(code, network) - if err == nil && len(diags) > 0 { - text += "\n## Type Check (LSP)\n" + formatDiagnostics(diags) - } - } - - return mcp.NewToolResultText(text), nil -} - -func (m *mcpContext) cadenceExecuteScript(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - code, err := req.RequireString("code") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - network, _ := req.GetString("network", "mainnet") - argsJSON, _ := req.GetString("args", "[]") - - gw, err := createGateway(m.state, network) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Gateway error: %v", err)), nil - } - - // Parse script arguments - var argStrings []string - if err := json.Unmarshal([]byte(argsJSON), &argStrings); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid args format: %v", err)), nil - } - - cadenceArgs, err := arguments.ParseWithoutType(argStrings) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Argument parse error: %v", err)), nil - } - - value, err := gw.ExecuteScript(ctx, flowkit.Script{ - Code: []byte(code), - Args: cadenceArgs, - }, flowkit.LatestScriptQuery) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Script execution failed:\n%v", err)), nil - } - - return mcp.NewToolResultText(value.String()), nil -} -``` - -- [ ] **Step 2: Wire tools into the MCP server** - -Update `internal/mcp/mcp.go` — replace the `runMCP` function: - -```go -func runMCP(cmd *cobra.Command, args []string) { - // Try to load flow.json for custom network configs - loader := &afero.Afero{Fs: afero.NewOsFs()} - state, _ := flowkit.Load(config.DefaultPaths(), loader) - - // Initialize LSP wrapper (disable flow client for now — network queries - // use gateways directly, and the LSP's flow client would prompt for - // flow.json interactively which doesn't work over MCP stdio) - lsp, err := NewLSPWrapper(false) - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: LSP initialization failed: %v\n", err) - fmt.Fprintf(os.Stderr, "LSP tools will be unavailable.\n") - } - - mctx := &mcpContext{ - lsp: lsp, - state: state, - } - - s := mcpserver.NewMCPServer("cadence-mcp", "1.0.0") - registerTools(s, mctx) - - if err := mcpserver.ServeStdio(s); err != nil { - fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err) - os.Exit(1) - } -} - -func registerTools(s *mcpserver.MCPServer, mctx *mcpContext) { - networkParam := mcp.WithString("network", - mcp.Description("Flow network: mainnet, testnet, or emulator (default: mainnet)"), - mcp.Enum("mainnet", "testnet", "emulator"), - ) - - // --- LSP Tools --- - - if mctx.lsp != nil { - s.AddTool(mcp.NewTool("cadence_check", - mcp.WithDescription("Check Cadence smart contract code for syntax and type errors. Returns diagnostics."), - mcp.WithString("code", mcp.Required(), mcp.Description("Cadence source code to check")), - mcp.WithString("filename", mcp.Description("Virtual filename (default: check.cdc)")), - networkParam, - ), mctx.cadenceCheck) - - s.AddTool(mcp.NewTool("cadence_hover", - mcp.WithDescription("Get type information and documentation for a symbol at a given position in Cadence code."), - mcp.WithString("code", mcp.Required(), mcp.Description("Cadence source code")), - mcp.WithNumber("line", mcp.Required(), mcp.Description("0-based line number")), - mcp.WithNumber("character", mcp.Required(), mcp.Description("0-based column number")), - mcp.WithString("filename", mcp.Description("Virtual filename")), - networkParam, - ), mctx.cadenceHover) - - s.AddTool(mcp.NewTool("cadence_definition", - mcp.WithDescription("Find the definition location of a symbol at a given position in Cadence code."), - mcp.WithString("code", mcp.Required(), mcp.Description("Cadence source code")), - mcp.WithNumber("line", mcp.Required(), mcp.Description("0-based line number")), - mcp.WithNumber("character", mcp.Required(), mcp.Description("0-based column number")), - mcp.WithString("filename", mcp.Description("Virtual filename")), - networkParam, - ), mctx.cadenceDefinition) - - s.AddTool(mcp.NewTool("cadence_symbols", - mcp.WithDescription("List all symbols (contracts, resources, functions, events, etc.) in Cadence code."), - mcp.WithString("code", mcp.Required(), mcp.Description("Cadence source code")), - mcp.WithString("filename", mcp.Description("Virtual filename")), - networkParam, - ), mctx.cadenceSymbols) - - s.AddTool(mcp.NewTool("cadence_completion", - mcp.WithDescription("Get code completions at a position in Cadence code. Returns available members, methods, and keywords."), - mcp.WithString("code", mcp.Required(), mcp.Description("Cadence source code")), - mcp.WithNumber("line", mcp.Required(), mcp.Description("0-based line number")), - mcp.WithNumber("character", mcp.Required(), mcp.Description("0-based column number")), - mcp.WithString("filename", mcp.Description("Virtual filename")), - networkParam, - ), mctx.cadenceCompletion) - } - - // --- Audit Tools --- - - s.AddTool(mcp.NewTool("get_contract_source", - mcp.WithDescription("Fetch on-chain contract manifest from a Flow address: lists all contracts with names and sizes."), - mcp.WithString("address", mcp.Required(), mcp.Description("Flow address (0x...)")), - networkParam, - ), mctx.getContractSource) - - s.AddTool(mcp.NewTool("get_contract_code", - mcp.WithDescription("Fetch the source code of contracts from a Flow address."), - mcp.WithString("address", mcp.Required(), mcp.Description("Flow address (0x...)")), - mcp.WithString("contract_name", mcp.Description("Name of specific contract to fetch. If omitted, returns all.")), - networkParam, - ), mctx.getContractCode) - - s.AddTool(mcp.NewTool("cadence_code_review", - mcp.WithDescription("Review Cadence code for common issues and best practices. Uses pattern matching to flag potential problems — not a substitute for a proper audit."), - mcp.WithString("code", mcp.Required(), mcp.Description("Cadence source code to review")), - networkParam, - ), mctx.cadenceCodeReview) - - s.AddTool(mcp.NewTool("cadence_execute_script", - mcp.WithDescription("Execute a read-only Cadence script on the Flow network. Scripts can query on-chain state. Cannot modify state."), - mcp.WithString("code", mcp.Required(), mcp.Description("Cadence script code (must have `access(all) fun main()` entry point)")), - mcp.WithString("args", mcp.Description(`Script arguments as JSON array of strings in "Type:Value" format, e.g. ["Address:0x1654653399040a61", "UFix64:10.0"]`)), - networkParam, - ), mctx.cadenceExecuteScript) -} -``` - -- [ ] **Step 3: Verify it builds** - -Run: -```bash -CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go build ./cmd/flow/... -``` -Expected: builds with no errors. - -- [ ] **Step 4: Commit** - -```bash -git add internal/mcp/tools.go internal/mcp/mcp.go -git commit -m "Add tool handler implementations and wire into MCP server" -``` - ---- - -### Task 5: Tool handler tests - -**Files:** -- Create: `internal/mcp/tools_test.go` - -- [ ] **Step 1: Write tool handler tests** - -Create `internal/mcp/tools_test.go`: - -```go -/* - * Flow CLI - * - * Copyright Flow Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package mcp - -import ( - "context" - "encoding/json" - "testing" - - "github.com/mark3labs/mcp-go/mcp" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func newTestContext(t *testing.T) *mcpContext { - t.Helper() - lsp, err := NewLSPWrapper(false) - require.NoError(t, err) - return &mcpContext{lsp: lsp} -} - -func makeRequest(args map[string]any) mcp.CallToolRequest { - raw, _ := json.Marshal(args) - var params struct { - Arguments map[string]any `json:"arguments"` - } - params.Arguments = args - rawParams, _ := json.Marshal(params) - var req mcp.CallToolRequest - json.Unmarshal(rawParams, &req) - // mcp-go uses req.Params.Arguments - req.Params.Arguments = args - return req -} - -func TestTool_CadenceCheck_Valid(t *testing.T) { - mctx := newTestContext(t) - req := makeRequest(map[string]any{ - "code": "access(all) fun main() {}", - }) - - result, err := mctx.cadenceCheck(context.Background(), req) - require.NoError(t, err) - assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "No errors found") -} - -func TestTool_CadenceCheck_Invalid(t *testing.T) { - mctx := newTestContext(t) - req := makeRequest(map[string]any{ - "code": "access(all) fun main() { let x: Int = \"bad\" }", - }) - - result, err := mctx.cadenceCheck(context.Background(), req) - require.NoError(t, err) - text := result.Content[0].(mcp.TextContent).Text - assert.Contains(t, text, "error") -} - -func TestTool_CadenceCheck_MissingCode(t *testing.T) { - mctx := newTestContext(t) - req := makeRequest(map[string]any{}) - - result, err := mctx.cadenceCheck(context.Background(), req) - require.NoError(t, err) - assert.True(t, result.IsError) -} - -func TestTool_CadenceSymbols(t *testing.T) { - mctx := newTestContext(t) - req := makeRequest(map[string]any{ - "code": `access(all) contract Foo { - access(all) fun bar() {} - }`, - }) - - result, err := mctx.cadenceSymbols(context.Background(), req) - require.NoError(t, err) - text := result.Content[0].(mcp.TextContent).Text - assert.Contains(t, text, "Foo") -} - -func TestTool_CadenceCodeReview(t *testing.T) { - mctx := newTestContext(t) - req := makeRequest(map[string]any{ - "code": "access(all) var balance: UFix64", - }) - - result, err := mctx.cadenceCodeReview(context.Background(), req) - require.NoError(t, err) - text := result.Content[0].(mcp.TextContent).Text - assert.Contains(t, text, "overly-permissive-access") -} -``` - -- [ ] **Step 2: Run tests** - -Run: -```bash -CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go test ./internal/mcp/... -run TestTool -v -``` -Expected: all tests PASS. - -Note: The `makeRequest` helper may need adjustment based on the exact mcp-go `CallToolRequest` struct. Check the struct definition after adding the dependency and adjust accordingly. - -- [ ] **Step 3: Commit** - -```bash -git add internal/mcp/tools_test.go -git commit -m "Add tool handler tests" -``` - ---- - -### Task 6: End-to-end verification and license headers - -**Files:** -- Modify: all files in `internal/mcp/` - -- [ ] **Step 1: Verify license headers on all files** - -Run: -```bash -make check-headers -``` -Expected: PASS. All Go files in `internal/mcp/` already have the Apache 2.0 header from the code above. If any are flagged, add the header. - -- [ ] **Step 2: Run linter** - -Run: -```bash -make lint -``` -Expected: PASS with no new lint issues. - -- [ ] **Step 3: Run full test suite** - -Run: -```bash -CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go test ./internal/mcp/... -v -``` -Expected: all tests PASS. - -- [ ] **Step 4: Manual smoke test** - -Run the MCP server interactively to verify it starts and responds: - -```bash -echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | go run ./cmd/flow mcp -``` - -Expected: JSON response with server capabilities including the tool list. - -- [ ] **Step 5: Commit any fixes** - -If any fixes were needed: -```bash -git add internal/mcp/ -git commit -m "Fix lint and license header issues" -``` - ---- - -### Task 7: Integration tests for network tools (optional, behind flag) - -**Files:** -- Create: `internal/mcp/integration_test.go` - -These tests hit the real network and are skipped unless `SKIP_NETWORK_TESTS` is unset. - -- [ ] **Step 1: Write integration tests** - -Create `internal/mcp/integration_test.go`: - -```go -/* - * Flow CLI - * - * Copyright Flow Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package mcp - -import ( - "context" - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func skipIfNoNetwork(t *testing.T) { - t.Helper() - if os.Getenv("SKIP_NETWORK_TESTS") != "" { - t.Skip("Skipping network test (SKIP_NETWORK_TESTS is set)") - } -} - -func TestIntegration_GetContractSource(t *testing.T) { - skipIfNoNetwork(t) - mctx := &mcpContext{state: nil} - - req := makeRequest(map[string]any{ - "address": "0x1654653399040a61", - "network": "mainnet", - }) - - result, err := mctx.getContractSource(context.Background(), req) - require.NoError(t, err) - text := result.Content[0].(mcp.TextContent).Text - assert.Contains(t, text, "FungibleToken") -} - -func TestIntegration_GetContractCode(t *testing.T) { - skipIfNoNetwork(t) - mctx := &mcpContext{state: nil} - - req := makeRequest(map[string]any{ - "address": "0x1654653399040a61", - "contract_name": "FungibleToken", - "network": "mainnet", - }) - - result, err := mctx.getContractCode(context.Background(), req) - require.NoError(t, err) - text := result.Content[0].(mcp.TextContent).Text - assert.Contains(t, text, "FungibleToken") - assert.Contains(t, text, "access(all) contract interface") -} - -func TestIntegration_ExecuteScript(t *testing.T) { - skipIfNoNetwork(t) - mctx := &mcpContext{state: nil} - - req := makeRequest(map[string]any{ - "code": `access(all) fun main(): Int { return 42 }`, - "network": "mainnet", - }) - - result, err := mctx.cadenceExecuteScript(context.Background(), req) - require.NoError(t, err) - text := result.Content[0].(mcp.TextContent).Text - assert.Contains(t, text, "42") -} -``` - -- [ ] **Step 2: Run integration tests (if network available)** - -Run: -```bash -CGO_ENABLED=1 CGO_CFLAGS="-O2 -D__BLST_PORTABLE__ -std=gnu11" go test ./internal/mcp/... -run TestIntegration -v -``` -Expected: PASS (or skip if `SKIP_NETWORK_TESTS` is set). - -- [ ] **Step 3: Commit** - -```bash -git add internal/mcp/integration_test.go -git commit -m "Add integration tests for network tools" -``` diff --git a/docs/superpowers/specs/2026-03-25-flow-mcp-server-design.md b/docs/superpowers/specs/2026-03-25-flow-mcp-server-design.md deleted file mode 100644 index 4c2c50eef..000000000 --- a/docs/superpowers/specs/2026-03-25-flow-mcp-server-design.md +++ /dev/null @@ -1,224 +0,0 @@ -# Flow MCP Server Design - -## Overview - -Add a `flow mcp` command to flow-cli that starts a Model Context Protocol (MCP) -server over stdio for Cadence smart contract development. The server exposes 9 -tools across two categories: LSP tools (in-process language server) and -on-chain query / code review tools. - -This replaces the need for a separate TypeScript MCP server (see -[cadence-lang.org PR #285](https://github.com/onflow/cadence-lang.org/pull/285)) -by integrating directly into the Go CLI with no extra runtime dependencies. - -## Tools - -### LSP Tools (5) - -These wrap the in-process `cadence-tools/languageserver` Server. - -| Tool | Description | Parameters | -|---|---|---| -| `cadence_check` | Check Cadence code for syntax and type errors | `code`, `filename?`, `network?` | -| `cadence_hover` | Get type info and docs for a symbol at a position | `code`, `line`, `character`, `filename?`, `network?` | -| `cadence_definition` | Find definition location of a symbol | `code`, `line`, `character`, `filename?`, `network?` | -| `cadence_symbols` | List all symbols (contracts, resources, functions, events) | `code`, `filename?`, `network?` | -| `cadence_completion` | Get completions at a position | `code`, `line`, `character`, `filename?`, `network?` | - -All LSP tools accept an optional `network` parameter (mainnet/testnet/emulator, -default mainnet) for resolving on-chain imports. - -### Audit Tools (4) - -These use flowkit gRPC gateways for on-chain data and pattern matching for code review. - -| Tool | Description | Parameters | -|---|---|---| -| `get_contract_source` | Fetch on-chain contract manifest (names, sizes, imports, dependency graph) | `address`, `network?`, `recurse?` | -| `get_contract_code` | Fetch source code of contracts from an address | `address`, `contract_name?`, `network?` | -| `cadence_code_review` | Review Cadence code for common issues and best practices | `code`, `network?` | -| `cadence_execute_script` | Execute a read-only Cadence script on-chain | `code`, `network?`, `args?` | - -## Architecture - -``` -flow mcp (stdio) - | - v -+-- MCP Server (mcp-go) ------------------------------------+ -| | -| LSP Tools --> LSPWrapper --> server.Server (cadence-tools) | -| (doc lifecycle (in-process) | -| management) | -| | -| Audit Tools --> flowkit gateway --> Flow network | -| | -+------------------------------------------------------------+ -``` - -### Package Structure - -``` -internal/mcp/ - mcp.go - Cobra command + MCP server setup, tool registration - lsp.go - LSP wrapper: server.Server lifecycle, diagnostic capture - audit.go - Code review rules (cadence_code_review) - tools.go - Tool handler implementations (all 9 tools) -``` - -Registered in `cmd/flow/main.go` alongside other top-level commands. - -## Command - -```go -var Cmd = &cobra.Command{ - Use: "mcp", - Short: "Start the Cadence MCP server", -} -``` - -Uses `Run` (not `RunS`) so it works without a `flow.json`. If `flow.json` is -found, its network configurations are used (allowing custom host overrides). -Otherwise, hardcoded defaults are used for mainnet/testnet/emulator. - -The `--help` output includes installation instructions for Claude Code, Cursor, -and Claude Desktop, plus a summary of available tools. - -## LSP Wrapper - -### In-Process Server - -The wrapper manages a `server.Server` instance from `cadence-tools/languageserver`. - -```go -type LSPWrapper struct { - server *server.Server - mu sync.Mutex -} -``` - -Created at startup with: -1. `server.NewServer()` to create the LSP server -2. `integration.NewFlowIntegration(server, true)` to enable on-chain import resolution - -### Document Lifecycle - -The LSP server stores documents in an in-memory map (`s.documents`), not on -disk. The `file:///` URI is purely virtual — no actual files are created. - -Since the LSP server has no `DidCloseTextDocument` handler, opened documents -stay in the map forever. To avoid unbounded accumulation, we reuse a single -virtual URI (`file:///mcp/scratch.cdc`) as a scratch buffer: - -1. First call: `DidOpenTextDocument` with the virtual URI and the code string -2. Every subsequent call: `DidChangeTextDocument` to replace the content -3. The LSP runs the type checker on the updated content -4. Call the LSP method (`Hover`, `Completion`, etc.) and return the result - -Each MCP tool call is independent — it overwrites the scratch buffer with its -code, queries the LSP, and returns. Calls are serialized by the mutex so there -is no contention over the single URI. - -### Diagnostic Capture - -`DidOpenTextDocument` and `DidChangeTextDocument` trigger type checking, which -pushes diagnostics via `conn.Notify("textDocument/publishDiagnostics", ...)`. - -A thin `protocol.Conn` adapter captures these: - -```go -type diagConn struct { - diagnostics []protocol.Diagnostic -} - -func (c *diagConn) Notify(_ context.Context, method string, params any) error { - if method == "textDocument/publishDiagnostics" { - // extract and store diagnostics - } - return nil -} -``` - -The `cadence_check` tool returns these captured diagnostics. Other tools -(hover, completion, etc.) ignore them. - -### Serialization - -All LSP operations are serialized via `sync.Mutex`. The LSP server is -single-threaded by design — concurrent document updates would corrupt state. - -## Network Configuration - -### flow.json Detection - -At startup: -1. Attempt `flowkit.Load()` to find and load `flow.json` -2. If found, use its network configurations (custom hosts, accounts, aliases) -3. If not found, proceed with defaults — the server still works - -### Gateway Creation - -```go -func (m *MCPServer) gatewayForNetwork(network string) (gateway.Gateway, error) { - if m.state != nil { - net, err := m.state.Networks().ByName(network) - if err == nil { - return gateway.NewGrpcGateway(net) - } - } - return gateway.NewGrpcGateway(defaultNetworks[network]) -} -``` - -Default network addresses: -- mainnet: `access.mainnet.nodes.onflow.org:9000` -- testnet: `access.devnet.nodes.onflow.org:9000` -- emulator: `127.0.0.1:3569` - -## cadence_code_review Rules - -Regex-based pattern matching for common Cadence issues and best practices. -These are heuristic checks — not a substitute for a proper security audit. -Ported from the TypeScript PR: - -| Rule | Severity | Pattern | -|---|---|---| -| overly-permissive-access | warning | `access(all) var/let` on state fields | -| overly-permissive-function | note | `access(all) fun` | -| deprecated-pub | info | `pub` keyword (deprecated in Cadence 1.0) | -| unsafe-force-unwrap | note | Force-unwrap `!` | -| auth-account-exposure | warning | `AuthAccount` or `auth(...) &Account` | -| hardcoded-address | info | Hardcoded `0x...` not in imports | -| unguarded-capability | warning | `.publish(` calls | -| potential-reentrancy | note | `.borrow` followed by `self.` mutation | -| resource-loss-destroy | warning | `destroy()` calls | - -When the LSP is available, `cadence_code_review` also runs a full type check -and merges those diagnostics into the output. - -## Help Text - -`flow mcp --help` includes: - -- What the server does -- Installation for Claude Code: `claude mcp add cadence-mcp -- flow mcp` -- Configuration for Cursor / Claude Desktop (JSON snippet) -- List of all available tools with descriptions - -## Testing - -- **LSP wrapper:** Unit tests with real `server.Server` — check Cadence snippets - for expected diagnostics, hover info, completions -- **cadence_code_review:** Unit tests against known-vulnerable and clean Cadence - snippets, verify expected findings -- **Network tools:** Integration tests behind `SKIP_NETWORK_TESTS` for - `get_contract_source`, `get_contract_code`, `cadence_execute_script` -- **MCP server:** End-to-end tool call tests with mock inputs - -All tests in `internal/mcp/*_test.go`. - -## Dependencies - -- `github.com/mark3labs/mcp-go` — Go MCP SDK (stdio transport, tool registration) -- `github.com/onflow/cadence-tools/languageserver` — already in go.mod -- `github.com/onflow/flowkit/v2` — already in go.mod From 4cdf25c6238dac805a8eb55aa8889e9395c6a287 Mon Sep 17 00:00:00 2001 From: Peter Argue <89119817+peterargue@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:42:14 -0700 Subject: [PATCH 14/14] Add file parameter as alternative to code for all Cadence tools --- internal/mcp/tools.go | 68 ++++++++++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index aacb535bc..eeac71773 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -22,6 +22,7 @@ import ( "context" "encoding/json" "fmt" + "os" "sort" "strings" @@ -40,14 +41,33 @@ type mcpContext struct { state *flowkit.State // may be nil } +// 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 +} + // registerTools registers all MCP tools on the given server. func registerTools(s *mcpserver.MCPServer, mctx *mcpContext) { // LSP tools — only register if the LSP wrapper is available. if mctx.lsp != nil { s.AddTool( mcplib.NewTool("cadence_check", - mcplib.WithDescription("Check Cadence code for syntax and type errors"), - mcplib.WithString("code", mcplib.Required(), mcplib.Description("Cadence source code to check")), + mcplib.WithDescription("Check Cadence code for syntax and type errors. Provide either code or file path."), + mcplib.WithString("code", mcplib.Description("Cadence source code to check")), + mcplib.WithString("file", mcplib.Description("Path to a .cdc file to check (alternative to code)")), mcplib.WithString("network", mcplib.Description("Flow network for address resolution"), mcplib.Enum("mainnet", "testnet", "emulator")), ), mctx.cadenceCheck, @@ -55,8 +75,9 @@ func registerTools(s *mcpserver.MCPServer, mctx *mcpContext) { s.AddTool( mcplib.NewTool("cadence_hover", - mcplib.WithDescription("Get type information for a symbol at a position in Cadence code"), - mcplib.WithString("code", mcplib.Required(), mcplib.Description("Cadence source code")), + mcplib.WithDescription("Get type information for a symbol at a position in Cadence code. Provide either code or file path."), + mcplib.WithString("code", mcplib.Description("Cadence source code")), + mcplib.WithString("file", mcplib.Description("Path to a .cdc file (alternative to code)")), mcplib.WithNumber("line", mcplib.Required(), mcplib.Description("0-based line number")), mcplib.WithNumber("character", mcplib.Required(), mcplib.Description("0-based column number")), mcplib.WithString("network", mcplib.Description("Flow network for address resolution"), mcplib.Enum("mainnet", "testnet", "emulator")), @@ -66,8 +87,9 @@ func registerTools(s *mcpserver.MCPServer, mctx *mcpContext) { s.AddTool( mcplib.NewTool("cadence_definition", - mcplib.WithDescription("Find where a symbol is defined in Cadence code"), - mcplib.WithString("code", mcplib.Required(), mcplib.Description("Cadence source code")), + mcplib.WithDescription("Find where a symbol is defined in Cadence code. Provide either code or file path."), + mcplib.WithString("code", mcplib.Description("Cadence source code")), + mcplib.WithString("file", mcplib.Description("Path to a .cdc file (alternative to code)")), mcplib.WithNumber("line", mcplib.Required(), mcplib.Description("0-based line number")), mcplib.WithNumber("character", mcplib.Required(), mcplib.Description("0-based column number")), mcplib.WithString("network", mcplib.Description("Flow network for address resolution"), mcplib.Enum("mainnet", "testnet", "emulator")), @@ -77,8 +99,9 @@ func registerTools(s *mcpserver.MCPServer, mctx *mcpContext) { s.AddTool( mcplib.NewTool("cadence_symbols", - mcplib.WithDescription("List all symbols in Cadence code"), - mcplib.WithString("code", mcplib.Required(), mcplib.Description("Cadence source code")), + mcplib.WithDescription("List all symbols in Cadence code. Provide either code or file path."), + mcplib.WithString("code", mcplib.Description("Cadence source code")), + mcplib.WithString("file", mcplib.Description("Path to a .cdc file (alternative to code)")), mcplib.WithString("network", mcplib.Description("Flow network for address resolution"), mcplib.Enum("mainnet", "testnet", "emulator")), ), mctx.cadenceSymbols, @@ -86,8 +109,9 @@ func registerTools(s *mcpserver.MCPServer, mctx *mcpContext) { s.AddTool( mcplib.NewTool("cadence_completion", - mcplib.WithDescription("Get completion suggestions at a position in Cadence code"), - mcplib.WithString("code", mcplib.Required(), mcplib.Description("Cadence source code")), + mcplib.WithDescription("Get completion suggestions at a position in Cadence code. Provide either code or file path."), + mcplib.WithString("code", mcplib.Description("Cadence source code")), + mcplib.WithString("file", mcplib.Description("Path to a .cdc file (alternative to code)")), mcplib.WithNumber("line", mcplib.Required(), mcplib.Description("0-based line number")), mcplib.WithNumber("character", mcplib.Required(), mcplib.Description("0-based column number")), mcplib.WithString("network", mcplib.Description("Flow network for address resolution"), mcplib.Enum("mainnet", "testnet", "emulator")), @@ -118,8 +142,9 @@ func registerTools(s *mcpserver.MCPServer, mctx *mcpContext) { s.AddTool( mcplib.NewTool("cadence_code_review", - mcplib.WithDescription("Review Cadence code for common issues and anti-patterns"), - mcplib.WithString("code", mcplib.Required(), mcplib.Description("Cadence source code to review")), + mcplib.WithDescription("Review Cadence code for common issues and anti-patterns. Provide either code or file path."), + mcplib.WithString("code", mcplib.Description("Cadence source code to review")), + mcplib.WithString("file", mcplib.Description("Path to a .cdc file to review (alternative to code)")), mcplib.WithString("network", mcplib.Description("Flow network for address resolution"), mcplib.Enum("mainnet", "testnet", "emulator")), ), mctx.cadenceCodeReview, @@ -127,8 +152,9 @@ func registerTools(s *mcpserver.MCPServer, mctx *mcpContext) { s.AddTool( mcplib.NewTool("cadence_execute_script", - mcplib.WithDescription("Execute a read-only Cadence script on-chain"), - mcplib.WithString("code", mcplib.Required(), mcplib.Description("Cadence script source code")), + mcplib.WithDescription("Execute a read-only Cadence script on-chain. Provide either code or file path."), + mcplib.WithString("code", mcplib.Description("Cadence script source code")), + mcplib.WithString("file", mcplib.Description("Path to a .cdc script file (alternative to code)")), mcplib.WithString("network", mcplib.Description("Flow network to execute against"), mcplib.Enum("mainnet", "testnet", "emulator")), mcplib.WithString("arguments", mcplib.Description("JSON array of arguments as strings, e.g. [\"String:hello\", \"UFix64:1.0\"]")), ), @@ -141,7 +167,7 @@ func registerTools(s *mcpserver.MCPServer, mctx *mcpContext) { // --------------------------------------------------------------------------- func (m *mcpContext) cadenceCheck(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { - code, err := req.RequireString("code") + code, err := resolveCode(req) if err != nil { return mcplib.NewToolResultError(err.Error()), nil } @@ -155,7 +181,7 @@ func (m *mcpContext) cadenceCheck(_ context.Context, req mcplib.CallToolRequest) } func (m *mcpContext) cadenceHover(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { - code, err := req.RequireString("code") + code, err := resolveCode(req) if err != nil { return mcplib.NewToolResultError(err.Error()), nil } @@ -177,7 +203,7 @@ func (m *mcpContext) cadenceHover(_ context.Context, req mcplib.CallToolRequest) } func (m *mcpContext) cadenceDefinition(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { - code, err := req.RequireString("code") + code, err := resolveCode(req) if err != nil { return mcplib.NewToolResultError(err.Error()), nil } @@ -203,7 +229,7 @@ func (m *mcpContext) cadenceDefinition(_ context.Context, req mcplib.CallToolReq } func (m *mcpContext) cadenceSymbols(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { - code, err := req.RequireString("code") + code, err := resolveCode(req) if err != nil { return mcplib.NewToolResultError(err.Error()), nil } @@ -217,7 +243,7 @@ func (m *mcpContext) cadenceSymbols(_ context.Context, req mcplib.CallToolReques } func (m *mcpContext) cadenceCompletion(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { - code, err := req.RequireString("code") + code, err := resolveCode(req) if err != nil { return mcplib.NewToolResultError(err.Error()), nil } @@ -348,7 +374,7 @@ func (m *mcpContext) getContractCode(ctx context.Context, req mcplib.CallToolReq } func (m *mcpContext) cadenceCodeReview(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { - code, err := req.RequireString("code") + code, err := resolveCode(req) if err != nil { return mcplib.NewToolResultError(err.Error()), nil } @@ -369,7 +395,7 @@ func (m *mcpContext) cadenceCodeReview(_ context.Context, req mcplib.CallToolReq } func (m *mcpContext) cadenceExecuteScript(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { - code, err := req.RequireString("code") + code, err := resolveCode(req) if err != nil { return mcplib.NewToolResultError(err.Error()), nil }