import * as ts from 'typescript';
import * as path from 'path';
import * as fs from 'fs';
interface ResolvedConfigState {
filePath: string;
inheritanceChain: string[];
compilerOptions: Record<string, string | boolean | number | object>;
}
export class TsConfigResolver {
private readonly compilerApi: typeof ts;
constructor(api: typeof ts) {
this.compilerApi = api;
}
public resolve(projectRoot: string): ResolvedConfigState {
const configPath = path.join(projectRoot, 'tsconfig.json');
if (!fs.existsSync(configPath)) {
throw new Error(`Configuration file not found: ${configPath}`);
}
const rawSource = this.compilerApi.readConfigFile(configPath, this.compilerApi.sys.readFile);
if (rawSource.error) {
throw new Error(`Failed to parse config: ${rawSource.error.messageText}`);
}
const parsedResult = this.compilerApi.parseJsonConfigFileContent(
rawSource.config,
this.compilerApi.sys,
path.dirname(configPath),
{},
configPath
);
return {
filePath: configPath,
inheritanceChain: this.extractInheritanceChain(parsedResult),
compilerOptions: this.normalizeOptions(parsedResult.options)
};
}
private extractInheritanceChain(parsed: ts.ParsedCommandLine): string[] {
// The compiler API does not expose the chain directly,
// but we can reconstruct it from the raw config's extends field
// and validate against resolved paths.
return [parsed.options.configFilePath || 'unknown'];
}
private normalizeOptions(options: ts.CompilerOptions): Record<string, unknown> {
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(options)) {
if (value === undefined) continue;
normalized[key] = this.mapEnumValues(key, value);
}
return normalized;
}
private mapEnumValues(key: string, value: unknown): unknown {
if (key === 'target' || key === 'module' || key === 'moduleResolution') {
const enumMap = this.compilerApi;
const enumKey = key.charAt(0).toUpperCase() + key.slice(1);
const enumObj = enumKey in enumMap ? (enumMap as any)[enumKey] : undefined;
if (enumObj && typeof value === 'number') {
return enumObj[value] || value;
}
}
return value;
}
}
Architecture Rationale:
- Using
parseJsonConfigFileContent instead of manual JSON merging guarantees exact parity with tsc. The compiler handles relative path resolution, package lookup, default value injection, and circular dependency detection.
- Enum normalization converts numeric compiler flags (e.g.,
9 for ES2022) into readable strings. LLMs process semantic tokens more reliably than magic numbers, reducing context window waste and improving prompt comprehension.
- The resolver is decoupled from MCP transport logic, allowing reuse in CLI tools, IDE extensions, or CI pipelines.
The resolved state is exposed through three specialized tools. Each tool addresses a specific agent blind spot.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { TsConfigResolver } from './resolver';
export function registerConfigTools(server: McpServer, resolver: TsConfigResolver) {
server.tool(
'get_effective_compiler_options',
'Returns the fully resolved TypeScript compiler options after inheritance chain processing',
{ projectPath: 'string' },
async ({ projectPath }) => {
const state = resolver.resolve(projectPath);
return {
content: [{
type: 'text',
text: JSON.stringify(state, null, 2)
}]
};
}
);
server.tool(
'resolve_module_alias',
'Maps a TypeScript path alias to its physical file location using resolved baseUrl and paths',
{ alias: 'string', projectPath: 'string' },
async ({ alias, projectPath }) => {
const state = resolver.resolve(projectPath);
const paths = state.compilerOptions.paths as Record<string, string[]> | undefined;
const baseUrl = state.compilerOptions.baseUrl as string | undefined;
if (!paths || !baseUrl) {
return { content: [{ type: 'text', text: 'No path aliases configured' }] };
}
const pattern = Object.keys(paths).find(p => alias.startsWith(p.replace('/*', '')));
if (!pattern) {
return { content: [{ type: 'text', text: `Alias ${alias} not found in paths` }] };
}
const mappedDirs = paths[pattern];
const relativePath = alias.replace(pattern, '');
const candidates = mappedDirs.map(dir =>
path.resolve(baseUrl, dir.replace('/*', ''), relativePath)
);
const existing = candidates.filter(p => fs.existsSync(p));
return {
content: [{
type: 'text',
text: existing.length > 0
? `Resolved: ${existing[0]}`
: `No matching file found. Candidates: ${candidates.join(', ')}`
}]
};
}
);
server.tool(
'validate_project_references',
'Checks monorepo project references for composite flag compliance and file existence',
{ projectPath: 'string' },
async ({ projectPath }) => {
const configPath = path.join(projectPath, 'tsconfig.json');
const raw = ts.readConfigFile(configPath, ts.sys.readFile);
const refs = raw.config?.references as Array<{ path: string }> | undefined;
if (!refs || refs.length === 0) {
return { content: [{ type: 'text', text: 'No project references defined' }] };
}
const results = refs.map(ref => {
const refPath = path.resolve(projectPath, ref.path);
const refConfigPath = path.join(refPath, 'tsconfig.json');
const exists = fs.existsSync(refConfigPath);
let compositeValid = true;
if (exists) {
const refRaw = ts.readConfigFile(refConfigPath, ts.sys.readFile);
compositeValid = refRaw.config?.compilerOptions?.composite === true;
}
return {
reference: ref.path,
exists,
compositeEnabled: compositeValid,
status: !exists ? 'MISSING' : !compositeValid ? 'INVALID' : 'VALID'
};
});
return {
content: [{
type: 'text',
text: JSON.stringify(results, null, 2)
}]
};
}
);
}
Why this structure works:
- Tools are scoped to specific resolution tasks. Separating option retrieval, alias mapping, and reference validation prevents context pollution and allows agents to request only the data they need.
- Path resolution uses the compiler's
baseUrl and paths mapping rather than hardcoded assumptions. This handles workspace-relative aliases correctly.
- Reference validation enforces
composite: true because TypeScript's incremental build system requires it for project references. Missing this flag causes silent build failures that agents cannot diagnose without explicit tooling.
Pitfall Guide
1. Assuming Static JSON Merging Matches Compiler Behavior
Explanation: Manually merging extends chains using Object.assign or spread operators ignores TypeScript's resolution logic. The compiler applies default values, resolves relative paths against the config's directory, and handles package exports correctly.
Fix: Always delegate resolution to ts.parseJsonConfigFileContent. Never implement custom inheritance logic.
2. Ignoring composite: true in Project References
Explanation: TypeScript requires composite: true for any project referenced in a references array. Without it, incremental builds break, and the compiler cannot generate .d.ts declaration files or track dependencies correctly.
Fix: Validate every referenced project for the composite flag. Fail fast in CI if missing.
3. Treating Enum Values as Raw Numbers
Explanation: Compiler options like target, module, and moduleResolution are stored as numeric enums internally. LLMs interpret 9 or 199 as arbitrary integers, leading to incorrect suggestions or context confusion.
Fix: Map enum values to their string equivalents using the typescript package's enum exports before returning data to the agent.
4. Overlooking baseUrl Scope in Path Aliases
Explanation: paths mappings are resolved relative to baseUrl, not the project root. Agents that assume aliases map to the workspace root will generate incorrect import statements.
Fix: Always resolve aliases against the computed baseUrl from the merged configuration. Validate physical file existence before suggesting imports.
5. Failing to Handle Missing Extended Configs Gracefully
Explanation: If an extends target is missing or misnamed, the compiler throws an error. Agents receiving unhandled exceptions may crash or produce garbled output.
Fix: Wrap resolution calls in try-catch blocks. Return structured error messages indicating which config file failed to load and why.
6. Circular Inheritance Traps
Explanation: Misconfigured extends chains can create circular dependencies. The compiler detects these, but custom resolvers may enter infinite loops or stack overflow.
Fix: Rely on the compiler API for cycle detection. Do not implement custom traversal logic for inheritance chains.
7. Not Accounting for Environment-Specific Overrides
Explanation: Some projects use tsconfig.build.json or environment-specific configs that override base settings. Resolving only tsconfig.json may return incomplete state.
Fix: Allow the resolution tool to accept explicit config file paths. Document which configuration file the agent should query for build vs development contexts.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single-project repository | Static file read + manual extends resolution | Low complexity, minimal overhead | Near zero |
| Monorepo with shared configs | Compiler API resolution via MCP | Ensures parity with tsc, handles deep inheritance | Moderate (API dependency) |
| CI/CD pipeline validation | validate_project_references tool | Catches composite violations before build | Low (fast validation) |
| IDE plugin development | Direct typescript API integration | Lower latency, no network overhead | Higher (requires native bindings) |
| AI agent prompt engineering | MCP tool invocation | Eliminates context window waste, improves accuracy | Low (tool call overhead) |
Configuration Template
{
"mcpServers": {
"tsconfig-resolution-layer": {
"command": "node",
"args": ["./dist/server.js"],
"env": {
"NODE_ENV": "production",
"TS_CONFIG_CACHE_TTL": "30000"
},
"cwd": "${workspaceFolder}"
}
}
}
Quick Start Guide
- Initialize the MCP server project: Run
npm init -y and install dependencies: npm install typescript @modelcontextprotocol/sdk.
- Create the resolver module: Copy the
TsConfigResolver class into src/resolver.ts and implement the enum mapping logic.
- Register MCP tools: Use the
registerConfigTools function to expose get_effective_compiler_options, resolve_module_alias, and validate_project_references.
- Configure your AI client: Add the MCP server configuration to your client's
mcpServers section. Point cwd to your project root.
- Test resolution: Prompt your agent to query
get_effective_compiler_options for your workspace. Verify that inherited flags like noUncheckedIndexedAccess appear in the response.