T14-L04

Connect your tools · Integrator

Build your own connector

T14-L04 | Connect your tools | Level 4 Integrator | 55 minutes

Level
IntegratorLevel 4 of 5
Curriculum position
Family 2 · Track 14
Reading time
55 minutes
Reading progress
0%Time on this book
Last revised
Sep 5, 2026

T14-L04 | Connect your tools | Level 4 Integrator | 55 minutes

At Level 4, you are creating the route an assistant uses to reach an internal system. A wrong tool contract can affect every colleague who connects it, expose records outside the task, or turn a plausible model-generated argument into an unauthorized action.

2. The missing connector becomes your responsibility

You need the assistant to retrieve one current inventory record. The information lives in a small internal system, but no approved connector exists. A generic database tool looks quick: give it a query field, attach a service credential, and tell the model to read only.

That design gives model-generated text the authority of the credential. A mistaken table name could expose unrelated records; a helpful query could update state; a stack trace could reveal implementation details. The assistant's instruction is not the boundary.

You need to build one connector with one job: return a minimal synthetic inventory record by a validated ID. The server must select a narrow profile before startup, reject another profile's IDs, return clear errors for malformed or missing records, and expose no write tool. You will test the handler, start the MCP server locally, inspect the discovered contract, and prove that an attempted update is unavailable while the fixture remains unchanged.

3. After this you can

  • Specify one assistant-facing tool with a clear name, one job, and unambiguous parameters.
  • Implement server-side validation, profile authorization, bounded output, and actionable errors.
  • Connect the tested handler to a local MCP STDIO server without exposing a network listener.
  • Test allowed, malformed, forbidden, missing, unknown-tool, and write-shaped calls reproducibly.
  • Prove that a write attempt is refused and the synthetic source state remains unchanged.

4. Prerequisites

  • T14-L03 | Connect an assistant to your tools, including call inspection and denied-path testing.
  • T12-L04 | Secure an AI system, especially identity, least privilege, secrets, logs, and stop controls.
  • T08-L03 | Spec-driven development, including acceptance criteria and must-not-change boundaries.
  • Node.js 22.19.0 or later, npm, a text editor, and a new local training folder. Inspector 2.5.0 declares this Node minimum.
  • Permission to install the exact pinned packages in Section 6 and run a local STDIO process.
  • An approved MCP client or the pinned MCP Inspector that shows discovered tools, arguments, results, and errors.

Do not connect the exercise to a production database, CRM, inventory service, laboratory system, filesystem, shell, or network endpoint. Do not add an API key, OAuth token, password, cookie, customer or participant record, unpublished result, sample identifier, or real stock record. The fixture identities below demonstrate authorization logic; they are not production authentication. A real remote connector needs the organisation's approved identity, authorization, secret, network, logging, retention, deployment, and incident controls.

5. The idea in one page

A connector is an application boundary. The model may choose a tool and propose arguments; deterministic server code decides whether the request is valid and authorized. Build from the boundary inward:

assistant proposal -> named tool -> schema validation -> profile authorization
                  -> one source operation -> minimal result or explicit error

Use one system, one connector, and the fewest tools that complete the task. Each tool gets one verb and one object. read_inventory_item(item_id) is easier to predict, authorize, test, and review than inventory(mode, table, query, body). Do not expose a generic SQL, URL, path, shell, or mode parameter.

A usable contract states:

PartDecisionBehaviour it changes
Name and descriptionExact job, source, read-only limit, and resultHelps the model select the right tool without implying extra authority.
Input schemaRequired fields, format, length, and no free-form queryRejects malformed arguments before source access.
Identity and resource grantWhich profile may read which namespaceMakes a valid-shaped cross-profile request return forbidden.
Output schemaOnly fields the task needsPrevents a successful read from returning the whole source row.
ErrorsHandler codes invalid_input, forbidden, not_found, tool_unavailable; documented MCP SDK validation/not-found errors at the protocol boundaryLets the caller stop or correct one field without pretending an unregistered tool reached application code.
Write boundaryNo write handler, registration, or source permissionMakes an update impossible rather than merely discouraged.

Descriptions guide tool choice; code enforces permission. Validate again inside the handler even when the protocol library validates the schema. Authorize before lookup so another namespace cannot be distinguished by its contents. Return errors without stack traces, credentials, alternate records, or fallback searches.

Test the contract below the model first. Then inspect it through a real MCP client. Require an allowed read, malformed ID, valid-shaped cross-profile ID, absent in-scope ID, unknown tool, and attempted update. A passing happy path does not prove the boundary. Deploy this exercise as a local child process over STDIO; it opens no listening port, and stopping the process removes the route.

6. The worked example: one narrow connector, built and tested

The fictional Northstar Inventory contains only four synthetic records. Lab and Company use the same implementation with different startup profiles. There is one exposed tool and no write code. The deterministic dispatcher and MCP registration share the read handler. The live registration also has a strict schema gate, so malformed or additional arguments cannot be silently stripped before reaching that handler.

Dependency check - last verified 4 September 2026: this exercise pins @modelcontextprotocol/sdk 1.30.0, zod 4.5.4, and optional MCP Inspector 2.5.0, matching the preceding book's verified sandbox. Recheck approved registry metadata and official release guidance before installing. Do not silently substitute another version.

Write the contract before the server

Create a new empty folder named northstar-inventory-connector. Put this acceptance contract in your connector package record:

Tool: read_inventory_item
Purpose: read one synthetic inventory record by item_id from the startup profile
Input: exactly one item_id shaped LAB-ITEM-000 or CO-ITEM-000
Output: item_id, label, quantity, unit, location, updated_at

AC1 allowed in-profile ID returns exactly the six public fields
AC2 malformed ID returns invalid_input before lookup
AC3 valid cross-profile ID returns forbidden without record fields
AC4 absent in-profile ID returns not_found without fallback search
AC5 dispatcher returns tool_unavailable for an unknown or write tool name;
    live MCP returns an SDK isError result whose text is Tool <name> not found
AC6 refused write leaves the source record unchanged

Non-goals: search, list, SQL, URL fetch, create, update, delete, reserve, order,
authentication, remote deployment, production data, and compatibility guarantees

These non-goals are enforceable in the exercise because the corresponding parameters, handlers, and source operations do not exist. In a real service, authentication is not optional; it is excluded only because this local fixture selects a synthetic profile before the process starts.

Implement the connector package

Create package.json:

{
  "name": "northstar-inventory-connector",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {"node": ">=22.19.0"},
  "scripts": {
    "start": "node connector.mjs",
    "test": "node --test",
    "verify": "node verify.mjs"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "1.30.0",
    "zod": "4.5.4"
  }
}

Create connector.mjs:

import {pathToFileURL} from 'node:url';

import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
import {z} from 'zod';

const records = Object.freeze([
  Object.freeze({
    item_id: 'LAB-ITEM-104', profile: 'lab', label: 'Synthetic calibration kit',
    quantity: 12, unit: 'kits', location: 'Training shelf A', updated_at: '2026-09-03',
    supplier_note: 'internal field not returned',
  }),
  Object.freeze({
    item_id: 'LAB-ITEM-208', profile: 'lab', label: 'Fictional sample rack',
    quantity: 5, unit: 'racks', location: 'Training shelf B', updated_at: '2026-09-01',
    supplier_note: 'internal field not returned',
  }),
  Object.freeze({
    item_id: 'CO-ITEM-317', profile: 'company', label: 'Fictional replacement adapter',
    quantity: 24, unit: 'units', location: 'Training bin C', updated_at: '2026-09-02',
    supplier_note: 'internal field not returned',
  }),
  Object.freeze({
    item_id: 'CO-ITEM-412', profile: 'company', label: 'Synthetic packing insert',
    quantity: 80, unit: 'packs', location: 'Training bin D', updated_at: '2026-08-30',
    supplier_note: 'internal field not returned',
  }),
]);

