Skip to content
曾亮懿 ZengLiangYi Blog

A Minimal MCP Server: Register Tools & Verify Stdio Calls

Build an offline addition tool with TypeScript SDK, verify protocol discovery and calls, and inspect ChatCrystal adapter integration boundaries.

曾亮懿

曾亮懿

4 min read
Table of contents Show Hide

Starting a process does not establish an MCP handshake, and listing a tool does not establish argument validation. This article builds an addition tool that accesses no model, network, or knowledge base, then uses a real SDK client over stdio to verify initialization, discovery, and calls. ChatCrystal’s implementation then illustrates the responsibilities of the protocol adapter.

Pin the lesson version and communication boundary

ChatCrystal’s pinned dependency manifest and lockfile resolve @modelcontextprotocol/sdk to 1.30.0. This lesson uses that version and Zod 4.6.2 on Node 24+. Package names and APIs change; do not combine these imports with examples from another major version.

An MCP host organizes the AI application, a client connects to a server, and a server provides capabilities such as tools. A transport is the communication channel. Stdio sends newline-delimited JSON-RPC messages through a child process’s stdin/stdout. It requires no HTTP listening port, but that does not prevent handlers from accessing a network. See the stdio transport specification.

Reserve stdout for protocol messages and send diagnostics to stderr. server.connect attaches transport and handlers; the client still needs to send initialization. A server waiting silently when started alone is not necessarily failing to start.

Write a server with one tool

Install the lesson dependencies in a new temporary directory:

npm init -y
npm install --ignore-scripts --no-audit --no-fund @modelcontextprotocol/sdk@1.30.0 zod@4.6.2

Save server.mjs. This is an independent teaching server, not ChatCrystal’s entry point. Its handler only adds numbers.

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

const server = new McpServer({ name: 'lesson-add', version: '1.0.0' });
server.registerTool(
  'add',
  {
    description: 'Add two finite numbers in this offline lesson.',
    inputSchema: { a: z.number(), b: z.number() },
  },
  async ({ a, b }) => ({ content: [{ type: 'text', text: String(a + b) }] })
);
await server.connect(new StdioServerTransport());
console.error('lesson server connected to stdio');

The tool name identifies calls, description explains purpose, inputSchema defines arguments, and the handler returns content. Descriptions help readers and tool selection, but they are not required for validation to work. No measurement here guarantees better selection accuracy.

Use a client to establish handshake and argument errors

Save client.mjs in the same directory and run node client.mjs. The client starts the server child process; you do not need to start another instance manually.

import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';

const client = new Client({ name: 'lesson-client', version: '1.0.0' });
const transport = new StdioClientTransport({
  command: process.execPath,
  args: [fileURLToPath(new URL('./server.mjs', import.meta.url))],
});
try {
  await client.connect(transport);
  const { tools } = await client.listTools();
  assert.deepEqual(
    tools.map((tool) => tool.name),
    ['add']
  );
  const good = await client.callTool({ name: 'add', arguments: { a: 4, b: 3 } });
  assert.equal(good.content[0].text, '7');
  const bad = await client.callTool({ name: 'add', arguments: { a: 'four', b: 3 } });
  assert.equal(bad.isError, true);
  console.log('initialize, list, valid call, and invalid arguments pass');
} finally {
  await client.close();
  await transport.close();
}

Change the expected result from '7' to '8' and the program fails; restore it and it passes. The invalid-input case establishes that a string fails the numeric schema, not that a language model corrects arguments automatically. This checks only the SDK client and teaching server. It does not test an AI host’s executable paths, permission interface, or automatic tool selection.

ChatCrystal’s MCP layer adapts HTTP APIs

The current MCP implementation creates CrystalClient and calls status before registering seven tools: search_knowledge, get_note, list_notes, get_relations, recall_for_task, validate_task_memory, and write_task_memory. Its server version comes from the CLI package rather than a hard-coded early version.

Handlers delegate to existing APIs through an HTTP client and serialize results as text content. Memory tools reuse separate request shapes. The MCP process does not directly hold the knowledge-base database, but the client does retain process-local connection-check state. Restarting the protocol process does not guarantee that every in-flight write is neither lost nor repeated.

The crystal mcp command resolves the base URL and token. The connection target determines which knowledge base tools access. Identical tool names do not establish identical instances. See MCP integration for actual connection setup. This article does not execute that command, keeping the lesson from accessing real data or starting a background service.

Define failures before extending tools

Before adding a tool, specify its input, reading or writing scope, successful output, and failure categories. An input schema is not business authorization, and a description does not establish caller permissions. An HTTP-backed handler also needs to distinguish connection failures, authentication rejection, business errors, and protocol errors instead of wrapping every case as successful text.

The lesson is complete when initialization succeeds, discovery lists the expected tool, valid input produces the expected result, invalid input is rejected, and closing the client ends the child process. Selecting the tool at an appropriate time needs a separate evaluation with a specific task and host.

Share:

Related Posts

MCP Boundaries: Discovery, Identity, and Call Permissions

Examine Model Context Protocol boundaries: tool discovery, server instance tracking, and execution permissions in reviewable developer environments.

曾亮懿 曾亮懿
4 min read
#ChatCrystal #MCP #Node.js

AI Conversation Formats: JSONL Events vs SQLite Snapshots

Compare 5 AI adapter formats: separate JSONL events from SQLite keys, and inspect indexed, orphan, and unknown-version chat transcripts in memory stores.

曾亮懿 曾亮懿
4 min read
#ChatCrystal #Node.js #SQLite

How Claude Code, Cursor, Copilot, and Trae Store Chats on Disk

Compare Claude Code, Cursor, Copilot, and Trae storage paths. Distinguish raw copies from exports, and verify migration retention with test manifests.

曾亮懿 曾亮懿
4 min read
#ChatCrystal #Node.js #SQLite

From writing to code

Explore ChatCrystal, developer tools and daily learning notes on my GitHub. Questions about the implementation are welcome.