Back to Blog

Claude Code Decoded: Multi-Repo Context Loading

Modern codebases span multiple repositories. Learn how to build an MCP server that intelligently loads context across repos without burning 50,000+ tokens on duplicate dependencies.

Black Dog Labs Team
7/19/2025
13 min read
claude-codeai-developmentmcpmonorepomicroservices

Claude Code Decoded: Multi-Repo Context Loading

Your authentication service calls your user service. Your user service depends on your shared utilities library. Your utilities library imports types from your API contracts repo. When you ask Claude Code to help debug an auth flow, it needs context from all four repositories.

The naive approach:

# Load everything, hope for the best
You: "Here's the auth service..."
*Pastes entire auth-service repo: 15,000 tokens*
You: "And here's the user service it calls..."
*Pastes entire user-service repo: 12,000 tokens*
You: "And the shared utilities..."
*Pastes utilities repo: 8,000 tokens*
Total: 35,000+ tokens (and you still haven't included the API contracts)

Most of that context is irrelevant. The auth bug you're tracking down touches maybe 5 functions across 4 repos - about 500 lines total. But without intelligent cross-repo loading, you're dumping entire codebases into context.

The cost: Teams working with microservices or multi-repo architectures burn 40-60% of their token budget on cross-repo context duplication. And then, inevitably:

claude-code

Mid-debug, your session crashes from context overload. Now you have to reload context from all four repos again. The irony? You only needed 500 lines.

The multi-repo challenge

Why it's hard

Dependency resolution across repos:

// auth-service/src/handlers/login.ts
import { validateUser } from '@company/user-service-client';
import { generateToken } from '@company/shared-utils';
import { LoginRequest } from '@company/api-contracts';
// Claude needs to understand:
// 1. What validateUser does (user-service repo)
// 2. What generateToken does (shared-utils repo)
// 3. The LoginRequest type shape (api-contracts repo)

Without intelligent loading:

  • Load entire client library (8,000 tokens) to understand one function
  • Load entire utils package (6,000 tokens) for token generation
  • Load all API contracts (10,000 tokens) for one type

With intelligent loading:

  • Extract just validateUser function (150 tokens)
  • Extract just generateToken function (100 tokens)
  • Extract just LoginRequest type (50 tokens)

Token savings: 99%

Common anti-patterns

Anti-pattern 1: Manual repository switching

You: "Let me find the user service code..."
*Switches to user-service directory*
*Searches for function*
*Copies function back to Claude*
*Repeats for each dependency*
Time wasted: 5-10 minutes per dependency
Context pollution: Mixed conversations across repos

Anti-pattern 2: Workspace kitchen sink

You: "I'll just add all our repos to the Claude workspace..."
*Adds 12 repositories*
*Every search now returns results from all repos*
*Context limit hit before real work starts*
First interaction: 50,000+ tokens
Usability: Destroyed

Anti-pattern 3: Copy-paste dependency chains

You: "Here's the function... and here's what it calls... and here's what THAT calls..."
*Manually traces 4 levels of dependencies*
*Misses one dependency*
*Claude gives incomplete solution*

Building the Multi-Repo Context Loader

Architecture overview

┌─────────────────┐
│ Claude Code │
│ │
│ /repos load │ ← Load symbol across repos
│ /repos trace │ ← Trace dependency chain
└────────┬────────┘
│ MCP Protocol
┌────────▼────────┐
│ Multi-Repo │
│ MCP Server │
│ │
│ • Repo registry│
│ • Symbol index │
│ • Dep resolver │
└────────┬────────┘
┌────┴────┬────────┬──────────┐
│ │ │ │
┌───▼───┐ ┌──▼──┐ ┌───▼────┐ ┌───▼────┐
│ auth- │ │user-│ │shared- │ │ api- │
│service│ │service│ │utils │ │contracts│
└───────┘ └─────┘ └────────┘ └────────┘

Implementation

1. Repository registry

// src/repo-registry.ts
import path from 'path';
import fs from 'fs/promises';
interface RepoConfig {
name: string;
path: string;
type: 'service' | 'library' | 'contracts';
packageName?: string; // npm package name if published
}
export class RepoRegistry {
private repos: Map<string, RepoConfig> = new Map();
async loadFromConfig(configPath: string) {
const content = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(content);
for (const repo of config.repositories) {
this.repos.set(repo.name, repo);
// Also index by package name if available
if (repo.packageName) {
this.repos.set(repo.packageName, repo);
}
}
}
resolveImport(importPath: string): RepoConfig | null {
// Handle scoped packages: @company/user-service-client
if (importPath.startsWith('@')) {
return this.repos.get(importPath) || null;
}
// Handle relative imports - already in current repo
if (importPath.startsWith('.')) {
return null;
}
// Handle node_modules imports
return this.repos.get(importPath) || null;
}
getRepo(name: string): RepoConfig | null {
return this.repos.get(name) || null;
}
getAllRepos(): RepoConfig[] {
return Array.from(this.repos.values());
}
}

2. Cross-repo symbol extractor

// src/symbol-extractor.ts
import { Parser } from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import fs from 'fs/promises';
import path from 'path';
import { RepoRegistry } from './repo-registry.js';
interface Symbol {
name: string;
type: 'function' | 'class' | 'interface' | 'type';
repo: string;
file: string;
startLine: number;
endLine: number;
content: string;
imports: string[];
}
export class SymbolExtractor {
private parser: Parser;
private registry: RepoRegistry;
constructor(registry: RepoRegistry) {
this.parser = new Parser();
this.parser.setLanguage(TypeScript.typescript);
this.registry = registry;
}
async findSymbol(symbolName: string, repoName?: string): Promise<Symbol | null> {
const repos = repoName
? [this.registry.getRepo(repoName)].filter(Boolean)
: this.registry.getAllRepos();
for (const repo of repos) {
if (!repo) continue;
const symbol = await this.searchRepoForSymbol(repo, symbolName);
if (symbol) return symbol;
}
return null;
}
private async searchRepoForSymbol(repo: any, symbolName: string): Promise<Symbol | null> {
// Search TypeScript/JavaScript files
const files = await this.findSourceFiles(repo.path);
for (const file of files) {
const content = await fs.readFile(file, 'utf-8');
const tree = this.parser.parse(content);
const symbol = this.extractSymbolFromTree(tree, content, symbolName, repo.name, file);
if (symbol) return symbol;
}
return null;
}
private async findSourceFiles(repoPath: string): Promise<string[]> {
const files: string[] = [];
const srcPath = path.join(repoPath, 'src');
try {
await this.walkDir(srcPath, files);
} catch {
// Fallback to root if no src/ directory
await this.walkDir(repoPath, files);
}
return files.filter(f => /\.(ts|tsx|js|jsx)$/.test(f));
}
private async walkDir(dir: string, files: string[]): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
await this.walkDir(fullPath, files);
} else if (entry.isFile()) {
files.push(fullPath);
}
}
}
private extractSymbolFromTree(
tree: Parser.Tree,
content: string,
symbolName: string,
repoName: string,
filePath: string
): Symbol | null {
const cursor = tree.walk();
const visit = (): Symbol | null => {
const node = cursor.currentNode;
// Check for function declarations
if (node.type === 'function_declaration' || node.type === 'function') {
const nameNode = node.childForFieldName('name');
if (nameNode && content.slice(nameNode.startIndex, nameNode.endIndex) === symbolName) {
return this.createSymbol(node, content, symbolName, 'function', repoName, filePath);
}
}
// Check for class declarations
if (node.type === 'class_declaration') {
const nameNode = node.childForFieldName('name');
if (nameNode && content.slice(nameNode.startIndex, nameNode.endIndex) === symbolName) {
return this.createSymbol(node, content, symbolName, 'class', repoName, filePath);
}
}
// Check for type/interface declarations
if (node.type === 'interface_declaration' || node.type === 'type_alias_declaration') {
const nameNode = node.childForFieldName('name');
if (nameNode && content.slice(nameNode.startIndex, nameNode.endIndex) === symbolName) {
const type = node.type === 'interface_declaration' ? 'interface' : 'type';
return this.createSymbol(node, content, symbolName, type, repoName, filePath);
}
}
if (cursor.gotoFirstChild()) {
do {
const result = visit();
if (result) return result;
} while (cursor.gotoNextSibling());
cursor.gotoParent();
}
return null;
};
return visit();
}
private createSymbol(
node: Parser.SyntaxNode,
content: string,
name: string,
type: Symbol['type'],
repo: string,
file: string
): Symbol {
const symbolContent = content.slice(node.startIndex, node.endIndex);
const imports = this.extractImports(content);
return {
name,
type,
repo,
file,
startLine: node.startPosition.row,
endLine: node.endPosition.row,
content: symbolContent,
imports,
};
}
private extractImports(content: string): string[] {
const imports: string[] = [];
const importRegex = /import\s+.*?from\s+['"]([^'"]+)['"]/g;
let match;
while ((match = importRegex.exec(content)) !== null) {
imports.push(match[1]);
}
return imports;
}
}

