Loading episodes…
0:00 0:00

The Stable Adapter Layer: Building AI Tools That Don't Break When You Refactor

00:00
BACK TO HOME

The Stable Adapter Layer: Building AI Tools That Don't Break When You Refactor

10xTeam May 22, 2026 11 min read

The MCP server was working. Thirteen tools. An AI could ask your framework questions and get structured answers. Then you renamed a function.

One rename. Six tools broke silently.

Not with an error message on startup — with wrong behavior at query time. The tool handler called runInspect, which no longer existed. The MCP server started fine. The AI called inspect_models, got an error response, and had no way to know whether the framework had no models or the tool was broken.

This is the failure mode for AI tool layers built the obvious way: each tool handler imports directly from whatever internal module it needs. The tool layer and the framework it wraps are coupled at every import site.

When the framework evolves — and it always evolves — the tool layer silently rots.


Why the Obvious Pattern Fails

The obvious way to build an MCP server over a framework looks like this:

server.tool('inspect_models', ..., async () => {
  const { runInspect } = require('../cli/commands/inspect');
  const output = await capture(() => runInspect('models', ['--json']));
  return parseJSON(output);
});

server.tool('health', ..., async () => {
  const { runHealth } = require('../cli/commands/health');
  const output = await capture(() => runHealth({ json: true }));
  return parseJSON(output);
});

Six tool handlers. Six direct imports. When runInspect is renamed to inspectApp during a framework refactor, all four inspect tools break — each independently, each requiring a separate fix.

The problem isn’t the rename. Renames are normal. The problem is the surface area: six places where the tool layer knows a framework internal by name.


The Single Change Point

The fix is an adapter class that owns all framework calls:

class DarAdapter {
  constructor(appDir) {
    this._appDir = appDir;
  }

  getModels() {
    const { runInspect } = require('../cli/commands/inspect');
    const output = this._capture(() => runInspect('models', ['--json']));
    return this._parseJSON(output, 'getModels');
  }

  health() {
    const { runHealth } = require('../cli/commands/health');
    const output = this._capture(() => runHealth({ json: true }));
    return this._parseJSON(output, 'health');
  }
}

Tool handlers become thin wrappers:

server.tool('inspect_models', ..., async () => ok(await adapter.getModels()));
server.tool('health', ..., async () => ok(await adapter.health()));

The tool handlers have no direct framework imports. They call the adapter. The adapter has all the framework imports. When a framework internal changes, one method in one class changes. Nothing else.

The reduction in surface area matters. Six independent import sites become one class. When the framework changes, you know exactly where to look.


The Drift Problem

One class is better than six import sites. But you still have a drift problem: the adapter can go out of sync with the framework, and there’s no automated way to know when it has.

You renamed runInspect to runDarInspect. You updated DarAdapter.getModels(). You forgot getModel(), getPages(), and getPage() — they all use the same import. The adapter now has three broken methods and one working one. Your tool layer has partial breakage. The AI will get correct responses for some queries and error responses for others, with no diagnostic output explaining why.

This is worse than total failure. Partial breakage looks like a data problem, not a tooling problem.


Export Drift Detection

The solution is a maintenance script that knows what the adapter expects from each CLI command file and checks whether those expectations are still valid:

const ADAPTER_DEPS = [
  {
    file:    'packages/cli/commands/inspect.js',
    imports: ['runInspect'],
    methods: ['getModels', 'getModel', 'getPages', 'getPage'],
  },
  {
    file:    'packages/cli/commands/health.js',
    imports: ['runHealth'],
    methods: ['health'],
  },
];

function checkDep(dep) {
  const actual  = extractExports(path.join(ROOT, dep.file));
  const missing = dep.imports.filter(e => !actual.includes(e));
  const added   = actual.filter(e => !dep.imports.includes(e));
  return { ok: missing.length === 0, missing, added };
}

extractExports reads the CLI file and parses module.exports = { ... } to get the actual exported names. The check compares expected against actual.

The output tells you immediately what broke: inspect.js — missing: runInspect actual: runDarInspect. One removed, one added — that’s an unambiguous rename, and it can be fixed automatically.


Auto-Fix Renames

For the common case — one export renamed in one file — the script can fix the adapter without human intervention:

function autoFixRename(dep, check) {
  if (check.missing.length !== 1 || check.added.length !== 1) {
    return { fixed: false, reason: 'ambiguous' };
  }

  const oldName = check.missing[0];
  const newName = check.added[0];

  const patterns = [
    new RegExp(`(\\{\\s*)${oldName}(\\s*[,}])`, 'g'),  // destructuring
    new RegExp(`\\b${oldName}(?=\\()`, 'g'),             // function calls
  ];

  let updated = fs.readFileSync(ADAPTER_PATH, 'utf8');
  for (const p of patterns) {
    updated = updated.replace(p, m => m.replace(oldName, newName));
  }

  fs.writeFileSync(ADAPTER_PATH, updated, 'utf8');
  return { fixed: true, oldName, newName };
}