const grants = Object.freeze({
  lab: Object.freeze({profile: 'lab', prefix: 'LAB-ITEM-'}),
  company: Object.freeze({profile: 'company', prefix: 'CO-ITEM-'}),
});
const idPattern = /^(LAB|CO)-ITEM-\d{3}$/;
const mcpInputSchema = z.object({
  item_id: z.string().regex(idPattern),
}).strict();
const fail = (error, reason) => ({ok: false, error, reason});

export function readInventoryItem(input, grant) {
  if (!input || Object.keys(input).length !== 1
      || typeof input.item_id !== 'string' || !idPattern.test(input.item_id)) {
    return fail('invalid_input', 'item_id must match LAB-ITEM-000 or CO-ITEM-000');
  }
  if (!input.item_id.startsWith(grant.prefix)) {
    return fail('forbidden', 'item_id is outside the active profile');
  }
  const found = records.find((record) => record.item_id === input.item_id);
  if (!found) return fail('not_found', 'no item exists for that in-profile item_id');
  const {item_id, label, quantity, unit, location, updated_at} = found;
  return {ok: true, item: {item_id, label, quantity, unit, location, updated_at}};
}

export function invokeTool(name, input, grant) {
  if (name !== 'read_inventory_item') {
    return fail('tool_unavailable', 'only read_inventory_item is available');
  }
  return readInventoryItem(input, grant);
}

export function getGrant(profile) {
  return grants[profile] ?? null;
}

export function fixtureSnapshot(itemId) {
  return JSON.stringify(records.find((record) => record.item_id === itemId));
}

export function createServer(grant) {
  const server = new McpServer({name: 'northstar-inventory', version: '1.0.0'});
  server.registerTool('read_inventory_item', {
    description: 'Read one item from the active synthetic inventory profile. Read-only; no search or update.',
    inputSchema: mcpInputSchema,
  }, async (input) => {
    const value = invokeTool('read_inventory_item', input, grant);
    return {
      content: [{type: 'text', text: JSON.stringify(value)}],
      isError: value.ok === false,
    };
  });
  return server;
}

const isEntryPoint = process.argv[1]
  && import.meta.url === pathToFileURL(process.argv[1]).href;

if (isEntryPoint) {
  const grant = getGrant(process.env.CONNECTOR_PROFILE);
  if (!grant) {
    console.error('Set CONNECTOR_PROFILE to lab or company');
    process.exit(1);
  }
  await createServer(grant).connect(new StdioServerTransport());
}

The source row includes supplier_note so the positive test can prove output minimization. The handler selects only six declared fields. Passing a complete z.object(...).strict() to registerTool, rather than the raw shape shorthand, makes the live SDK reject extra keys instead of stripping them. Object.freeze makes accidental mutation fail or have no effect in this fixture; the stronger control is that no update function or MCP tool exists.

Add ordinary and denied tests

Create test/connector.test.mjs:

import test from 'node:test';
import assert from 'node:assert/strict';

import {fixtureSnapshot, getGrant, invokeTool} from '../connector.mjs';

const lab = getGrant('lab');

test('AC1 returns only the bounded in-profile item', () => {
  const result = invokeTool('read_inventory_item', {item_id: 'LAB-ITEM-104'}, lab);
  assert.deepEqual(result, {
    ok: true,
    item: {
      item_id: 'LAB-ITEM-104', label: 'Synthetic calibration kit', quantity: 12,
      unit: 'kits', location: 'Training shelf A', updated_at: '2026-09-03',
    },
  });
  assert.equal('supplier_note' in result.item, false);
});

test('AC2 rejects malformed input', () => {
  assert.equal(invokeTool('read_inventory_item', {item_id: '../secrets'}, lab).error,
    'invalid_input');
  assert.equal(invokeTool('read_inventory_item', {
    item_id: 'LAB-ITEM-104', query: 'all',
  }, lab).error, 'invalid_input');
});

test('AC3 rejects a valid cross-profile item', () => {
  const result = invokeTool('read_inventory_item', {item_id: 'CO-ITEM-317'}, lab);
  assert.deepEqual(result, {
    ok: false, error: 'forbidden', reason: 'item_id is outside the active profile',
  });
});

test('AC4 reports an absent in-profile item without fallback', () => {
  assert.equal(invokeTool('read_inventory_item', {item_id: 'LAB-ITEM-999'}, lab).error,
    'not_found');
});

test('AC5 rejects an unknown read tool', () => {
  assert.equal(invokeTool('search_inventory', {query: 'kit'}, lab).error,
    'tool_unavailable');
});

test('AC5 and AC6 refuse a write and preserve state', () => {
  const before = fixtureSnapshot('LAB-ITEM-104');
  const result = invokeTool('update_inventory_item', {
    item_id: 'LAB-ITEM-104', quantity: 0,
  }, lab);
  assert.equal(result.error, 'tool_unavailable');
  assert.equal(fixtureSnapshot('LAB-ITEM-104'), before);
});

Create verify.mjs to print a deterministic result that is easier to retain than test-runner decoration:

import {fixtureSnapshot, getGrant, invokeTool} from './connector.mjs';

const lab = getGrant('lab');
const before = fixtureSnapshot('LAB-ITEM-104');
const allowed = invokeTool('read_inventory_item', {item_id: 'LAB-ITEM-104'}, lab);
const report = {
  allowed: allowed.item?.item_id,
  returned_fields: Object.keys(allowed.item ?? {}).sort(),
  malformed: invokeTool('read_inventory_item', {item_id: '../secrets'}, lab).error,
  cross_profile: invokeTool('read_inventory_item', {item_id: 'CO-ITEM-317'}, lab).error,
  missing: invokeTool('read_inventory_item', {item_id: 'LAB-ITEM-999'}, lab).error,
  write_attempt: invokeTool('update_inventory_item', {
    item_id: 'LAB-ITEM-104', quantity: 0,
  }, lab).error,
  state_unchanged: fixtureSnapshot('LAB-ITEM-104') === before,
};
console.log(JSON.stringify(report, null, 2));

Install and run from the package directory:

npm install --ignore-scripts
npm test
npm run verify

npm test must report six passing tests and zero failures. npm run verify must produce these material values; field ordering is already sorted:

{
  "allowed": "LAB-ITEM-104",
  "returned_fields": ["item_id", "label", "location", "quantity", "unit", "updated_at"],
  "malformed": "invalid_input",
  "cross_profile": "forbidden",
  "missing": "not_found",
  "write_attempt": "tool_unavailable",
  "state_unchanged": true
}

Read the assertions, not only the green summary. The write test calls the same dispatcher used by registration, checks tool_unavailable, and compares the complete frozen source row before and after. It does not claim that a real database credential is read-only; a production connector must also lack source-level write permission.

Connect the tested server locally

Configure the Lab profile in an approved MCP client. Replace the path with the absolute path to connector.mjs:

{
  "mcpServers": {
    "northstar-inventory-lab": {
      "command": "node",
      "args": ["/absolute/path/northstar-inventory-connector/connector.mjs"],
      "env": {"CONNECTOR_PROFILE": "lab"}
    }
  }
}

Restart or reload the client. The discovered list must contain exactly read_inventory_item. Its schema must require item_id, disallow additional properties, and describe the ID pattern; its description must say synthetic and read-only. Stop if any search, create, update, delete, reserve, order, SQL, URL, file, or shell tool appears.

For direct calls, use the approved client test view or MCP Inspector 2.5.0. On PowerShell:

$env:CONNECTOR_PROFILE='lab'
npx --yes @modelcontextprotocol/inspector@2.5.0 node connector.mjs

On a POSIX shell:

CONNECTOR_PROFILE=lab npx --yes @modelcontextprotocol/inspector@2.5.0 node connector.mjs

Review the command and package source before installation. The Inspector opens a local interface. Call the allowed ID, malformed ID, extra-key input, cross-profile ID, and missing ID. The two schema-denied calls must return SDK tool results with isError: true and text beginning Input validation error: Invalid arguments for tool read_inventory_item:; neither reaches the handler. Cross-profile and missing calls pass the schema and return the handler's JSON forbidden and not_found results with isError: true.