3. Dependency resolver

// src/dependency-resolver.ts
import { SymbolExtractor } from './symbol-extractor.js';
import { RepoRegistry } from './repo-registry.js';
interface DependencyChain {
root: any;
dependencies: any[];
totalTokens: number;
}
export class DependencyResolver {
constructor(
private extractor: SymbolExtractor,
private registry: RepoRegistry
) {}
async resolveDependencies(
symbolName: string,
repoName?: string,
maxDepth: number = 2,
maxTokens: number = 3000
): Promise<DependencyChain> {
const root = await this.extractor.findSymbol(symbolName, repoName);
if (!root) {
throw new Error(`Symbol '${symbolName}' not found`);
}
const dependencies: any[] = [];
let totalTokens = this.estimateTokens(root.content);
await this.resolveDependenciesRecursive(
root,
dependencies,
maxDepth,
maxTokens,
totalTokens,
new Set([symbolName])
);
return {
root,
dependencies,
totalTokens,
};
}
private async resolveDependenciesRecursive(
symbol: any,
dependencies: any[],
maxDepth: number,
maxTokens: number,
currentTokens: number,
visited: Set<string>
): Promise<number> {
if (maxDepth === 0) return currentTokens;
for (const importPath of symbol.imports) {
// Resolve which repo this import comes from
const targetRepo = this.registry.resolveImport(importPath);
if (!targetRepo) continue;
// Extract imported symbols from the import statement
const importedSymbols = this.extractImportedSymbols(symbol.content, importPath);
for (const importedSymbol of importedSymbols) {
if (visited.has(importedSymbol)) continue;
const dep = await this.extractor.findSymbol(importedSymbol, targetRepo.name);
if (!dep) continue;
const depTokens = this.estimateTokens(dep.content);
if (currentTokens + depTokens > maxTokens) {
continue; // Skip if would exceed budget
}
dependencies.push(dep);
visited.add(importedSymbol);
currentTokens += depTokens;
// Recursively resolve this dependency's dependencies
currentTokens = await this.resolveDependenciesRecursive(
dep,
dependencies,
maxDepth - 1,
maxTokens,
currentTokens,
visited
);
}
}
return currentTokens;
}
private extractImportedSymbols(content: string, importPath: string): string[] {
const regex = new RegExp(`import\\s+{([^}]+)}\\s+from\\s+['"]${importPath}['"]`);
const match = content.match(regex);
if (!match) return [];
return match[1]
.split(',')
.map(s => s.trim())
.filter(Boolean);
}
private estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
}