The regex patterns are deliberately specific. Only destructuring ({ runInspect }) and calls (runInspect() are targeted. A naive word-boundary replace (\brunInspect\b) would also match inside string literals like require('../cli/commands/runInspect') — replacing the path and breaking the import entirely. That was discovered and fixed during implementation; the lesson is that identifier-context matching is not the same as word matching.


The Spec-Driven Generator

There’s a deeper version of this problem. The adapter has seven read methods, all following the same pattern: import from a CLI command file, call the function, capture output, parse JSON. They’re repetitive. They’ll stay synchronized with the framework only as long as someone remembers to update them every time.

The fix: make the read methods derived, not written. Define the bindings in a spec file:

{
  "name": "getModels",
  "source": "packages/cli/commands/inspect.js",
  "import": "runInspect",
  "params": [],
  "invocation": "runInspect('models', ['--json'])",
  "async": false
}

A generator reads this spec and produces the method:

getModels() {
  const { runInspect } = require('../../packages/cli/commands/inspect.js');
  const raw = this._cwd(() => this._capture(() => runInspect('models', ['--json'])));
  return this._parseJSON(raw, 'getModels');
}

The generated section lives between markers in the adapter file:

// READ OPERATIONS — BEGIN GENERATED (dar-adapter-gen)
// [generated methods here]
// READ OPERATIONS — END GENERATED

Everything outside those markers — write methods, infrastructure, constructor — is hand-written and untouched by the generator. The generator is idempotent: running it twice produces the same output.


Three Files, One Invariant

The system now has three files that must agree:

File What it records
adapter-spec.json "import" and "invocation" values for each read method
DarAdapter.js (generated section) Destructure + call identifiers
dar-adapter-sync.js (ADAPTER_DEPS) Expected import names per CLI file

When an export is renamed and auto-fixed, all three are updated atomically. The drift check, the spec, and the generated code stay consistent. The next --check run is clean.

The maintenance command is one line:

node tools/dar-adapter-sync.js --fix --app apps/your-app

Detect → rename → update spec → regenerate → live smoke test. If any step fails, it reports clearly what broke and what to fix manually.


What This Costs

The adapter pattern adds one class and two maintenance scripts. The spec adds one JSON file. The total addition is about 600 lines across four files.

In return:

  • Framework internals can be renamed or refactored without hunting down tool handler import sites
  • Export drift is detected by a script, not by a failing AI tool query
  • Unambiguous renames are fixed automatically — zero human intervention
  • Read methods are generated, not maintained — the spec is the single place to edit for call convention changes
  • A live smoke test catches output format changes that static analysis cannot see

The pattern scales. Add a new CLI command: add one entry to the spec, run the generator, add one tool in index.js. Remove a command: update the spec, regenerate, remove the tool. The adapter is the only integration surface, and the spec keeps it honest.


The Principle

The insight behind this pattern isn’t about MCP specifically. It’s about what happens when one system wraps another.

The thing being wrapped always evolves. The wrapper layer always wants to evolve independently. If the wrapper couples directly to the wrapped system’s internals at every call site, every internal change requires hunting down every coupling. The surface area of the coupling grows with every new capability you add.

A stable adapter layer inverts this. You write the tool handlers against the adapter interface, which is stable. You maintain the adapter as a single integration surface. When the underlying system changes, the adapter absorbs the change without it propagating outward.

This is not a new idea. It’s the adapter pattern. What’s new is why it matters so much for AI tool layers specifically: the humans managing the adapter drift are not the same people who changed the framework internals. When runInspect gets renamed in a refactoring session, the developer knows the MCP tools exist but may not track down every import site. The drift check runs in CI and catches it before the AI tool layer starts returning confusing partial errors.

Automated drift detection doesn’t make the adapter pattern work. It makes the adapter pattern maintainable under the conditions that actually exist in a real project: parallel development, imperfect attention, refactors done quickly.


Next: once your AI tools can read the app, the next question is whether they can build it.


Join the 10xdev Community

Subscribe and get 8+ free PDFs that contain detailed roadmaps with recommended learning periods for each programming language or field, along with links to free resources such as books, YouTube tutorials, and courses with certificates.

Audio Interrupted

We lost the audio stream. Retry with shorter sentences?