THE WORLD IS NOT STANDING STILL.RSS
BIG CHANGE.
BIG CHANGE, one tap away
BIG CHANGE, one tap away

Make room for the big picture.

Add BIG CHANGE to your home screen or desktop. It opens in its own window, with the latest stories when you’re online.

iPhone & iPad
Open bigchange.ai in Safari. Tap Share, then Add to Home Screen. Turn on Open as Web App if shown, then tap Add.
Android
Open bigchange.ai in Chrome. In the browser menu, choose Install app or Add to Home screen.
Desktop
In Chrome or Edge, use the install icon in the address bar or the browser’s app menu. In Safari on Mac, choose File → Add to Dock.

Markdown edition

# Measure MCP tool context without losing the answer

> Build a pinned local MCP server and Claude host, compare five context controls against a broad baseline, and check answers before token totals.

By BIG CHANGE Editorial

Published: 2026-10-21T18:42:39.021Z
Updated: 2026-10-21T18:42:39.021Z
Canonical: https://bigchange.ai/blog/measure-mcp-tool-context-tokens-guide

![Conceptual charcoal illustration of a removable storage drive connected by one orange cable to a compact desktop computer on a worktable.](https://bigchange.ai/api/media/file/mcp-local-snapshot-computer-hero-v1.png)
AI-generated conceptual illustration by BIG CHANGE.

An MCP tool can return far more text than an agent needs to answer one question. The useful number is not the size of the server response alone: it is what the host actually sends to the model, what the provider reports for every turn, and whether the final answer still matches the records. This guide gives developers who control a local, read-only MCP server a way to measure all three.

## The big change

- **What changed:** The current MCP TypeScript SDK v2 supports a local stdio server and client with validated tool schemas. A host can choose which returned content enters a Claude Messages tool result.
- **Why it matters:** Filtering, server-side counting, record pagination, and selective host context change what reaches the model. The revision-bound application cache reuses server results; a cache hit alone does not shorten that result. Their combined token effect is an experiment, not a promised saving.
- **What to watch:** A smaller response can omit a later-page record, lose a caveat, or require extra model turns. Check the answer and the full usage record before calling an optimization successful.

The example below uses the [MCP TypeScript SDK v2 server and client](https://ts.sdk.modelcontextprotocol.io/v2/get-started/first-server) and [Claude Messages tool calls](https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls). BIG CHANGE compiled and ran a local artificial fixture on 21 October 2026. We did not send records to a model, make a paid API call, or measure a token saving. The API comparison is for you to run with records you are allowed to use.

## 1. Freeze the records and write the answer key

You need Node.js 20 or newer, an approved Claude Messages API model and credential, and a small budget you are willing to spend. Run this in a fresh local directory. This is a local stdio connection; it creates no remote MCP service. The example accepts at most 100 records, each with a unique string `id`, string `revision`, and other flat string fields of at most 4,000 characters. Keep secrets and personal data out of the snapshot unless your organization has approved sending those fields to the model. The private run log includes questions, tool arguments, and answers; do not publish it unreviewed.

Create `snapshot.json` from a reader-authorized, read-only issue or incident export. Preserve its exact bytes for both runs. Give each record a stable ID and revision. In `fields.json`, write a JSON array of the exact field names the server may return, including `id` and `revision`. The baseline and optimized tools use the same allowlist. Use enough records that the later-page question really needs a sixth or later matching result: the application page size here is five. No sample customer records are supplied as reported evidence.

Create `tasks.json` as a JSON array with exactly three entries. Each entry needs `kind`, `question`, `expectedAnswer`, `evidenceIds`, and `requiredCaveats`; the `lookup` entry also needs `detailField`, the exact allowed field that must be retrieved after a compact search. The `aggregate` entry needs `aggregateField` and `aggregateValue`; its `expectedAnswer` is the decimal count as a string. Use `lookup`, `aggregate`, and `later_page` once each. Write the human-checked answer and the exact supporting IDs before the model run. The `expectedAnswer` must be the exact string you expect in the model's JSON `answer`; the caveats array contains exact required strings. Make the lookup question require a fact in `detailField` that a compact `find_records` result omits. Its keyed ID must appear in that brief result before the exact detail call. The optimized answer passes its tool-path check only when `find_records` requests brief fields first and a later `exact_record` call retrieves a keyed ID. Choose an aggregate question expressible as an exact count of one recorded field value. The host independently counts matching snapshot rows before any model call and rejects an incorrect aggregate answer key. For the later-page question, make its evidence fall after the first five matching rows in ID order. The automated check requires an actual continuation call for that case; inspect the full records yourself before accepting its answer.

Record the source system, export time, field permissions, and who checked the keys in your own private notes. The program hashes the snapshot, field allowlist, and task file bytes and stops when any of them change. Hashing proves the two runs used the same file; it does not prove the export was complete or authorized.

## 2. Install the pinned local example

Create the files in the code sections below under `src/`, plus `package.json` and `tsconfig.json` in the directory root. After copying the code into a fresh directory, `npm install --package-lock-only` generates a lockfile from these pinned direct dependencies, then `npm ci` installs it. Commit or retain the lockfile with your private experiment. We checked the installed signatures of MCP client/server 2.3.1 and Zod 4.6.7. The older Zod 4.1.12 did not satisfy this SDK's JSON schema type contract during local compilation.

```json
{"name":"mcp-measured-context-example","private":true,"type":"module","engines":{"node":">=20"},"scripts":{"check":"tsc --noEmit","fixture":"tsx src/fixture-check.ts","run":"tsx src/host.ts"},"dependencies":{"@anthropic-ai/sdk":"0.67.0","@modelcontextprotocol/client":"2.3.1","@modelcontextprotocol/server":"2.3.1","zod":"4.6.7"},"devDependencies":{"@types/node":"24.8.1","tsx":"4.20.6","typescript":"5.9.3"}}
```

```json
{"compilerOptions":{"target":"ES2022","module":"NodeNext","moduleResolution":"NodeNext","strict":true,"esModuleInterop":true,"skipLibCheck":true,"resolveJsonModule":true,"noEmit":true},"include":["src/**/*.ts"]}
```

Run `npm install --package-lock-only`, `npm ci`, `npm run check`, then `npm run fixture`. The fixture uses artificial `T` records strictly as software test input. Its `PASS` line confirms a local MCP handshake, catalog, baseline list, two data pages, a cache hit, an independently counted aggregate, exact lookup, and error returns. It says nothing about your records or Claude's answers. Keep stdout free for MCP protocol traffic in the server; diagnostics go to stderr.

## 3. Use the frozen server and task-specific tools

The baseline exposes a broad `list_records` tool plus `exact_record`. The optimized server exposes `find_records`, `count_by_field`, and the same exact lookup. Filtering accepts only field names present in the authorized snapshot. The server returns IDs and revision with projected rows, a total match count, `moreAvailable`, and a snapshot-bound `nextCursor`. It binds cursors to the snapshot and exact query, rejecting invalid, stale, or cross-query cursors instead of silently restarting or skipping rows. An aggregate carries its definition and a bounded, pageable audit ID trail, so a count is not detached from its source.

The cache lives in the server process for 60 seconds and keys the exact query, selected fields, cursor, and source revision. The server checks the source and allowlist bytes again on every call and clears the cache if either changed. This is an application result cache, separate from Claude prompt caching. Each question starts a fresh server session and cold application cache. A separate fresh-server warm-cache probe makes the same direct tool query twice and is excluded from model token totals. `listTools()` pagination concerns the advertised tool catalog; `nextCursor` below pages record data returned by `callTool`.

The code for `src/data.ts` follows.

```typescript
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';

export type Row = { id: string; revision: string; [key: string]: string };
export type Snapshot = { rows: Row[]; hash: string; revision: string; fields: string[] };
export const sha = (v: string | Buffer) => createHash('sha256').update(v).digest('hex');
export function loadSnapshot(path: string): Snapshot {
  const bytes = readFileSync(path);
  const parsed: unknown = JSON.parse(bytes.toString('utf8'));
  if (!Array.isArray(parsed) || parsed.length < 2 || parsed.length > 100) throw Error('snapshot must contain 2..100 records');
  const rows = parsed as Row[];
  const ids = new Set<string>();
  const fields = new Set<string>();
  for (const r of rows) {
    if (!r || typeof r !== 'object' || typeof r.id !== 'string' || !r.id || typeof r.revision !== 'string' || !r.revision || ids.has(r.id)) throw Error('invalid or duplicate id/revision');
    ids.add(r.id);
    for (const [k, v] of Object.entries(r)) {
      if (typeof v !== 'string' || v.length > 4000 || !/^[a-zA-Z][a-zA-Z0-9_]*$/.test(k)) throw Error('invalid field or value');
      fields.add(k);
    }
  }
  rows.sort((a,b) => a.id.localeCompare(b.id));
  const hash = sha(bytes);
  return { rows, hash, revision: hash.slice(0,16), fields: [...fields].sort() };
}
export function canonical(v: unknown): string { return JSON.stringify(v); }
export function cursor(index: number, hash: string, scope: string): string { return Buffer.from(canonical({index,hash,scope})).toString('base64url'); }
export function decodeCursor(v: string | undefined, hash: string, scope: string, max: number): number {
  if (!v) return 0;
  let x: any;
  try { x = JSON.parse(Buffer.from(v,'base64url').toString('utf8')); } catch { throw Error('INVALID_CURSOR'); }
  if (!x || x.hash !== hash) throw Error('STALE_CURSOR');
  if (x.scope !== scope) throw Error('CURSOR_QUERY_MISMATCH');
  if (!Number.isInteger(x.index) || x.index < 0 || x.index >= max || cursor(x.index,hash,scope) !== v) throw Error('INVALID_CURSOR');
  return x.index;
}
```

The code for `src/server.ts` follows.

```typescript
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { z } from 'zod/v4';
import { readFileSync } from 'node:fs';
import { loadSnapshot, cursor, decodeCursor, canonical, sha, type Row } from './data.js';

const path = process.env.SNAPSHOT_PATH;
const mode = process.env.MODE;
if (!path || !['baseline','optimized'].includes(mode ?? '')) throw Error('SNAPSHOT_PATH and MODE required');
const source = loadSnapshot(path);
if (!process.env.FIELDS_PATH) throw Error('FIELDS_PATH required');
const allowedFields: unknown = JSON.parse(readFileSync(process.env.FIELDS_PATH,'utf8'));
const fieldsHash=sha(readFileSync(process.env.FIELDS_PATH));
if (!Array.isArray(allowedFields) || !allowedFields.every(f=>typeof f==='string' && source.fields.includes(f)) || !allowedFields.includes('id') || !allowedFields.includes('revision')) throw Error('INVALID_ALLOWLIST');
const allowed = allowedFields as string[];
if (process.env.EXPECTED_SHA256 && process.env.EXPECTED_SHA256 !== source.hash) throw Error('SOURCE_CHANGED');
if (process.env.EXPECTED_FIELDS_SHA256 && process.env.EXPECTED_FIELDS_SHA256 !== fieldsHash) throw Error('ALLOWLIST_CHANGED');
const pageSize = 5;
const ttlMs = 60_000;
const cache = new Map<string,{ expires: number; value: unknown }>();
const output = (value: unknown) => ({ content: [{ type:'text' as const, text: canonical(value) }], structuredContent: value as Record<string,unknown> });
const failure = (e: unknown) => ({ isError: true, content: [{ type:'text' as const, text: e instanceof Error ? e.message : 'UNKNOWN_ERROR' }] });
function fields(names: string[]) {
  if (names.some(n => !allowed.includes(n))) throw Error('INVALID_FIELD');
  return [...new Set(['id','revision',...names])];
}
function unchanged() {
  if (loadSnapshot(path!).hash !== source.hash) {cache.clear();throw Error('SOURCE_CHANGED');}
  if (sha(readFileSync(process.env.FIELDS_PATH!)) !== fieldsHash) {cache.clear();throw Error('ALLOWLIST_CHANGED');}
}
function project(row: Row, names: string[]) { return Object.fromEntries(names.map(n => [n,row[n] ?? null])); }
function memo<T>(key: unknown, fn: () => T): {value:T; cache:'hit'|'miss'} {
  unchanged();
  const full = canonical({key, revision:source.revision});
  const old = cache.get(full);
  if (old && old.expires > Date.now()) return { value:old.value as T, cache:'hit' };
  const value = fn(); cache.set(full,{expires:Date.now()+ttlMs,value});
  return {value,cache:'miss'};
}
serveStdio(() => {
  const server = new McpServer({name:'measured-records',version:'1.0.0'}, {capabilities:{tools:{}}});
  server.registerTool('exact_record', { description:'Fetch one complete authorized record by exact ID and source revision.', inputSchema:z.object({id:z.string().min(1)}) }, async ({id}) => {
    try {unchanged();} catch(e) {return failure(e);}
    const row = source.rows.find(r=>r.id===id);
    return output({sourceHash:source.hash, sourceRevision:source.revision, found:!!row, row:row ? project(row,allowed) : null});
  });
  if (mode === 'baseline') {
    server.registerTool('list_records', {description:'List every authorized record from the frozen snapshot.',inputSchema:z.object({})}, async () => {try {unchanged();return output({sourceHash:source.hash,sourceRevision:source.revision,total:source.rows.length,rows:source.rows.map(r=>project(r,allowed))});} catch(e) {return failure(e);}});
  } else {
    server.registerTool('find_records', {description:'Filter authorized records by one exact field value, choose allowed fields, and page by ID. Follow nextCursor until absent.', inputSchema:z.object({matchField:z.string(),matchValue:z.string(),fields:z.array(z.string()).max(20),cursor:z.string().optional()})}, async ({matchField,matchValue,fields:requested,cursor:page}) => {
      try {
        unchanged();
        const allowed = fields([matchField,...requested]);
        const found = source.rows.filter(r=>r[matchField]===matchValue);
        const scope=sha(canonical({tool:'find',matchField,matchValue,allowed}));
        const start = decodeCursor(page,source.hash,scope,found.length);
        const {value,cache:disposition} = memo({tool:'find',matchField,matchValue,allowed,cursor:page ?? null},()=>({sourceHash:source.hash,sourceRevision:source.revision,totalMatches:found.length,rows:found.slice(start,start+pageSize).map(r=>project(r,allowed)),moreAvailable:start+pageSize<found.length,nextCursor:start+pageSize<found.length?cursor(start+pageSize,source.hash,scope):null}));
        return output({...value,cache:disposition});
      } catch(e) { return failure(e); }
    });
    server.registerTool('count_by_field', {description:'Count all authorized records with one exact field value; returns bounded audit IDs in pages.',inputSchema:z.object({field:z.string(),value:z.string(),cursor:z.string().optional()})}, async ({field,value,cursor:page}) => {
      try {
        unchanged();
        fields([field]); const found=source.rows.filter(r=>r[field]===value);
        const scope=sha(canonical({tool:'count',field,value}));
        const start=decodeCursor(page,source.hash,scope,found.length);
        const {value:result,cache:disposition}=memo({tool:'count',field,value,cursor:page ?? null},()=>({sourceHash:source.hash,sourceRevision:source.revision,definition:{field,value},count:found.length,auditIds:found.slice(start,start+pageSize).map(r=>r.id),moreAvailable:start+pageSize<found.length,nextCursor:start+pageSize<found.length?cursor(start+pageSize,source.hash,scope):null}));
        return output({...result,cache:disposition});
      } catch(e) { return failure(e); }
    });
  }
  return server;
},{onerror:e=>console.error(e)});
```

## 4. Run the same three questions through one host

The host advertises only the tools available in that condition, with their schema. It preserves the same model ID, system instruction, `max_tokens`, snapshot, and three questions across baseline and optimized runs. Each `tool_use` receives exactly one `tool_result` with the matching ID, including an error flag if MCP failed. The host appends turns in order and accepts only `end_turn`; a refusal, `max_tokens`, timeout, protocol error, missing JSON, or missing answer-key evidence is recorded as failure. The host does not convert an error into a complete answer.

The optimized host removes the server's cache disposition and full source hash from model-facing text but keeps source revision, rows, IDs, audit trail, completeness flag, and cursor. It records raw `result.content` bytes and hash separately from the text it sends to Claude. The server also returns `structuredContent`, but this host builds model input and its log from `result.content`. Returning `structuredContent` alongside full text does not itself reduce model context. A smaller field projection is only safe when the exact lookup can recover required detail and the answer-key check catches omitted evidence.

The code for `src/host.ts` follows.

```typescript
import Anthropic from '@anthropic-ai/sdk';
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { sha, loadSnapshot } from './data.js';

type Kind = 'lookup'|'aggregate'|'later_page';
type Task = {kind:Kind; question:string; expectedAnswer:string; evidenceIds:string[]; requiredCaveats:string[]; detailField?:string; aggregateField?:string; aggregateValue?:string};
const snapshotPath=resolve(process.env.SNAPSHOT_PATH ?? 'snapshot.json');
const fieldsPath=resolve(process.env.FIELDS_PATH ?? 'fields.json');
const tasksPath=resolve(process.env.TASKS_PATH ?? 'tasks.json');
const runsDir=resolve(process.env.RUNS_DIR ?? 'runs');
mkdirSync(runsDir,{recursive:true});
const file=resolve(runsDir,`${new Date().toISOString().replace(/[:.]/g,'-')}.json`);
const run:any={status:'starting',warning:'Private local run; raw records, questions, tool arguments and answers may be sensitive. No credentials are intentionally saved.',results:[],errors:[]};
function persist() {writeFileSync(file,JSON.stringify(run,null,2));}
function errorText(e:unknown) {return e instanceof Error ? e.message : String(e);}
persist();

const maxTokens=1024;
const system='Answer only from the frozen authorized records. Use tools to inspect all necessary pages and exact detail. For an optimized exact lookup, call find_records with brief fields first, then exact_record for the detail. Return JSON only: {"answer":"...","evidenceIds":["..."],"caveats":["..."]}. If evidence is missing or a tool fails, say so; never guess.';

async function main() {
  const snapshotBytes=readFileSync(snapshotPath), fieldsBytes=readFileSync(fieldsPath), tasksBytes=readFileSync(tasksPath);
  const source=loadSnapshot(snapshotPath);
  const inputHashes={snapshot:sha(snapshotBytes),fields:sha(fieldsBytes),tasks:sha(tasksBytes)};
  if (source.hash!==inputHashes.snapshot) throw Error('SOURCE_CHANGED_DURING_STARTUP');
  const allowedFields:unknown=JSON.parse(fieldsBytes.toString('utf8'));
  if (!Array.isArray(allowedFields) || !allowedFields.every(f=>typeof f==='string' && source.fields.includes(f)) || !allowedFields.includes('id') || !allowedFields.includes('revision') || new Set(allowedFields).size!==allowedFields.length) throw Error('INVALID_ALLOWLIST');
  const parsed:unknown=JSON.parse(tasksBytes.toString('utf8'));
  const ids=new Set(source.rows.map(r=>r.id));
  if (!Array.isArray(parsed) || parsed.length!==3 || new Set(parsed.map(t=>t?.kind)).size!==3 || !['lookup','aggregate','later_page'].every(k=>parsed.some(t=>t?.kind===k))) throw Error('TASK_KINDS_REQUIRED');
  const tasks=parsed as Task[];
  for (const t of tasks) {
    if (!t || typeof t!=='object' || typeof t.question!=='string' || !t.question.trim() || typeof t.expectedAnswer!=='string' || !t.expectedAnswer.trim() || !Array.isArray(t.evidenceIds) || !t.evidenceIds.length || !t.evidenceIds.every(id=>typeof id==='string' && ids.has(id)) || !Array.isArray(t.requiredCaveats) || !t.requiredCaveats.every(c=>typeof c==='string' && !!c.trim())) throw Error(`INVALID_TASK_${t?.kind ?? 'unknown'}`);
    if (t.kind==='lookup' && (typeof t.detailField!=='string' || !allowedFields.includes(t.detailField) || ['id','revision'].includes(t.detailField))) throw Error('LOOKUP_DETAIL_FIELD_REQUIRED');
    if (t.kind==='aggregate') {
      if (typeof t.aggregateField!=='string' || !allowedFields.includes(t.aggregateField) || typeof t.aggregateValue!=='string') throw Error('AGGREGATE_DEFINITION_REQUIRED');
      if (t.expectedAnswer!==String(source.rows.filter(r=>r[t.aggregateField!]===t.aggregateValue).length)) throw Error('AGGREGATE_KEY_MISMATCH');
    }
  }
  const model=process.env.CLAUDE_MODEL;
  if (!model || !process.env.ANTHROPIC_API_KEY) throw Error('Set CLAUDE_MODEL and ANTHROPIC_API_KEY; this command makes billable calls');
  const packageVersion=(name:string)=>JSON.parse(readFileSync(resolve('node_modules',name,'package.json'),'utf8')).version as string;
  run.inputs={paths:{snapshot:snapshotPath,fields:fieldsPath,tasks:tasksPath},sha256:inputHashes,recordCount:source.rows.length,allowedFields};
  run.runtime={node:process.version,mcpClientPackage:packageVersion('@modelcontextprotocol/client'),mcpServerPackage:packageVersion('@modelcontextprotocol/server'),anthropicSdkPackage:packageVersion('@anthropic-ai/sdk'),zodPackage:packageVersion('zod'),lockfileSha256:sha(readFileSync('package-lock.json')),model,maxTokens,systemSha256:sha(system)};
  run.status='running';persist();
  const anthropic=new Anthropic({timeout:30_000,maxRetries:0});
  const assertInputs=()=>{
    if (sha(readFileSync(snapshotPath))!==inputHashes.snapshot) throw Error('SOURCE_CHANGED');
    if (sha(readFileSync(fieldsPath))!==inputHashes.fields) throw Error('ALLOWLIST_CHANGED');
    if (sha(readFileSync(tasksPath))!==inputHashes.tasks) throw Error('TASKS_CHANGED');
  };
  const open=async (mode:'baseline'|'optimized')=>{
    assertInputs();
    const transport=new StdioClientTransport({command:process.execPath,args:[resolve('node_modules/tsx/dist/cli.mjs'),resolve('src/server.ts')],cwd:process.cwd(),env:{...process.env,SNAPSHOT_PATH:snapshotPath,FIELDS_PATH:fieldsPath,MODE:mode,EXPECTED_SHA256:inputHashes.snapshot,EXPECTED_FIELDS_SHA256:inputHashes.fields} as Record<string,string>,stderr:'inherit'});
    const client=new Client({name:'measured-host',version:'1.0.0'});
    try {await client.connect(transport);} catch(e) {await client.close().catch(()=>{});throw e;}
    return client;
  };
  let abort=false;
  for (const mode of ['baseline','optimized'] as const) for (const task of tasks) {
    const record:any={mode,task:task.kind,question:task.question,status:abort?'skipped':'running',turns:[],sourceHash:source.hash,sourceRevision:source.revision};
    if (task.kind==='aggregate') record.independentCount=source.rows.filter(r=>r[task.aggregateField!]===task.aggregateValue).length;
    run.results.push(record);persist();
    if (abort) {record.failure='PRIOR_FAILURE';persist();continue;}
    let client:Client|undefined;
    const started=Date.now();
    try {
      assertInputs();
      client=await open(mode); // Fresh server and application cache for every question.
      const listing=await client.listTools(); // This pages the catalog, not records.
      const tools=listing.tools.filter(t=>mode==='baseline' ? ['list_records','exact_record'].includes(t.name) : ['find_records','count_by_field','exact_record'].includes(t.name)).map(t=>({name:t.name,description:t.description ?? '',input_schema:t.inputSchema}));
      record.tools=tools;record.schemaHash=sha(JSON.stringify(tools));record.protocolVersion=client.getNegotiatedProtocolVersion();record.serverApplicationVersion=client.getServerVersion();persist();
      const messages:any[]=[{role:'user',content:task.question}];
      for (let n=0;n<12;n++) {
        assertInputs();
        if (process.env.OFFLINE_FAIL_FIRST_TASK==='1' && mode==='baseline' && task.kind===tasks[0].kind) throw Error('OFFLINE_TEST_FAILURE');
        const modelStarted=Date.now();
        const response:any=await anthropic.messages.create({model,max_tokens:maxTokens,system,messages,tools:tools as any});
        const u=response.usage;
        const turn:any={modelElapsedMs:Date.now()-modelStarted,stopReason:response.stop_reason,usage:{input_tokens:u.input_tokens,cache_creation_input_tokens:u.cache_creation_input_tokens ?? 0,cache_read_input_tokens:u.cache_read_input_tokens ?? 0,output_tokens:u.output_tokens},tools:[]};
        record.turns.push(turn);persist();
        assertInputs();
        if (response.stop_reason==='end_turn') {
          try {record.answer=JSON.parse(response.content.filter((b:any)=>b.type==='text').map((b:any)=>b.text).join(''));}
          catch {record.failure='NON_JSON_ANSWER';}
          break;
        }
        if (response.stop_reason!=='tool_use') {record.failure=`STOP_${response.stop_reason ?? 'UNKNOWN'}`;break;}
        const uses=response.content.filter((b:any)=>b.type==='tool_use');
        if (!uses.length) {record.failure='EMPTY_TOOL_USE';break;}
        messages.push({role:'assistant',content:response.content});
        const results:any[]=[];
        for (const use of uses) {
          assertInputs();
          const toolStarted=Date.now();
          let content='', is_error=false, rawBlocks:unknown[]=[], rawBytes=0, rawHash='';
          try {
            if (!tools.some(t=>t.name===use.name)) throw Error('UNADVERTISED_TOOL');
            const result=await client.callTool({name:use.name,arguments:use.input as Record<string,unknown>},{timeout:10_000});
            is_error=!!result.isError;rawBlocks=result.content;rawBytes=Buffer.byteLength(JSON.stringify(result.content));rawHash=sha(JSON.stringify(result.content));
            const texts=result.content.filter((b:any)=>b.type==='text').map((b:any)=>b.text);
            content=texts.map((text:string)=>{if (mode==='baseline' || result.isError) return text;const {cache,sourceHash,...selected}=JSON.parse(text);return JSON.stringify(selected);}).join('\n');
            if (!content) {content='EMPTY_TOOL_CONTENT';is_error=true;}
          } catch(e) {content=`TOOL_ERROR: ${errorText(e)}`;is_error=true;}
          turn.tools.push({name:use.name,arguments:use.input,elapsedMs:Date.now()-toolStarted,is_error,rawMcpContent:rawBlocks,rawMcpContentBytes:rawBytes,rawMcpContentSha256:rawHash,modelFacingText:content,modelFacingBytes:Buffer.byteLength(content),modelFacingSha256:sha(content)});
          results.push({type:'tool_result',tool_use_id:use.id,content,is_error});
          persist();
          if (is_error) record.failure='TOOL_FAILURE';
        }
        messages.push({role:'user',content:results});
        if (record.failure) break;
      }
      if (!record.answer && !record.failure) record.failure='TURN_LIMIT';
      assertInputs();
      const calls=record.turns.flatMap((t:any)=>t.tools);
      let pathOkay=mode==='baseline';
      if (mode==='optimized' && task.kind==='aggregate') pathOkay=calls.some((c:any)=>c.name==='count_by_field');
      if (mode==='optimized' && task.kind==='later_page') pathOkay=calls.some((c:any)=>c.name==='find_records' && c.arguments?.cursor);
      if (mode==='optimized' && task.kind==='lookup') {
        const brief=calls.findIndex((c:any)=>{
          if (c.name!=='find_records' || c.arguments?.matchField===task.detailField || c.arguments?.fields?.includes(task.detailField) || c.is_error) return false;
          try {return JSON.parse(c.modelFacingText).rows.some((r:any)=>task.evidenceIds.includes(r.id));} catch {return false;}
        });
        pathOkay=brief>=0 && calls.slice(brief+1).some((c:any)=>c.name==='exact_record' && task.evidenceIds.includes(c.arguments?.id));
      }
      const answer=record.answer;
      record.quality=!!answer && !record.failure && pathOkay && answer.answer===task.expectedAnswer && Array.isArray(answer.evidenceIds) && task.evidenceIds.every(id=>answer.evidenceIds.includes(id)) && Array.isArray(answer.caveats) && task.requiredCaveats.every(c=>answer.caveats.includes(c));
      if (!record.quality && !record.failure) record.failure='ANSWER_KEY_OR_TOOL_PATH_MISMATCH';
    } catch(e) {record.failure=`RUNTIME_ERROR: ${errorText(e)}`;record.quality=false;}
    finally {
      if (client) try {await client.close();} catch(e) {record.failure=[record.failure,`CLOSE_ERROR: ${errorText(e)}`].filter(Boolean).join('; ');record.quality=false;}
      record.elapsedMs=Date.now()-started;
      record.totalInputTokens=record.turns.reduce((s:number,t:any)=>s+t.usage.input_tokens+t.usage.cache_creation_input_tokens+t.usage.cache_read_input_tokens,0);
      record.totalOutputTokens=record.turns.reduce((s:number,t:any)=>s+t.usage.output_tokens,0);
      record.status=record.failure?'failed':'passed';persist();
    }
    if (record.failure) abort=true;
  }
  if (!abort) {
    const probe:any={mode:'optimized',task:'warm_cache_probe_only',status:'running',note:'Separate fresh server; direct tool calls excluded from model comparison.'};run.results.push(probe);persist();
    let client:Client|undefined;
    try {
      assertInputs();client=await open('optimized');
      const args={field:'id',value:source.rows[0].id};
      const a=await client.callTool({name:'count_by_field',arguments:args});const b=await client.callTool({name:'count_by_field',arguments:args});
      const read=(r:any)=>r.isError?{error:r.content}:JSON.parse(r.content[0].text);
      probe.first=read(a);probe.repeat=read(b);
      if (a.isError || b.isError || probe.first.cache!=='miss' || probe.repeat.cache!=='hit') throw Error('CACHE_PROBE_FAILED');
      probe.status='passed';
    } catch(e) {probe.status='failed';probe.failure=errorText(e);abort=true;}
    finally {if(client) try {await client.close();} catch(e) {probe.status='failed';probe.failure=[probe.failure,`CLOSE_ERROR: ${errorText(e)}`].filter(Boolean).join('; ');abort=true;}persist();}
  } else {run.results.push({mode:'optimized',task:'warm_cache_probe_only',status:'skipped',failure:'PRIOR_FAILURE'});persist();}
  run.status=abort?'failed':'completed';persist();
}

try {await main();}
catch(e) {run.errors.push({at:new Date().toISOString(),message:errorText(e)});run.status='failed';process.exitCode=1;}
finally {persist();console.log(file);if (run.status!=='completed') process.exitCode=1;}
```

The local check code for `src/fixture-check.ts` follows. Its artificial values are test inputs only; an injected pre-call failure checks receipt persistence without contacting a model.

```typescript
import { mkdtempSync, writeFileSync, rmSync, readFileSync, readdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
import { loadSnapshot, cursor } from './data.js';

// Artificial software test input only. It is not a reported business result or model run.
const dir=mkdtempSync(join(tmpdir(),'mcp-context-check-'));
const file=join(dir,'rows.json');
const fieldsFile=join(dir,'fields.json');
writeFileSync(file,JSON.stringify(Array.from({length:12},(_,i)=>({id:`T${String(i).padStart(2,'0')}`,revision:'r1',status:i%2?'open':'closed',detail:`test-${i}`}))));
writeFileSync(fieldsFile,JSON.stringify(['id','revision','status','detail']));
const src=loadSnapshot(file);
async function connect(mode:string) {
  const transport=new StdioClientTransport({command:process.execPath,args:[resolve('node_modules/tsx/dist/cli.mjs'),resolve('src/server.ts')],cwd:process.cwd(),env:{...process.env,SNAPSHOT_PATH:file,FIELDS_PATH:fieldsFile,MODE:mode} as Record<string,string>,stderr:'inherit'});
  const client=new Client({name:'fixture-check',version:'1.0.0'}); await client.connect(transport); return client;
}
try {
  const c=await connect('optimized');
  try {
    const names=(await c.listTools()).tools.map(t=>t.name);
    assert.deepEqual(names.sort(),['count_by_field','exact_record','find_records']);
    const call=async (name:string,args:Record<string,unknown>)=>await c.callTool({name,arguments:args});
    const unpack=(r:any)=>JSON.parse(r.content[0].text);
    const first=unpack(await call('find_records',{matchField:'status',matchValue:'open',fields:['detail']}));
    assert.equal(first.rows.length,5); assert.equal(first.totalMatches,6); assert.equal(first.moreAvailable,true);
    assert.equal(first.rows[0].detail,'test-1');
    const second=unpack(await call('find_records',{matchField:'status',matchValue:'open',fields:['detail'],cursor:first.nextCursor}));
    assert.equal(second.rows.length,1); assert.equal(second.moreAvailable,false);
    const repeat=unpack(await call('find_records',{matchField:'status',matchValue:'open',fields:['detail']})); assert.equal(repeat.cache,'hit');
    const count=unpack(await call('count_by_field',{field:'status',value:'open'})); assert.equal(count.count,6); assert.equal(count.auditIds.length,5);
    const audit=unpack(await call('count_by_field',{field:'status',value:'open',cursor:count.nextCursor})); assert.equal(audit.auditIds.length,1);
    const exact=unpack(await call('exact_record',{id:'T11'})); assert.equal(exact.row.detail,'test-11');
    const missing=unpack(await call('exact_record',{id:'absent'})); assert.equal(missing.found,false);
    assert.equal((await call('find_records',{matchField:'secret',matchValue:'x',fields:[]})).isError,true);
    assert.equal((await call('find_records',{matchField:'status',matchValue:'open',fields:[],cursor:'bad'})).isError,true);
    assert.equal((await call('find_records',{matchField:'status',matchValue:'open',fields:[],cursor:cursor(5,'old-hash','old-scope')})).isError,true);
    assert.equal((await call('find_records',{matchField:'status',matchValue:'open',fields:[],cursor:first.nextCursor})).isError,true); // selected fields differ
    assert.equal(src.rows.filter(r=>r.status==='open').length,count.count);
    const original=readFileSync(file);
    writeFileSync(file,original.toString('utf8').replace('test-0','test-changed'));
    assert.equal((await call('find_records',{matchField:'status',matchValue:'open',fields:[]})).isError,true);
    writeFileSync(file,original);
    const originalFields=readFileSync(fieldsFile);
    writeFileSync(fieldsFile,JSON.stringify(['id','revision','status']));
    assert.equal((await call('count_by_field',{field:'status',value:'open'})).isError,true);
    writeFileSync(fieldsFile,originalFields);
  } finally {await c.close();}
  const b=await connect('baseline');
  try {assert.deepEqual((await b.listTools()).tools.map(t=>t.name).sort(),['exact_record','list_records']); const r=await b.callTool({name:'list_records',arguments:{}}); assert.equal(JSON.parse((r.content[0] as any).text).rows.length,12);} finally {await b.close();}
  const tasksFile=join(dir,'tasks.json'),runsDir=join(dir,'runs');
  writeFileSync(tasksFile,JSON.stringify([
    {kind:'lookup',question:'test lookup',expectedAnswer:'test',evidenceIds:['T01'],requiredCaveats:[],detailField:'detail'},
    {kind:'aggregate',question:'test aggregate',expectedAnswer:'6',aggregateField:'status',aggregateValue:'open',evidenceIds:['T01'],requiredCaveats:[]},
    {kind:'later_page',question:'test page',expectedAnswer:'test',evidenceIds:['T11'],requiredCaveats:[]}
  ]));
  const failed=spawnSync(process.execPath,[resolve('node_modules/tsx/dist/cli.mjs'),resolve('src/host.ts')],{cwd:process.cwd(),encoding:'utf8',env:{...process.env,SNAPSHOT_PATH:file,FIELDS_PATH:fieldsFile,TASKS_PATH:tasksFile,RUNS_DIR:runsDir,CLAUDE_MODEL:'offline-test',ANTHROPIC_API_KEY:'offline-test',OFFLINE_FAIL_FIRST_TASK:'1'}});
  assert.equal(failed.status,1);
  const run=JSON.parse(readFileSync(join(runsDir,readdirSync(runsDir)[0]),'utf8'));
  assert.equal(run.status,'failed');assert.equal(run.results.length,7);
  assert.match(run.results[0].failure,/OFFLINE_TEST_FAILURE/);
  assert.equal(run.results[1].status,'skipped');assert.equal(run.results[6].status,'skipped');
  assert.equal(run.runtime.mcpClientPackage,'2.3.1');assert.equal(run.runtime.mcpServerPackage,'2.3.1');
  const startupRuns=join(dir,'startup-runs');
  const startup=spawnSync(process.execPath,[resolve('node_modules/tsx/dist/cli.mjs'),resolve('src/host.ts')],{cwd:process.cwd(),encoding:'utf8',env:{...process.env,SNAPSHOT_PATH:file,FIELDS_PATH:fieldsFile,TASKS_PATH:tasksFile,RUNS_DIR:startupRuns,CLAUDE_MODEL:'',ANTHROPIC_API_KEY:''}});
  assert.equal(startup.status,1);
  const startupReceipt=JSON.parse(readFileSync(join(startupRuns,readdirSync(startupRuns)[0]),'utf8'));
  assert.equal(startupReceipt.status,'failed');assert.match(startupReceipt.errors[0].message,/CLAUDE_MODEL/);
  console.log('PASS: local MCP handshake, catalog, baseline, pagination, cache, aggregate, detail and failures; no model call');
} finally {rmSync(dir,{recursive:true,force:true});}
```

Set `SNAPSHOT_PATH`, `FIELDS_PATH`, and `TASKS_PATH` to your private files, `CLAUDE_MODEL` to an available model ID, and `ANTHROPIC_API_KEY` through your shell's secret mechanism. Then run `npm run run`. The script makes billable calls. Check current [Claude pricing](https://platform.claude.com/docs/en/about-claude/pricing), account limits, and [rate limits](https://platform.claude.com/docs/en/api/rate-limits) first; the example itself cannot enforce an account spending ceiling. Stop if you cannot bound the experiment acceptably. Never paste the key into the files or the run log.

## 5. Read the result as a quality check first

The script creates a private JSON file under `runs/` before reading inputs and updates it after each stage. A startup, connection, catalog, model, tool, or close failure leaves a failed receipt. When a task fails, remaining tasks are marked skipped; a startup failure is saved before tasks are created. Neither is a completed comparison. For each task and condition, it records the input file hashes, installed client/server package versions and lockfile hash, negotiated MCP protocol version, server application identity, advertised tool schemas, tool arguments, raw MCP content bytes and hash, model-facing bytes and hash, turn count, elapsed time, stop reason, and provider usage. Total input tokens sum `input_tokens`, `cache_creation_input_tokens`, and `cache_read_input_tokens` over every Messages response; output tokens are summed separately. A prompt-cache read may affect price without shrinking the context represented by the request. Compare current account billing if you estimate charges.

First inspect every `quality` and `failure` field against the original records. The exact-answer comparison, required IDs, caveat strings, and required tool path are intentionally strict; a harmless wording difference can fail and needs human review, while a deceptively matching short answer can still be incomplete. Examine later-page traversal and the bounded aggregate audit IDs yourself. If any answer loses a fact, hides a contrary record, or gains extra turns, retain that private failed run, repair the query or rubric, and rerun both conditions against the same frozen snapshot. Compare token and byte totals per task and in total only after both answers pass. Savings can be zero or negative.

For recovery, try an invalid field, an invalid or stale cursor, a missing exact ID, and a changed source revision. The local fixture checks these server paths, including a changed test snapshot; repeat the checks against your own export before drawing conclusions about an updated dataset. A model or tool timeout is a failed run, not an empty match. Rebuild the answer key after an authorized source change and begin a new comparison with a new snapshot hash.

If you are also using a ready-made Claude Code context plugin, our [Context Mode installation guide](https://bigchange.ai/blog/claude-code-context-mode-plugin-install-verify-guide) covers that separate setup. This experiment measures a reader-owned MCP server and host; neither guide establishes a universal token reduction.

## Sources & further reading

- [MCP SDK: first server](https://ts.sdk.modelcontextprotocol.io/v2/get-started/first-server) — local server setup and `serveStdio` pattern; the filter, cache, and answer rubric here are our application code.
- [MCP SDK: connect](https://ts.sdk.modelcontextprotocol.io/v2/clients/connect) and [call tools](https://ts.sdk.modelcontextprotocol.io/v2/clients/calling) — client transport, catalog listing, and result semantics. Catalog pagination is distinct from this guide's record cursor.
- [MCP SDK: schema libraries](https://ts.sdk.modelcontextprotocol.io/v2/advanced/schema-libraries) — schema support for tool arguments; field authorization still belongs to the application.
- [Claude: handle tool calls](https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls) — the client tool-use and tool-result loop; the local host must enforce its own quality conditions.
- [Claude: prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching), [pricing](https://platform.claude.com/docs/en/about-claude/pricing), and [rate limits](https://platform.claude.com/docs/en/api/rate-limits) — usage interpretation and current reader-account cost checks. No paid result is reported here.

## Sources

- [MCP TypeScript SDK: Build your first server](https://ts.sdk.modelcontextprotocol.io/v2/get-started/first-server) — Local serveStdio, McpServer and Zod tool registration; application optimization is not built in.
- [MCP TypeScript SDK: Connect to an MCP server](https://ts.sdk.modelcontextprotocol.io/v2/clients/connect) — Local client and stdio transport setup.
- [MCP TypeScript SDK: Call tools](https://ts.sdk.modelcontextprotocol.io/v2/clients/calling) — Tool catalog, callTool and result content; record pagination is the article application.
- [MCP TypeScript SDK: Schema libraries](https://ts.sdk.modelcontextprotocol.io/v2/advanced/schema-libraries) — Current schema contract; installed declarations and local compile check exact package combination.
- [Claude Platform: Handle tool calls](https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls) — Client tool-use and matching tool-result flow; no Claude execution in this package.
- [Claude Platform: Prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching) — Input usage interpretation and provider prompt cache, distinct from the local result cache.
- [Claude Platform: Pricing](https://platform.claude.com/docs/en/about-claude/pricing) — Reader must check current model prices before a paid experiment; no cost estimate reported.
- [Claude Platform: Rate limits](https://platform.claude.com/docs/en/api/rate-limits) — Reader account limits and recovery; no account limit was measured.