Attempt both search_inventory and update_inventory_item directly. Neither appears in discovery. With SDK 1.30.0, each call returns isError: true and the exact text Tool search_inventory not found or Tool update_inventory_item not found. These are the live equivalents of the dispatcher's stable tool_unavailable result: because no such tool is registered, application code cannot emit its own code. Do not change the server to add a temporary write tool for the test.

Lab framing: retrieve a synthetic stock check

With the Lab profile active, ask the assistant:

Read synthetic inventory item LAB-ITEM-104. Return its item ID, label, quantity,
unit, location, and updated date. Show the tool call. Do not search or change stock.

Inspect the call before trusting the prose. It must invoke read_inventory_item with exactly {"item_id":"LAB-ITEM-104"}. The result must contain quantity 12, unit kits, location Training shelf A, and date 2026-09-03; it must not contain supplier_note. Then call CO-ITEM-317, LAB-ITEM-999, ../secrets, and {"item_id":"LAB-ITEM-104","query":"all"} directly. Require handler codes forbidden and not_found for the first two, then SDK isError input-validation results for both malformed calls.

The Lab skin represents a read-only view over a sample-supply catalog. It does not contain real samples, participant IDs, unpublished results, ordering authority, or a reservation action. If a future task needs any of those, write a new contract and repeat security review rather than broadening this tool.

Company framing: retrieve the parallel stock check

Stop the Lab process, set the client profile to company, and rename the connection northstar-inventory-company. Ask:

Read synthetic inventory item CO-ITEM-317. Return its item ID, label, quantity,
unit, location, and updated date. Show the tool call. Do not search or change stock.

The call shape is identical. The result must show quantity 24, unit units, location Training bin C, and date 2026-09-02, with no internal note. LAB-ITEM-104 must now return forbidden; CO-ITEM-999 must return not_found; malformed or extra-key input must return the SDK input-validation error result; and update_inventory_item must return Tool update_inventory_item not found with isError: true.

The Company skin represents a read-only view over ordinary stock. It cannot reserve, order, price, approve, notify, or alter an item. A plausible assistant sentence such as stock has been updated is false unless an authorized action system separately proves it. This connector returns evidence for a person; it performs no action.

Package the proof without secrets

Retain one connector package containing the contract, package.json, lockfile, implementation, tests, deterministic verify output, and a short test record. The test record names Node version, package versions, command, six-test result, verify output, discovered tool list, live allowed and denied outcomes, the SDK input-validation and tool-not-found texts, refused write, profile used, reviewer, and date. It may reference a sanitized screenshot, but a screenshot is not required and is not a second exit artifact.

Stop the MCP process after testing. Remove the client entry if the exercise is complete. For a real internal system, do not carry this fixture profile into production. Replace it with approved per-user or workload authentication and server-side authorization, keep source credentials out of client configuration where the deployment design requires that, deploy through the approved network boundary, and repeat tests against a non-production environment before any real-data decision.

7. What goes wrong

One tool does everything through a mode flag

Symptom: inventory(mode, query, body) can read, search, update, and delete depending on model-generated text.

Fix: remove the generic surface. Expose one named operation per justified job, with a separate schema, authorization rule, tests, and approval boundary for any later action.

Parameter names make sense only to the builder

Symptom: fields such as id, scope, or payload require hidden conventions, so the assistant guesses values.

Fix: use domain-specific names such as item_id, define the exact pattern and example, reject additional free-form routing fields, and test malformed values.

Errors are stack traces or silent fallbacks

Symptom: a missing item reveals file paths and code, or causes a broad search that returns another record.

Fix: map expected failures to short stable codes and actionable reasons. Return no stack, alternate record, broad query, or record fields on failure.

Read-only exists only in the description

Symptom: the text says read-only, but an update handler is registered or the backing credential can write.

Fix: omit write code and registration, use a source identity without write permission, and prove both an unavailable tool and unchanged state in a non-production test.

Authorization happens after lookup

Symptom: another profile receives not_found, timing differences, or record details because the source was queried before scope was checked.

Fix: validate shape, authorize namespace and operation, then access the source. Test a real-shaped cross-profile ID and require forbidden without fields.

Unit tests pass but the live contract differs

