Build a Code Intelligence MCP Server
Build an MCP server in Go that gives Claude Code a live symbol graph of any codebase.
Introduction
30 Second Summary
AI coding assistants are powerful, but they often struggle to understand the structure of your code. They grep through files, guess at relationships, and miss the big picture of how functions connect to each other.
In this project, you will build a local MCP server in Go that parses source code into a queryable symbol graph. You will connect it to Claude Code so it can answer precise questions about any Go codebase's functions, types, and call relationships.
What You'll Build
You will run a single command and watch Claude Code instantly map out every function and type in a Go project, then ask it "what does HandleRequest call?" and get a precise, structured answer pulled from your live symbol graph.
By the end of this project, you'll have:
- A running codegraph binary that you point at any Go project directory and it instantly indexes every function, method, and type into a structured symbol graph.
- Two new tools in Claude Code (list_symbols and get_symbol) that let your AI agent query call relationships and browse codebase structure without grepping through files.
- Hands-on experience with Tree-sitter (the same parser powering GitHub's code search), walking an Abstract Syntax Tree programmatically to extract meaningful data from raw source code.
- Secret Mission: Add import graph tracking so Claude can answer file-level dependency questions like "what does this package depend on?"
Are there any prerequisites?
You should have basic Go knowledge (structs, functions, error handling) and the Go toolchain installed. You also need Claude Code installed and working. Step 1 will verify everything is ready.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up the Project and Dependencies
Every Go project starts with a module and its dependencies. Your MCP server depends on three external packages: Tree-sitter's Go bindings for parsing source code into ASTs, the Tree-sitter Go grammar that teaches the parser Go-specific syntax, and the MCP SDK for serving tools to Claude Code.
Tree-sitter's core is written in C. Go calls it through CGO, a bridge between Go and C code. This step confirms that the C toolchain compiles and links correctly before you write any real parsing logic.
In this step, get ready to:
- Initialize a Go module and install three dependencies.
- Create placeholder main.go and parser.go files.
- Confirm CGO compiles Tree-sitter's C code successfully.
Initialize the module and install dependencies
You need a fresh Go module to hold your MCP server code. Once the module exists, you can pull in the three packages it depends on.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir codegraph
cd codegraph
- Initialize a new Go module called codegraph by running:
go mod init codegraph
You should see the confirmation: go: creating new go.mod: module codegraph.
- Install the three dependencies by running these commands:
go get github.com/tree-sitter/go-tree-sitter@latest
go get github.com/tree-sitter/tree-sitter-go
go get github.com/modelcontextprotocol/go-sdk
Each command prints download and version resolution messages as it fetches the package from the Go module proxy.
What do these three packages do?
- go-tree-sitter provides Go bindings for Tree-sitter's C core. It lets you create parsers, feed them source code, and walk the resulting syntax trees.
- tree-sitter-go is the grammar that teaches Tree-sitter how to parse Go source files specifically.
- go-sdk is the official MCP Go SDK. It handles the protocol handshake, tool registration, and stdio transport so your server can talk to Claude Code.
Seeing a CGO or compiler error?
The Tree-sitter packages require a working C compiler. On macOS, this means Xcode Command Line Tools must be installed. Confirm by running xcode-select -p in your terminal. If it prints a path, you are set.
If you see an error about a missing compiler, run xcode-select --install and follow the prompts. Then retry the go get commands.
Help me fix this CGO error
Create the project files
Go needs at least one .go file with a package main declaration before it can build. You will create two files: main.go for the server entry point and parser.go for the Tree-sitter parsing logic.
- Create main.go with a minimal main function by running:
cat > main.go << 'EOF'
package main
func main() {}
EOF
- Create parser.go with just a package declaration by running:
cat > parser.go << 'EOF'
package main
EOF
- Confirm both files exist by running:
ls
You should see go.mod, go.sum, main.go, and parser.go listed in the output.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved and you are ready to verify the build.
ⓧ I'd like to double check the full code
Here is what each file should contain at this point.
main.go:
package main
func main() {}
parser.go:
package main
Verify the build
The real test is whether Go can compile the C code inside the Tree-sitter bindings. A successful go build means CGO found your C compiler, linked the Tree-sitter library, and produced no errors.
Before you run this, what do you expect to happen? Will Go produce any output if the build succeeds?
- Build the entire module by running:
go build ./...
The command exits silently with no output. In Go, silence means success. Your C toolchain linked the Tree-sitter code without errors.
Seeing a build error?
If you see an error mentioning cc or gcc not found, your system is missing a C compiler. Run xcode-select --install and retry.
If you see a permission error, make sure you are inside the codegraph directory (run pwd to check).
Help me fix this build error
Your module is ready. The C toolchain compiles Tree-sitter without issues, and your two placeholder files give Go a valid package to build. Next up, you will write a parser that reads a Go file and prints its AST.
Parse Go Code and See the AST
In the previous step, you installed the Tree-sitter bindings and confirmed CGO compiles. Now it is time to actually parse some Go code and see what Tree-sitter produces.
Before you can extract functions and types from source code, you need to understand the raw material you are working with. Tree-sitter transforms source code into an Abstract Syntax Tree (AST), a nested data structure representing every piece of the code's grammar.
In this step, get ready to:
- Write a function that feeds Go source bytes to Tree-sitter and returns the parsed tree.
- Add a temporary main that reads a file, parses it, and prints the AST as an S-expression.
- Run the program against a real Go file and observe the tree structure.
Write the ParseTree function
Tree-sitter's core is a C library. The Go bindings let you create a parser, load a language grammar, and feed source bytes in to get a tree back.
- Replace the contents of parser.go with the following code:
package main
import (
tree_sitter "github.com/tree-sitter/go-tree-sitter"
tree_sitter_go "github.com/tree-sitter/tree-sitter-go/bindings/go"
)
func ParseTree(src []byte) *tree_sitter.Tree {
parser := tree_sitter.NewParser()
lang := tree_sitter.NewLanguage(tree_sitter_go.Language())
if err := parser.SetLanguage(lang); err != nil {
return nil
}
return parser.Parse(src, nil)
}
What does this code do?
- tree_sitter.NewParser() creates a new parser instance backed by the C library.
- tree_sitter.NewLanguage(tree_sitter_go.Language()) loads the Go grammar so the parser knows Go syntax rules.
- parser.Parse(src, nil) feeds the source bytes in and returns a *tree_sitter.Tree containing the full syntax tree.
- Save parser.go.
- Confirm it compiles by running:
go build ./...
The command should exit with no output, meaning CGO compiled the Tree-sitter C code and your imports resolved correctly.
Seeing an import error?
- Make sure the import paths match exactly: github.com/tree-sitter/go-tree-sitter and github.com/tree-sitter/tree-sitter-go/bindings/go.
- If you see a CGO-related error, confirm Xcode Command Line Tools are installed by running xcode-select -p in your terminal.
Help me fix a go build error with Tree-sitter imports
Add a temporary main to print the AST
To see what Tree-sitter actually produces, you need a way to run the parser and print the output. An S-expression is a text representation of a tree where each node is a parenthesized group.
- Replace the contents of main.go with the following code:
package main
import (
"fmt"
"os"
)
func main() {
// Check that the user provided a file path
if len(os.Args) < 2 {
fmt.Println("Usage: go run . <file.go>")
os.Exit(1)
}
// Read the source file into memory
src, err := os.ReadFile(os.Args[1])
if err != nil {
panic(err)
}
// Parse the file and print the S-expression
tree := ParseTree(src)
if tree != nil {
fmt.Println(tree.RootNode().ToSexp())
}
}
What does this code do?
- os.Args[1] grabs the file path the user passes on the command line.
- os.ReadFile loads the entire file into a byte slice, which is what Tree-sitter expects.
- tree.RootNode().ToSexp() converts the root of the parsed tree into a Lisp-style S-expression string you can read in the terminal.
- Save main.go.
✔️ Awesome, I've got everything!
Great. Double check you have saved both parser.go and main.go.
ⓧ I'd like to double check the full code
Here is the full parser.go:
package main
import (
tree_sitter "github.com/tree-sitter/go-tree-sitter"
tree_sitter_go "github.com/tree-sitter/tree-sitter-go/bindings/go"
)
func ParseTree(src []byte) *tree_sitter.Tree {
parser := tree_sitter.NewParser()
lang := tree_sitter.NewLanguage(tree_sitter_go.Language())
if err := parser.SetLanguage(lang); err != nil {
return nil
}
return parser.Parse(src, nil)
}
Here is the full main.go:
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: go run . <file.go>")
os.Exit(1)
}
src, err := os.ReadFile(os.Args[1])
if err != nil {
panic(err)
}
tree := ParseTree(src)
if tree != nil {
fmt.Println(tree.RootNode().ToSexp())
}
}
Run the parser on a real Go file
Your parser is ready to run against any Go file. You will point it at your own parser.go since it already contains a function declaration that Tree-sitter can break down.
Before you run this, what do you think the output will look like for a file that contains one function?
- Run the parser against parser.go by running:
go run . parser.go
You should see a single long line of parenthesized text starting with (source_file and containing (function_declaration name: (identifier) ...) structure.
What am I looking at?
This is the S-expression representation of your file's AST. Every parenthesized group is a node in the tree.
- source_file is the root node. It contains everything in the file.
- function_declaration represents the ParseTree function. Its name field holds the identifier.
- In later steps, you will walk these nodes programmatically to extract symbols.
Seeing a panic or no output?
- If you see panic: open parser.go: no such file or directory, make sure you are running the command from your codegraph project folder where parser.go lives.
- If the command exits with no output at all, check that parser.go is not empty. It needs the ParseTree function you just wrote.
Help me debug my Tree-sitter parser output
You can now see exactly how Tree-sitter breaks down Go source code into structured nodes. Next up, you will walk this tree and extract the meaningful pieces into clean data structures Claude can consume.
Extract Symbols from the AST
In the last step, you parsed a Go file and printed its raw AST as an S-expression. That proved Tree-sitter can read your code, but a wall of nested nodes is far too noisy for an AI agent to consume.
In this step, you will walk the AST and pull out only the meaningful pieces: function names, method names, and type definitions. The result is a clean, structured list that an AI tool can actually reason about.
In this step, get ready to:
- Define data structures to represent symbols and their relationships.
- Write a recursive tree-walker that extracts functions, methods, and types from the AST.
- Run the extractor against a real Go project and see structured output.
Define the Symbol and SymbolGraph types
Before you can extract anything, you need somewhere to store the results. You will define a Symbol struct for each extracted item and a SymbolGraph to hold all symbols plus their relationships.
- Delete everything in parser.go and replace it with the following package declaration, imports, and data types:
package main
import (
"fmt"
tree_sitter "github.com/tree-sitter/go-tree-sitter"
tree_sitter_go "github.com/tree-sitter/tree-sitter-go/bindings/go"
)
type Symbol struct {
Name string
Kind string
File string
Line int
Signature string
}
type SymbolGraph struct {
Symbols []Symbol
Calls map[string][]string
CalledBy map[string][]string
}
func NewSymbolGraph() *SymbolGraph {
return &SymbolGraph{
Calls: make(map[string][]string),
CalledBy: make(map[string][]string),
}
}
What do these types hold?
Symbol captures what you need to identify any code entity: its name, what kind it is (function, method, struct), which file it lives in, its line number, and a human-readable signature.
SymbolGraph holds the full collection of symbols plus two maps (Calls and CalledBy) that you will populate with call-graph edges in a later step.
Write the file parser and tree walker
Now you need the function that feeds source code to Tree-sitter and kicks off the extraction. This replaces the old ParseTree function with something that writes directly into the graph.
- Add the following ParseFile function below NewSymbolGraph in parser.go:
func ParseFile(src []byte, filename string, graph *SymbolGraph) {
parser := tree_sitter.NewParser()
defer parser.Close()
lang := tree_sitter.NewLanguage(tree_sitter_go.Language())
if err := parser.SetLanguage(lang); err != nil {
return
}
tree := parser.Parse(src, nil)
if tree == nil {
return
}
defer tree.Close()
root := tree.RootNode()
extractSymbols(root, src, filename, graph)
}
What does ParseFile do?
It creates a fresh parser, parses the source bytes into a tree, then hands the root node to extractSymbols for the actual extraction work. The defer Close() calls are required because Tree-sitter allocates C memory that Go's garbage collector cannot reach.
- Add the extractSymbols dispatcher below ParseFile:
func extractSymbols(node *tree_sitter.Node, src []byte, filename string, graph *SymbolGraph) {
for i := 0; i < int(node.ChildCount()); i++ {
child := node.Child(uint(i))
if child == nil {
continue
}
switch child.Type() {
case "function_declaration":
sym := extractFunction(child, src, filename)
if sym != nil {
graph.Symbols = append(graph.Symbols, *sym)
}
case "method_declaration":
sym := extractMethod(child, src, filename)
if sym != nil {
graph.Symbols = append(graph.Symbols, *sym)
}
case "type_declaration":
extractTypes(child, src, filename, graph)
}
}
}
How does the dispatcher work?
It iterates over every top-level child of the root node. When it finds a function_declaration, method_declaration, or type_declaration, it routes to the appropriate extraction helper. All other node types (imports, comments, blank lines) are silently skipped.
Add the extraction helpers
Each helper uses ChildByFieldName to reach into a grammar node's named fields. This is the Tree-sitter equivalent of asking "give me the function's name node" without caring about its position in the child list.
- Add extractFunction below extractSymbols:
func extractFunction(node *tree_sitter.Node, src []byte, filename string) *Symbol {
nameNode := node.ChildByFieldName("name")
if nameNode == nil {
return nil
}
name := string(src[nameNode.StartByte():nameNode.EndByte()])
params := node.ChildByFieldName("parameters")
result := node.ChildByFieldName("result")
sig := "func " + name
if params != nil {
sig += string(src[params.StartByte():params.EndByte()])
}
if result != nil {
sig += " " + string(src[result.StartByte():result.EndByte()])
}
return &Symbol{
Name: name,
Kind: "function",
File: filename,
Line: int(nameNode.StartPosition().Row) + 1,
Signature: sig,
}
}
What does this extract?
For a function_declaration node, the Go grammar exposes the fields name, parameters, and result. The function reads the raw source bytes at each field's byte range to build a human-readable signature like func main().
Line numbers are offset by +1 because Tree-sitter rows are zero-indexed.
- Add extractMethod below extractFunction. It follows the same pattern but also reads the receiver field:
func extractMethod(node *tree_sitter.Node, src []byte, filename string) *Symbol {
nameNode := node.ChildByFieldName("name")
if nameNode == nil {
return nil
}
name := string(src[nameNode.StartByte():nameNode.EndByte()])
receiver := node.ChildByFieldName("receiver")
params := node.ChildByFieldName("parameters")
result := node.ChildByFieldName("result")
sig := "func "
if receiver != nil {
sig += string(src[receiver.StartByte():receiver.EndByte()]) + " "
}
sig += name
if params != nil {
sig += string(src[params.StartByte():params.EndByte()])
}
if result != nil {
sig += " " + string(src[result.StartByte():result.EndByte()])
}
return &Symbol{
Name: name,
Kind: "method",
File: filename,
Line: int(nameNode.StartPosition().Row) + 1,
Signature: sig,
}
}
- Add extractTypes below extractMethod. This handles struct and interface declarations:
func extractTypes(node *tree_sitter.Node, src []byte, filename string, graph *SymbolGraph) {
for i := 0; i < int(node.ChildCount()); i++ {
child := node.Child(uint(i))
if child == nil {
continue
}
if child.Type() == "type_spec" {
nameNode := child.ChildByFieldName("name")
if nameNode == nil {
continue
}
name := string(src[nameNode.StartByte():nameNode.EndByte()])
typeNode := child.ChildByFieldName("type")
kind := "type"
if typeNode != nil {
switch typeNode.Type() {
case "struct_type":
kind = "struct"
case "interface_type":
kind = "interface"
}
}
graph.Symbols = append(graph.Symbols, Symbol{
Name: name,
Kind: kind,
File: filename,
Line: int(nameNode.StartPosition().Row) + 1,
Signature: fmt.Sprintf("type %s %s", name, kind),
})
}
}
}
Why iterate children of type_declaration?
A single type_declaration node can contain multiple type_spec children when the source uses a grouped declaration like type ( ... ). The loop ensures you catch every type defined in the group.
- Save parser.go.
- Confirm the new code compiles by running:
go build ./...
You will see an error like undefined: ParseTree because main.go still references the old function you deleted. That is expected.
Now you need to update main.go to call ParseFile instead of ParseTree and print the extracted symbols.
- Replace the entire contents of main.go with the following:
package main
import (
"fmt"
"os"
"path/filepath"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: go run . <directory>")
os.Exit(1)
}
dir := os.Args[1]
graph := NewSymbolGraph()
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || filepath.Ext(path) != ".go" {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return nil
}
relPath, _ := filepath.Rel(dir, path)
ParseFile(src, relPath, graph)
return nil
})
for _, sym := range graph.Symbols {
fmt.Printf("%s %s (%s:%d)\n", sym.Kind, sym.Name, sym.File, sym.Line)
}
}
What changed in main.go?
Instead of reading a single file and printing the raw S-expression, this version takes a directory argument, walks every .go file inside it, and runs ParseFile on each one. At the end, it prints each discovered symbol in a readable format.
- Save main.go.
Before you run this, what do you think the output will look like when you point it at your own project directory?
- Test the extractor by pointing it at the current directory:
go run . .
You should see a list of every function, method, and type in your own project, printed one per line. The output looks something like:
function NewSymbolGraph (parser.go:24)
function ParseFile (parser.go:31)
function extractSymbols (parser.go:50)
function extractFunction (parser.go:74)
function extractMethod (parser.go:97)
function extractTypes (parser.go:123)
function main (main.go:9)
Seeing a compile error?
- If you see undefined: ParseTree, make sure you deleted the old ParseTree function from parser.go and that main.go no longer references it.
- If you see undefined: extractFunction or similar, confirm all six functions (ParseFile, extractSymbols, extractFunction, extractMethod, extractTypes, NewSymbolGraph) are present in parser.go.
- If the output is empty, check that you passed a directory argument (. for the current directory) and that .go files exist there.
Help me debug my compile error
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Since parser.go is long, scroll up and confirm each section is present in your file in this order:
- Package declaration and imports (fmt, go-tree-sitter, tree-sitter-go).
- Symbol struct, SymbolGraph struct, NewSymbolGraph constructor.
- ParseFile function.
- extractSymbols dispatcher.
- extractFunction, extractMethod, extractTypes helpers.
Here is the complete main.go:
package main
import (
"fmt"
"os"
"path/filepath"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: go run . <directory>")
os.Exit(1)
}
dir := os.Args[1]
graph := NewSymbolGraph()
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || filepath.Ext(path) != ".go" {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return nil
}
relPath, _ := filepath.Rel(dir, path)
ParseFile(src, relPath, graph)
return nil
})
for _, sym := range graph.Symbols {
fmt.Printf("%s %s (%s:%d)\n", sym.Kind, sym.Name, sym.File, sym.Line)
}
}
Your parser now transforms raw syntax trees into clean, structured data. Next up, you will wire this into an MCP server so Claude Code can query these symbols directly.
Wire the MCP Server and Connect to Claude Code
Your parser extracts clean symbol data from any Go project. Right now that data prints to your terminal and disappears.
In this step, you will expose those symbols as live tools that Claude Code can query on demand. The Model Context Protocol turns your parser from a one-shot script into persistent infrastructure that an AI agent calls whenever it needs code intelligence.
In this step, get ready to:
- Rewrite main.go to serve an MCP server with two tools.
- Build the binary and register it with Claude Code.
- Verify Claude Code can invoke your tools to query symbols.
Replace main.go with the MCP server
You are going to replace the entire contents of main.go with your new MCP server code. The file currently runs a CLI that prints symbols. After this rewrite, it will listen for tool calls from Claude Code over stdio.
- Open main.go in your editor.
- Select all the existing code and delete it.
- Paste the following package declaration, imports, and type definitions for the list_symbols tool:
package main
import (
"context"
"log"
"os"
"path/filepath"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Input for the list_symbols tool
type ListSymbolsInput struct {
Directory string `json:"directory" jsonschema:"the path to the Go project directory to index"`
}
// Output wraps the list of discovered symbols
type ListSymbolsOutput struct {
Symbols []SymbolInfo `json:"symbols"`
}
// SymbolInfo is the JSON-friendly shape sent back to Claude
type SymbolInfo struct {
Name string `json:"name"`
Kind string `json:"kind"`
File string `json:"file"`
Line int `json:"line"`
Signature string `json:"signature"`
}
What does this code do?
The mcp import pulls in the MCP Go SDK, which handles protocol framing, JSON-RPC, and tool registration.
Each struct uses two tags: json controls the field name in JSON, and jsonschema provides a description that Claude sees when it discovers the tool. These tags let the SDK auto-generate the tool's input schema.
- Below the SymbolInfo struct, paste the input and output types for the get_symbol tool:
// Input for the get_symbol tool
type GetSymbolInput struct {
Directory string `json:"directory" jsonschema:"the path to the Go project directory"`
Name string `json:"name" jsonschema:"the symbol name to look up"`
}
// Output includes call graph edges for the requested symbol
type GetSymbolOutput struct {
Name string `json:"name"`
Kind string `json:"kind"`
File string `json:"file"`
Line int `json:"line"`
Signature string `json:"signature"`
Calls []string `json:"calls"`
CalledBy []string `json:"called_by"`
}
Why does GetSymbolOutput include Calls and CalledBy?
These fields are empty for now because you have not built call graph extraction yet. Defining them now means the response shape is stable when you add edges in the next step.
- Below the type definitions, paste the ListSymbols handler function:
// ListSymbols indexes a directory and returns every symbol found
func ListSymbols(ctx context.Context, req *mcp.CallToolRequest, input ListSymbolsInput) (*mcp.CallToolResult, ListSymbolsOutput, error) {
graph, err := IndexDirectory(input.Directory)
if err != nil {
return nil, ListSymbolsOutput{}, err
}
var symbols []SymbolInfo
for _, sym := range graph.Symbols {
symbols = append(symbols, SymbolInfo{
Name: sym.Name,
Kind: sym.Kind,
File: sym.File,
Line: sym.Line,
Signature: sym.Signature,
})
}
return nil, ListSymbolsOutput{Symbols: symbols}, nil
}
What does this handler do?
The MCP SDK calls this function whenever Claude invokes the list_symbols tool. It receives the parsed JSON input, indexes the directory using your Tree-sitter parser, and returns structured symbol data. The SDK serializes the output back over stdio.
- Below ListSymbols, paste the GetSymbol handler:
// GetSymbol looks up a single symbol by name and returns its details
func GetSymbol(ctx context.Context, req *mcp.CallToolRequest, input GetSymbolInput) (*mcp.CallToolResult, GetSymbolOutput, error) {
graph, err := IndexDirectory(input.Directory)
if err != nil {
return nil, GetSymbolOutput{}, err
}
for _, sym := range graph.Symbols {
if sym.Name == input.Name {
calls := graph.Calls[sym.Name]
calledBy := graph.CalledBy[sym.Name]
return nil, GetSymbolOutput{
Name: sym.Name,
Kind: sym.Kind,
File: sym.File,
Line: sym.Line,
Signature: sym.Signature,
Calls: calls,
CalledBy: calledBy,
}, nil
}
}
return nil, GetSymbolOutput{Name: input.Name, Kind: "not found"}, nil
}
What happens when the symbol is not found?
Instead of returning an error, the handler returns a response with Kind set to "not found". This gives Claude a clear signal without crashing the server, so it can ask the user to double-check the name.
- Below GetSymbol, paste the IndexDirectory helper that both handlers call:
// IndexDirectory walks a directory, parsing every .go file into the graph
func IndexDirectory(dir string) (*SymbolGraph, error) {
graph := NewSymbolGraph()
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() || filepath.Ext(path) != ".go" {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return nil
}
relPath, _ := filepath.Rel(dir, path)
ParseFile(src, relPath, graph)
return nil
})
if err != nil {
return nil, err
}
return graph, nil
}
What does IndexDirectory do?
This is the same directory-walking logic you had in the old main(), extracted into its own function so both tool handlers can call it. It walks the directory, reads every .go file, and passes the source to your ParseFile function from parser.go.
- Finally, paste the new main() function that creates the MCP server and registers both tools:
func main() {
// Create an MCP server named "codegraph"
server := mcp.NewServer(&mcp.Implementation{Name: "codegraph", Version: "v1.0.0"}, nil)
// Register the list_symbols tool
mcp.AddTool(server, &mcp.Tool{
Name: "list_symbols",
Description: "List all functions, methods, and types in a Go project directory",
}, ListSymbols)
// Register the get_symbol tool
mcp.AddTool(server, &mcp.Tool{
Name: "get_symbol",
Description: "Get details about a specific symbol including what it calls and what calls it",
}, GetSymbol)
// Start the server on stdio (blocks until the client disconnects)
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
log.Fatal(err)
}
}
What does this main function do?
mcp.NewServer creates a server instance with a name and version that Claude Code will display. mcp.AddTool registers each tool with its name, description, and handler function. The SDK uses the struct tags on your input types to generate the JSON schema automatically.
server.Run blocks forever, reading JSON-RPC requests from stdin and writing responses to stdout. Claude Code launches this binary as a subprocess and communicates over these pipes.
- Save main.go.
- Confirm the new code compiles by running:
go build -o codegraph .
The command should exit silently with no errors. You now have a codegraph binary in your project folder.
- Confirm the binary exists by running:
ls codegraph
You should see codegraph printed in the output.
Seeing a build error?
- If you see an "undeclared name" error, check that ListSymbols, GetSymbol, and IndexDirectory are all present in main.go. A missing function means one of the chunks was not pasted.
- If you see an import error for github.com/modelcontextprotocol/go-sdk/mcp, run go mod tidy to re-sync your dependencies.
- If you see CGO errors, confirm your Xcode Command Line Tools are still installed with xcode-select -p.
help me fix this go build error for my MCP server
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and the codegraph binary appears after building.
ⓧ I'd like to double check the full code
Scroll up and confirm each section is present in main.go in this order:
- Package declaration and imports (context, log, os, path/filepath, mcp).
- Five type definitions: ListSymbolsInput, ListSymbolsOutput, SymbolInfo, GetSymbolInput, GetSymbolOutput.
- ListSymbols handler function.
- GetSymbol handler function.
- IndexDirectory helper function.
- main() function creating the server and registering both tools.
If anything is missing, paste the corresponding chunk from above into the correct position.
Build the binary and register it with Claude Code
Claude Code needs to know where your server binary lives so it can launch it on demand. You register the binary using an absolute path.
- Get the absolute path to your binary by running:
pwd
Note the full path printed (for example, /Users/you/Desktop/codegraph). You will use this in the next command.
- Register your MCP server with Claude Code by running:
claude mcp add codegraph -- [[CODEGRAPH_PATH="/full/path/to/codegraph"]]
What does this command do?
claude mcp add registers a new MCP server. codegraph is the name Claude Code will use to identify your server. After the -- separator, you provide the command Claude Code runs to start the server. Since your binary communicates over stdio, no extra flags are needed.
Claude Code now knows about your server. It will launch the binary automatically whenever it needs to call one of your tools.
Getting a 'command not found' error?
Make sure you used the full absolute path to the codegraph binary (the output from pwd combined with /codegraph). A relative path will fail because Claude Code launches the server from its own working directory.
help me register my codegraph MCP server with Claude Code
Test your tools with Claude Code
The real test is whether Claude Code can discover your tools, invoke them, and return structured symbol data from a Go project.
- Open Claude Code in your terminal.
- Navigate to your codegraph project directory inside Claude Code (so it has a Go project to index).
Before you ask Claude to use your tool, predict what you expect to happen. Will Claude know about your list_symbols tool? What symbols should it find in this project?
- Type this prompt into Claude Code:
list the symbols in this project
Claude Code invokes your mcp__codegraph__list_symbols tool. You should see it return structured data listing every function, method, and type from both main.go and parser.go. You will see symbols like ListSymbols, GetSymbol, ParseFile, extractSymbols, and the type definitions.
Claude Code not finding your tools?
- Run claude mcp list in your terminal to confirm codegraph appears in the registered servers.
- If the server appears but Claude does not use the tool, try asking explicitly: "use the codegraph list_symbols tool on this directory".
- If you see a connection error, rebuild with go build -o codegraph . and re-register with the updated path.
help me debug why Claude Code cannot connect to my codegraph MCP server
Your parser is now live infrastructure. Claude Code can query your symbol graph on demand without you running anything manually. Next up, you will add call graph edges so Claude can answer relationship questions like "what does this function call?"
Add Call Graph Edges
Your MCP server is connected to Claude Code, and it can list every function, method, and type in a Go project. But ask it about relationships and you hit a wall.
The get_symbol tool already includes calls and called_by fields in its response. They come back empty every time because nothing populates the graph's edge data yet. In this step, you'll walk the AST a second time looking for call_expression nodes, turning your flat symbol list into a real call graph.
In this step, get ready to:
- Add call graph extraction functions that walk the AST for call_expression nodes.
- Wire the extraction into the existing symbol-parsing pipeline.
- Rebuild and verify Claude Code receives relationship data.
See the gap in action
Before writing any new code, ask Claude Code a relationship question so you can see exactly what's missing.
- In Claude Code, type a question like: "What functions does ParseFile call in this project?"
Before Claude responds, what do you expect to see in the calls array? Think about whether your server currently records any call relationships.
Claude invokes get_symbol and returns a result with empty calls and called_by arrays. The data structures exist, but nothing fills them. That's the gap you'll close now.
Write the call extraction functions
To find call relationships, you need to recursively walk the Abstract Syntax Tree inside each function body. Every call_expression node represents a function call. Its function field holds the name of what's being called.
- In parser.go, add the following two functions at the bottom of the file:
func extractCalls(node *tree_sitter.Node, src []byte, callerName string, graph *SymbolGraph) {
walkForCalls(node, src, callerName, graph)
}
func walkForCalls(node *tree_sitter.Node, src []byte, callerName string, graph *SymbolGraph) {
// Check if this node is a function call
if node.Type() == "call_expression" {
fnNode := node.ChildByFieldName("function")
if fnNode != nil {
callee := string(src[fnNode.StartByte():fnNode.EndByte()])
if callee != "" {
// Record the edge in both directions
graph.Calls[callerName] = append(graph.Calls[callerName], callee)
graph.CalledBy[callee] = append(graph.CalledBy[callee], callerName)
}
}
}
// Recurse into all children to find nested calls
for i := 0; i < int(node.ChildCount()); i++ {
child := node.Child(uint(i))
if child != nil {
walkForCalls(child, src, callerName, graph)
}
}
}
What does this code do?
These two functions work together to trace call relationships:
- extractCalls is the entry point. It takes a function's AST node and kicks off the recursive walk.
- walkForCalls checks every node it visits. If a node is a call_expression, it reads the callee name from the function field and records both directions of the edge.
- The recursion visits every descendant node, catching calls nested inside loops, conditionals, and closures.
Now wire these functions into the existing parsing pipeline. The extractSymbols function already finds each function and method. You need to call extractCalls right after recording each symbol.
- In parser.go, find the extractSymbols function. Locate the "function_declaration" case. It currently looks like this:
case "function_declaration":
sym := extractFunction(child, src, filename)
if sym != nil {
graph.Symbols = append(graph.Symbols, *sym)
}
- Add extractCalls(child, src, sym.Name, graph) on the line after the append, so it becomes:
case "function_declaration":
sym := extractFunction(child, src, filename)
if sym != nil {
graph.Symbols = append(graph.Symbols, *sym)
extractCalls(child, src, sym.Name, graph)
}
- Now find the "method_declaration" case just below. It currently looks like this:
case "method_declaration":
sym := extractMethod(child, src, filename)
if sym != nil {
graph.Symbols = append(graph.Symbols, *sym)
}
- Add the same extractCalls line after the append, so it becomes:
case "method_declaration":
sym := extractMethod(child, src, filename)
if sym != nil {
graph.Symbols = append(graph.Symbols, *sym)
extractCalls(child, src, sym.Name, graph)
}
- Save parser.go.
Why call extractCalls on the function node?
You pass the entire function declaration node (not just the body) because walkForCalls recurses into all children. It will naturally reach the function body and find every call_expression inside it.
The callerName parameter ties each discovered call back to the function that contains it. This is how you know that ParseFile calls extractSymbols, not just that extractSymbols is called somewhere.
- Confirm the code compiles by running:
go build ./...
The command should exit with no output and no errors.
Seeing a compile error?
- If you see undefined: extractCalls, make sure the new functions are in parser.go (same package main) and the file is saved.
- If you see sym.Name undefined, check that the extractCalls line is inside the if sym != nil block, not outside it.
- help me fix a compile error in my call graph code
✔️ Awesome, I've got everything!
Great. Make sure you've saved parser.go with both new functions and both updated case blocks.
ⓧ I'd like to double check the full code
Your parser.go should contain these functions in order:
- NewSymbolGraph (unchanged).
- ParseFile (unchanged).
- extractSymbols (now calls extractCalls after appending each function and method symbol).
- extractFunction (unchanged).
- extractMethod (unchanged).
- extractTypes (unchanged).
- extractCalls (new, delegates to walkForCalls).
- walkForCalls (new, recursively finds call_expression nodes and records edges).
main.go is unchanged from Step 4.
Rebuild and verify with Claude Code
- Rebuild the binary by running:
go build -o codegraph .
The claude mcp add registration from earlier already points to this binary. Rebuilding it in place means Claude Code will use the updated version on its next invocation.
Now ask the same relationship question again. Before you do, predict: what do you expect to see in the calls array this time?
- In Claude Code, ask: "What functions does ParseFile call in this project?"
This time Claude invokes get_symbol and receives a response with populated calls and called_by arrays containing actual function names from your project.
What should I see?
Claude should report something like: ParseFile calls tree_sitter.NewParser, tree_sitter.NewLanguage, parser.SetLanguage, parser.Parse, extractSymbols. The exact names depend on how Tree-sitter represents method calls (it captures the full selector expression like parser.Close).
Try asking "What calls IndexDirectory?" to see the called_by direction. Claude should list ListSymbols and GetSymbol as callers.
Still seeing empty arrays?
- Make sure you rebuilt with go build -o codegraph . (the binary name must match what you registered with claude mcp add).
- Confirm both extractCalls lines are inside the if sym != nil blocks in extractSymbols. If they're outside, the call runs with a nil symbol and does nothing.
- Check that the project directory you're asking Claude about contains .go files with actual function calls in them.
- help me debug why my call graph edges are still empty
Your symbol list just became a symbol graph. Claude Code can now answer "what does X call?" and "what calls X?" with precise, structured data pulled straight from the AST. Next up, you'll take this further in the Secret Mission by adding import-level dependency tracking.
Secret mission
Add Import Graph Tracking
Your codegraph server knows what functions call each other, but it has no idea what packages each file depends on. Add file-level import graph tracking so Claude can answer questions like "what does main.go import?" and "which files use net/http?"
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The codegraph project folder on your Desktop (contains main.go, parser.go, go.mod, and dependencies).
- The Claude Code MCP server registration for codegraph.
Keep everything running
No action needed. Choose this if you're still actively building or want Claude Code to keep using your server.
- The MCP registration stays in place. Claude Code starts the server on-demand whenever it needs code intelligence.
- Your project files remain in ~/Desktop/codegraph.
Pause - I'll come back to this later
Remove the MCP registration so Claude Code doesn't try to start the server. Keep the files so you can re-register later.
- Unregister the server from Claude Code by running:
claude mcp remove codegraph
- Your project files stay in ~/Desktop/codegraph. When you're ready to resume, re-register with claude mcp add.
Delete - I don't want to use this again
Remove all project resources and the MCP registration completely.
- Unregister the server from Claude Code by running:
claude mcp remove codegraph
- Delete the project folder. On macOS/Linux, run:
rm -rf ~/Desktop/codegraph
On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\codegraph
- Confirm deletion by running ls ~/Desktop (macOS/Linux) or dir %USERPROFILE%\Desktop (Windows). The codegraph folder should no longer appear.
Nice Work!
Nice Work!
You just built a production-grade code intelligence server in Go that Claude Code actively queries for structured answers about any codebase.
You've learned how to:
- Parse source code into Abstract Syntax Trees using Tree-sitter, and walk those trees programmatically to extract functions, methods, and types.
- Build a symbol graph with caller/callee relationships by finding call_expression nodes in the AST.
- Wire a Go binary into Claude Code as a live MCP server over stdio transport, exposing custom tools that an AI agent can invoke on demand.
- Secret Mission: Added import graph tracking so Claude Code can answer file-level dependency questions like "what does this file import?" and "which files depend on this package?".
Ready to quiz yourself?