4. MCP Server integration

// src/index.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { RepoRegistry } from './repo-registry.js';
import { SymbolExtractor } from './symbol-extractor.js';
import { DependencyResolver } from './dependency-resolver.js';
import path from 'path';
import os from 'os';
class MultiRepoMCPServer {
private server: Server;
private registry: RepoRegistry;
private extractor: SymbolExtractor;
private resolver: DependencyResolver;
constructor() {
this.server = new Server(
{ name: 'multi-repo-context', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
this.registry = new RepoRegistry();
this.extractor = new SymbolExtractor(this.registry);
this.resolver = new DependencyResolver(this.extractor, this.registry);
this.setupHandlers();
}
async initialize() {
const configPath = path.join(os.homedir(), '.multi-repo-config.json');
await this.registry.loadFromConfig(configPath);
}
private setupHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'load_symbol',
description: 'Load a symbol (function, class, type) with its cross-repo dependencies',
inputSchema: {
type: 'object',
properties: {
symbol_name: { type: 'string', description: 'Symbol to load' },
repo_name: { type: 'string', description: 'Repository name (optional)' },
max_depth: { type: 'number', description: 'Max dependency depth (default: 2)', default: 2 },
max_tokens: { type: 'number', description: 'Max tokens (default: 3000)', default: 3000 },
},
required: ['symbol_name'],
},
},
{
name: 'trace_dependency',
description: 'Trace the full dependency chain for a symbol across repos',
inputSchema: {
type: 'object',
properties: {
symbol_name: { type: 'string', description: 'Symbol to trace' },
repo_name: { type: 'string', description: 'Starting repository (optional)' },
},
required: ['symbol_name'],
},
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
if (name === 'load_symbol') {
const chain = await this.resolver.resolveDependencies(
args.symbol_name as string,
args.repo_name as string | undefined,
args.max_depth as number | undefined,
args.max_tokens as number | undefined
);
let output = `# ${chain.root.name}\n\n`;
output += `**Repository:** ${chain.root.repo}\n`;
output += `**File:** ${chain.root.file}\n`;
output += `**Type:** ${chain.root.type}\n`;
output += `**Total tokens:** ${chain.totalTokens}\n\n`;
output += '```typescript\n' + chain.root.content + '\n```\n\n';
if (chain.dependencies.length > 0) {
output += '## Dependencies\n\n';
for (const dep of chain.dependencies) {
output += `### ${dep.name} (${dep.repo})\n`;
output += '```typescript\n' + dep.content + '\n```\n\n';
}
}
return { content: [{ type: 'text', text: output }] };
}
if (name === 'trace_dependency') {
const chain = await this.resolver.resolveDependencies(
args.symbol_name as string,
args.repo_name as string | undefined,
10 // Deep trace
);
let output = `# Dependency Trace: ${chain.root.name}\n\n`;
output += this.formatDependencyTree(chain.root, chain.dependencies);
return { content: [{ type: 'text', text: output }] };
}
return {
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
isError: true,
};
} catch (error) {
return {
content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
isError: true,
};
}
});
}
private formatDependencyTree(root: any, deps: any[]): string {
let output = `${root.name} (${root.repo})\n`;
for (const dep of deps) {
output += ` └─ ${dep.name} (${dep.repo})\n`;
}
return output;
}
async run() {
await this.initialize();
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Multi-Repo Context MCP Server running');
}
}
new MultiRepoMCPServer().run().catch(console.error);