Symptom: the handler is correct, while MCP discovery exposes another name, optional field, or extra tool.

Fix: connect an approved client or Inspector after tests. Compare discovered name, description, schema, result, errors, and denied tool with the written contract.

The local fixture is mistaken for production security

Symptom: CONNECTOR_PROFILE=lab is described as authentication, or the fixture is pointed at a real service.

Fix: keep this package synthetic and local. Design real identity, per-request authorization, source permissions, secret injection, TLS, logging, retention, deployment, and revocation with the responsible owners.

8. Do it yourself: build and prove the connector in 120 minutes

Choose Lab or Company. Use the embedded synthetic package or replace only its fictional records with equivalent synthetic records. Submit one connector package, not a live production connection.

Minutes 0-15: write the one-tool contract. Name one system, operation, parameter, output shape, profile grant, errors, non-goals, owner, and affected users. State that write, search, generic query, URL, path, and shell access remain impossible.

Minutes 15-35: create the package and implementation. Keep the profile selection outside the prompt, authorize before lookup, select minimal output fields, and register only the read tool. Do not add network listeners or credentials.

Minutes 35-55: add all six tests before changing the fixture. Include exact positive output, malformed input, valid cross-profile input, missing in-profile input, unknown tool, refused update, and before/after source equality.

Minutes 55-68: install the pinned dependencies, run npm test, and run npm run verify. Compare material output with Section 6. Inspect the dependency lockfile and changed-file list. Stop on any failed or skipped test.

Minutes 68-85: connect one local profile to the approved client. Inspect discovery and the schema before calling. Run the allowed read and compare every returned field with the fixture. Confirm no internal field appears.

Minutes 85-100: run malformed, cross-profile, missing, and update-shaped calls through the approved test view. Require explicit outcomes and no fallback data. Snapshot the synthetic source before and after the write attempt and confirm equality.

Minutes 100-112: switch to the parallel profile and repeat the allowed and cross-profile checks. This proves the grant changes while code and tool contract remain fixed. Stop both processes when complete.

Minutes 112-120: complete the package test record with versions, commands, actual outputs, discovered tool list, reviewer, date, limitations, and removal step. Scan every retained file and image for secrets, real records, account identifiers, production endpoints, and stack traces.

If startup says Set CONNECTOR_PROFILE, set exactly lab or company in the client process environment and reload it; do not hard-code a broad default. If the client discovers no tool, run npm test, verify the absolute path and Node 22.19.0+, and inspect client stderr without printing secrets. If malformed or extra-key live input reaches the handler, stop: confirm registration receives mcpInputSchema as the complete strict object, then rerun the checks. A live schema denial is expected to use the SDK input-validation text, while direct dispatcher tests use invalid_input. If a cross-profile call returns not_found or data, stop and move authorization before lookup. If a write tool appears, disconnect immediately, remove its code and registration, rerun all tests, and inspect the backing permission. If an unregistered write call does not produce the pinned SDK's Tool update_inventory_item not found error result, record the actual version and stop rather than rewriting the expectation. If stdout contains logs, move safe diagnostics to stderr because STDIO stdout carries protocol messages.

9. Exit check

Deliver exactly one artifact: one working connector package with its passing test record, including the negative test showing a write attempt is refused.

It passes when the package contains one clearly described read tool; strict exact schema; startup profile grant; server-side validation and authorization before lookup; minimal output; stable handler-level invalid_input, forbidden, not_found, and tool_unavailable behavior; documented live SDK input-validation and tool-not-found error results; no write handler or registration; six passing deterministic tests; expected verify output; a live local discovery and allowed call; a direct cross-profile denial; and before/after evidence that the refused write changed no synthetic state. The record must name versions, commands, actual results, reviewer, date, limitations, and process-removal step.

It fails if a generic query, URL, path, shell, mode flag, production source, broad credential, hidden write, fallback search, stack trace, secret, real record, or unsupported claim appears. A passing read without a negative write test is incomplete. A prompt saying do not write is not refusal evidence.

10. Rule to remember

One tool, one job, one clear error.

11. Further reading & tools