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 bestYou: "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:
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.tsimport { 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
validateUserfunction (150 tokens) - Extract just
generateTokenfunction (100 tokens) - Extract just
LoginRequesttype (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 dependencyContext pollution: Mixed conversations across reposAnti-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+ tokensUsability: DestroyedAnti-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.tsimport 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.tsimport { 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.tsimport { 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.tsimport { 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```typescriptexport 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 depthload_symbol("validateUser", max_depth: 1)// For architecture understanding: deeper depthload_symbol("handleRequest", max_depth: 3)3. Use token budgets wisely
// Conservative for focused workmax_tokens: 2000// Generous for explorationmax_tokens: 5000Start 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:
- Claude Code Decoded: The Token Tax - Understanding token waste patterns
- Claude Code Decoded: The Handoff Protocol - Efficient session handoffs