5. Configuration file

Create ~/.multi-repo-config.json:

{
"repositories": [
{
"name": "auth-service",
"path": "/Users/you/projects/auth-service",
"type": "service"
},
{
"name": "user-service",
"path": "/Users/you/projects/user-service",
"type": "service",
"packageName": "@company/user-service-client"
},
{
"name": "shared-utils",
"path": "/Users/you/projects/shared-utils",
"type": "library",
"packageName": "@company/shared-utils"
},
{
"name": "api-contracts",
"path": "/Users/you/projects/api-contracts",
"type": "contracts",
"packageName": "@company/api-contracts"
}
]
}

Usage example

You: Load the validateUser function with dependencies
# validateUser
**Repository:** user-service
**File:** user-service/src/validators/user.ts
**Type:** function
**Total tokens:** 847
```typescript
export async function validateUser(userId: string): Promise<User> {
const user = await getUserById(userId);
if (!user) {
throw new ValidationError('User not found');
}
if (user.status === 'suspended') {
throw new ValidationError('User suspended');
}
return user;
}

Dependencies

getUserById (user-service)

async function getUserById(id: string): Promise<User | null> {
return db.users.findOne({ id });
}

ValidationError (shared-utils)

export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}

You: Now help me understand why auth is failing for suspended users

## Best practices
### When to use multi-repo loading
✅ **Perfect for:**
- Debugging issues that span services
- Understanding API contracts across repos
- Tracing request flows through microservices
- Adding features that touch multiple repos
❌ **Not needed for:**
- Single-file changes in one repo
- Working entirely within one service
- Exploring new codebases (use full context first)
### Optimizing your setup
**1. Keep repositories organized**
```json
{
"repositories": [
// Group by type for easier mental model
{
"name": "auth-service",
"type": "service"
},
{
"name": "user-service",
"type": "service"
},
{
"name": "shared-utils",
"type": "library"
}
]
}

2. Set appropriate depth limits

// For bug fixes: shallow depth
load_symbol("validateUser", max_depth: 1)
// For architecture understanding: deeper depth
load_symbol("handleRequest", max_depth: 3)

3. Use token budgets wisely

// Conservative for focused work
max_tokens: 2000
// Generous for exploration
max_tokens: 5000
Pro tip

Start with trace_dependency to see the full dependency graph, then use load_symbol with appropriate depth to load only what you need.

Microservices promised independent deployment and clear boundaries. What they delivered: dependency hell spread across a dozen repositories.

But the solution isn't to load all twelve repos into context and hope for the best. That's like responding to email overload by reading every email twice.

Multi-repo context loading is about treating repository boundaries like file boundaries - organizational artifacts that Claude doesn't care about. Follow the actual dependency chain: function calls function calls function, doesn't matter which repo. Load the 500 lines that matter, not the 50,000 lines that happen to share a git remote.

The architecture tax is real: More repos = more complexity. But that complexity doesn't have to become a token tax too.

Build the tools once, save tokens forever. Or keep manually switching between repos and wondering why your context limit keeps crashing mid-debug.

What's next

Multi-repo context solves the where - finding code across repositories. But what about the why? Git history tells you how code evolved and why decisions were made. Next: Git History Context - using version control to understand not just what code does, but why it exists.


Series navigation

← Previous: Claude Code Decoded: Smart Context Loading - Minimal context loading within a single repo

→ Next: Claude Code Decoded: Git History Context - Using version control history for richer context

Other posts in the series: