MCP reduces repeated implementation of discovery and invocation across applications. It defines communication and capability descriptions; applications still own the knowledge base, permissions, and output meaning. The minimal MCP server verifies a stdio handshake. This article examines instance identity and the difference between discovering a tool and permitting its invocation.
Hosts, clients, and servers have separate responsibilities
The 2025-11-25 architecture distinguishes a host, its clients, and servers. The host coordinates model interaction, connections, and call policy; a client communicates with one server; a server exposes capabilities such as tools. SDK 1.30.0 pins LATEST_PROTOCOL_VERSION to 2025-11-25, so this article cites that revision without calling it the latest specification.
A model tool call expresses a requested operation, and the application executes it. MCP makes discovery and invocation reusable across applications. Executors may reach remote services or run locally. Process location alone cannot distinguish model tool calling from MCP integration.
That revision defines stdio and Streamable HTTP as standard transports; see transports. Stdio requires no MCP HTTP listening port, while a handler may still use a network. Child-process permissions, connection options, service authentication, and business write checks remain relevant.
ChatCrystal exposes seven tools backed by HTTP
MCP server.ts constructs CrystalClient, requests status, registers seven tools, and connects StdioServerTransport. Its version comes from the CLI package. Four knowledge reads are search_knowledge, get_note, list_notes, and get_relations; the others are recall_for_task, validate_task_memory, and write_task_memory. The names match the original, but its old hard-coded version and under-150-lines claim are not current evidence.
Handlers delegate to CrystalClient and serialize results as text. Semantic search, keyword-filtered lists, and task-memory recall are distinct entry points. A validation dry run does not replace the write operation’s business checks. The protocol layer does not directly open the knowledge-base database.
mcp command uses resolveConnection to resolve a base URL and token from the local default, explicit options, environment, or saved target. The same stdio interface can reach different HTTP-backed instances; configured loopback instance checks also differ from the implicit local default. A tool name, successful status, or returned sentence cannot alone establish the expected dataset. This article does not run crystal mcp or access a real knowledge base.
Check boundaries with two in-memory servers
Install npm install --ignore-scripts --save-exact @modelcontextprotocol/sdk@1.30.0 zod@4.6.2 in an independent directory. Save lesson.mjs and run Node 24+. Two real SDK clients and servers use InMemoryTransport, with temporary string state only. There is no stdio subprocess, HTTP, model, or actual ChatCrystal. allowedCall is a hand-written teaching host policy, rather than an AI host’s permission implementation.
import assert from 'node:assert/strict';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { z } from 'zod';
async function peer(instance) {
let value = instance;
let reads = 0;
const server = new McpServer({ name: 'fixture-' + instance, version: '1.0.0' });
server.registerTool(
'read_fixture',
{
description: 'Read one synthetic value in this instance.',
inputSchema: { id: z.number() },
annotations: { readOnlyHint: true },
},
async () => {
reads += 1;
return { content: [{ type: 'text', text: value }] };
}
);
server.registerTool(
'write_fixture',
{
description: 'Replace one temporary in-memory fixture value.',
inputSchema: { value: z.string().min(1) },
annotations: { readOnlyHint: false },
},
async (input) => {
value = input.value;
return { content: [{ type: 'text', text: value }] };
}
);
const client = new Client({ name: 'fixture-client', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
return {
client,
server,
get reads() {
return reads;
},
};
}
async function allowedCall(client, name, args, allowed) {
if (!allowed.has(name)) throw new Error('blocked by teaching host policy');
return client.callTool({ name, arguments: args });
}
const a = await peer('A');
const b = await peer('B');
try {
const names = (await a.client.listTools()).tools.map((tool) => tool.name).sort();
assert.deepEqual(names, ['read_fixture', 'write_fixture']);
assert.deepEqual((await b.client.listTools()).tools.map((tool) => tool.name).sort(), names);
const allowed = new Set(['read_fixture']);
const first = await allowedCall(a.client, 'read_fixture', { id: 1 }, allowed);
const second = await allowedCall(b.client, 'read_fixture', { id: 1 }, allowed);
assert.equal(first.content[0].text, 'A');
assert.equal(second.content[0].text, 'B');
const before = a.reads;
const bad = await a.client.callTool({ name: 'read_fixture', arguments: { id: 'one' } });
assert.equal(bad.isError, true);
assert.equal(a.reads, before);
await assert.rejects(
() => allowedCall(a.client, 'write_fixture', { value: 'changed' }, allowed),
/blocked by teaching host policy/
);
assert.equal(
(await a.client.callTool({ name: 'read_fixture', arguments: { id: 1 } })).content[0].text,
'A'
);
await a.client.callTool({ name: 'write_fixture', arguments: { value: 'changed' } });
assert.equal(
(await a.client.callTool({ name: 'read_fixture', arguments: { id: 1 } })).content[0].text,
'changed'
);
console.log('instance identity, schema errors, and host policy boundaries pass');
} finally {
await a.client.close();
await b.client.close();
await a.server.close();
await b.server.close();
}
Both instances list the same names but return A and B. An invalid id produces isError without increasing the read handler count. The teaching policy blocks a write; a direct SDK call can still change the temporary value. Behavior annotations and schemas are not business authorization.
Change the second instance’s expected B to A and its assertion fails. Remove the allowlist check and the expected-rejection assertion fails. Restore each to pass. These checks establish the specified fixture, not universal host support for this SDK revision or policy.
Acceptance continues after discovery
The tool specification describes annotations as behavior metadata and requires care with untrusted sources. Even a tool marked read-only needs its actual handler, connection permissions, and returned data checked. Valid arguments do not establish relevant retrieval, accurate content, or an authorized write.
Verify initialization and version, discovery, target instance, schema rejection, authentication, business rejection, output, and shutdown separately. For writes, also check repeated calls and state after failure. MCP provides a reusable interface; reliability requires concrete evidence across these boundaries. Continue with MCP setup when configuring a real connection.