tiny-stdio-mcp-server 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # tiny-stdio-mcp-server
2
+
3
+ Minimal [Model Context Protocol](https://modelcontextprotocol.io) server for Node.js. Zero runtime dependencies, type-safe tool definitions, rich content helpers for images/audio/files.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install tiny-stdio-mcp-server
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```ts
14
+ import { createServer, defineSchema } from "tiny-stdio-mcp-server";
15
+
16
+ const schema = defineSchema({
17
+ text: { type: "string", description: "Text to reverse" },
18
+ });
19
+
20
+ createServer({ name: "my-server", version: "1.0.0" })
21
+ .tool("reverse", "Reverse a string", schema, ({ text }) => {
22
+ return text.split("").reverse().join("");
23
+ })
24
+ .listen();
25
+ ```
26
+
27
+ Run it:
28
+
29
+ ```sh
30
+ node my-server.js
31
+ ```
32
+
33
+ Any MCP client (Claude Code, Codex, etc.) can connect to it over stdio.
34
+
35
+ ## API
36
+
37
+ ### `createServer(options)`
38
+
39
+ Creates a new MCP server.
40
+
41
+ ```ts
42
+ const server = createServer({ name: "my-server", version: "1.0.0" });
43
+ ```
44
+
45
+ ### `.tool(name, description, schema, handler)`
46
+
47
+ Register a tool. The handler receives typed args matching the schema and returns a string, content helper, or array of either.
48
+
49
+ ```ts
50
+ const schema = defineSchema({
51
+ query: { type: "string", description: "Search query" },
52
+ limit: { type: "number", description: "Max results", optional: true },
53
+ });
54
+
55
+ server.tool("search", "Search for things", schema, async ({ query, limit }) => {
56
+ // `query` is string, `limit` is number | undefined
57
+ return `Found results for: ${query}`;
58
+ });
59
+ ```
60
+
61
+ ### `.listen()`
62
+
63
+ Start listening on stdin/stdout (standard MCP stdio transport).
64
+
65
+ ```ts
66
+ await server.listen();
67
+ ```
68
+
69
+ ### `.connect(transport)`
70
+
71
+ Connect to a custom readable/writable stream pair.
72
+
73
+ ```ts
74
+ await server.connect({ readable: process.stdin, writable: process.stdout });
75
+ ```
76
+
77
+ ### `.connectSDK(transport)`
78
+
79
+ Connect using an SDK-compatible in-memory transport (for testing).
80
+
81
+ ```ts
82
+ await server.connectSDK(sdkTransport);
83
+ ```
84
+
85
+ ### `.removeTool(name)` / `.notifyToolsChanged()`
86
+
87
+ Dynamically add/remove tools at runtime and notify connected clients.
88
+
89
+ ## `defineSchema(definition)`
90
+
91
+ Type-safe schema builder. Returns a JSON Schema object with inferred TypeScript types.
92
+
93
+ ```ts
94
+ const schema = defineSchema({
95
+ name: { type: "string", description: "User name" },
96
+ age: { type: "number", description: "User age", optional: true },
97
+ });
98
+ // Handler receives: { name: string; age?: number }
99
+ ```
100
+
101
+ Supported types: `string`, `number`, `boolean`, `object`, `array`.
102
+
103
+ ## Content helpers
104
+
105
+ Tool handlers can return rich content beyond plain text.
106
+
107
+ ### Images
108
+
109
+ ```ts
110
+ import { Image } from "tiny-stdio-mcp-server";
111
+
112
+ server.tool("screenshot", "Take a screenshot", schema, async () => {
113
+ return Image.fromBase64(base64Data, "image/png");
114
+ // or: await Image.fromUrl("https://example.com/image.png")
115
+ // or: Image.fromBytes(uint8Array)
116
+ });
117
+ ```
118
+
119
+ Supported formats: PNG, JPEG, GIF, WebP.
120
+
121
+ ### Audio
122
+
123
+ ```ts
124
+ import { Audio } from "tiny-stdio-mcp-server";
125
+
126
+ server.tool("speak", "Text to speech", schema, async () => {
127
+ return Audio.fromBase64(base64Data, "audio/mpeg");
128
+ // or: await Audio.fromUrl("https://example.com/audio.mp3")
129
+ // or: Audio.fromBytes(uint8Array, "mp3")
130
+ });
131
+ ```
132
+
133
+ Supported formats: MP3, WAV, OGG, M4A.
134
+
135
+ ### Files
136
+
137
+ ```ts
138
+ import { File } from "tiny-stdio-mcp-server";
139
+
140
+ server.tool("export", "Export data", schema, async () => {
141
+ return File.fromText(csvContent, "text/csv");
142
+ // or: File.fromBytes(uint8Array, "application/pdf")
143
+ // or: await File.fromUrl("https://example.com/report.pdf")
144
+ });
145
+ ```
146
+
147
+ ### Mixed content
148
+
149
+ Return arrays to send multiple content blocks:
150
+
151
+ ```ts
152
+ server.tool("analyze", "Analyze image", schema, async () => {
153
+ const image = await Image.fromUrl(url);
154
+ return [image, "Analysis complete: found 3 objects"];
155
+ });
156
+ ```
157
+
158
+ ## Testing
159
+
160
+ Use `createTestPair` with the official MCP SDK for in-memory testing:
161
+
162
+ ```ts
163
+ import { createTestPair } from "tiny-stdio-mcp-server/testing";
164
+
165
+ const server = createServer({ name: "test", version: "1.0.0" })
166
+ .tool("ping", "Ping", defineSchema({}), () => "pong");
167
+
168
+ const { client, cleanup } = await createTestPair(server);
169
+
170
+ const result = await client.callTool({ name: "ping", arguments: {} });
171
+ // result.content === [{ type: "text", text: "pong" }]
172
+
173
+ await cleanup();
174
+ ```
175
+
176
+ Requires `@modelcontextprotocol/sdk` as a dev dependency.
177
+
178
+ ## License
179
+
180
+ MIT
@@ -6,5 +6,11 @@ export interface TextContent {
6
6
  text: string;
7
7
  }
8
8
  export type ContentBlock = TextContent | ImageContent | AudioContent | EmbeddedResource;
9
- export type ToolReturn = string | Image | Audio | File | ContentBlock | Array<string | Image | Audio | File | ContentBlock>;
9
+ type JsonPrimitive = string | number | boolean | null;
10
+ export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
11
+ export type JsonObject = {
12
+ [key: string]: JsonValue;
13
+ };
14
+ export type ToolReturn = undefined | JsonPrimitive | JsonObject | Image | Audio | File | ContentBlock | Array<undefined | JsonPrimitive | JsonObject | Image | Audio | File | ContentBlock>;
10
15
  export declare function toContentBlocks(result: ToolReturn): ContentBlock[];
16
+ export {};
@@ -2,8 +2,11 @@ import { Image } from "./image.js";
2
2
  import { Audio } from "./audio.js";
3
3
  import { File } from "./file.js";
4
4
  function convertSingleValue(value) {
5
- if (typeof value === "string") {
6
- return { type: "text", text: value };
5
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
6
+ return { type: "text", text: String(value) };
7
+ }
8
+ if (value === null) {
9
+ return { type: "text", text: "null" };
7
10
  }
8
11
  if (value instanceof Image) {
9
12
  return value.toContentBlock();
@@ -14,12 +17,26 @@ function convertSingleValue(value) {
14
17
  if (value instanceof File) {
15
18
  return value.toContentBlock();
16
19
  }
17
- // Already a ContentBlock
18
- return value;
20
+ if (isContentBlock(value)) {
21
+ return value;
22
+ }
23
+ return { type: "text", text: JSON.stringify(value) };
19
24
  }
20
25
  export function toContentBlocks(result) {
26
+ if (result === undefined) {
27
+ return [];
28
+ }
21
29
  if (Array.isArray(result)) {
22
30
  return result.flatMap((item) => toContentBlocks(item));
23
31
  }
24
32
  return [convertSingleValue(result)];
25
33
  }
34
+ function isContentBlock(value) {
35
+ if (!("type" in value) || typeof value.type !== "string") {
36
+ return false;
37
+ }
38
+ return (value.type === "text" ||
39
+ value.type === "image" ||
40
+ value.type === "audio" ||
41
+ value.type === "resource");
42
+ }
package/dist/index.d.ts CHANGED
@@ -2,10 +2,8 @@ export { createServer } from "./server.js";
2
2
  export type { Server } from "./server.js";
3
3
  export { defineSchema } from "./schema.js";
4
4
  export type { TypedSchema } from "./schema.js";
5
- export { createTestPair } from "./testing.js";
6
- export type { TestPair } from "./testing.js";
7
5
  export { Image, Audio, File, toContentBlocks, fileTypeFromBuffer, } from "./content/index.js";
8
6
  export type { ImageContent, AudioContent, EmbeddedResource, TextResourceContents, BlobResourceContents, ContentBlock, TextContent, FileTypeResult, } from "./content/index.js";
9
7
  export type { ToolReturn } from "./content/index.js";
10
- export type { ServerOptions, ToolHandler, ToolDefinition, Tool, CallToolResult, ContentItem, JSONSchema, JSONSchemaProperty, Transport, SDKTransport, JSONRPCRequest, JSONRPCResponse, JSONRPCError, JSONRPCMessage, JSONRPCNotification, InitializeResult, } from "./types.js";
11
- export { JSON_RPC_ERROR_CODES } from "./types.js";
8
+ export type { ServerOptions, ToolHandler, ToolDefinition, Tool, CallToolResult, HandleResult, ContentItem, JSONSchema, JSONSchemaProperty, Transport, SDKTransport, JSONRPCRequest, JSONRPCResponse, JSONRPCError, JSONRPCMessage, JSONRPCNotification, InitializeResult, } from "./types.js";
9
+ export { JSON_RPC_ERROR_CODES, ToolError } from "./types.js";
package/dist/index.js CHANGED
@@ -2,8 +2,6 @@
2
2
  export { createServer } from "./server.js";
3
3
  // Schema
4
4
  export { defineSchema } from "./schema.js";
5
- // Testing utilities
6
- export { createTestPair } from "./testing.js";
7
5
  // Content helpers
8
6
  export { Image, Audio, File, toContentBlocks, fileTypeFromBuffer, } from "./content/index.js";
9
- export { JSON_RPC_ERROR_CODES } from "./types.js";
7
+ export { JSON_RPC_ERROR_CODES, ToolError } from "./types.js";
package/dist/server.d.ts CHANGED
@@ -1,9 +1,11 @@
1
- import type { ServerOptions, ToolHandler, Transport, SDKTransport } from "./types.js";
1
+ import type { ServerOptions, ToolHandler, HandleResult, Transport, SDKTransport, JSONRPCNotification } from "./types.js";
2
2
  import type { TypedSchema } from "./schema.js";
3
3
  export interface Server {
4
4
  tool<T>(name: string, description: string, inputSchema: TypedSchema<T>, handler: ToolHandler<T>): Server;
5
+ onNotification(listener: (notification: JSONRPCNotification) => void): () => void;
5
6
  removeTool(name: string): boolean;
6
7
  notifyToolsChanged(): Promise<void>;
8
+ handleMessage(method: string, params?: Record<string, unknown>): Promise<HandleResult>;
7
9
  listen(): Promise<void>;
8
10
  connect(transport: Transport): Promise<void>;
9
11
  connectSDK(transport: SDKTransport): Promise<void>;
package/dist/server.js CHANGED
@@ -1,14 +1,13 @@
1
1
  import * as readline from "readline";
2
- import { JSON_RPC_ERROR_CODES } from "./types.js";
2
+ import { JSON_RPC_ERROR_CODES, ToolError } from "./types.js";
3
3
  import { parseMessage, formatSuccessResponse, formatErrorResponse, } from "./jsonrpc.js";
4
4
  import { toContentBlocks } from "./content/convert.js";
5
5
  const PROTOCOL_VERSION = "2025-11-25";
6
6
  export function createServer(options) {
7
7
  const tools = new Map();
8
+ const notificationListeners = new Set();
8
9
  let initialized = false;
9
- let activeTransport = null;
10
- let activeSDKTransport = null;
11
- const handleRequest = async (method, params) => {
10
+ const handleMessage = async (method, params) => {
12
11
  // Allow ping and initialize before initialization
13
12
  if (method === "ping") {
14
13
  return { result: {} };
@@ -77,10 +76,20 @@ export function createServer(options) {
77
76
  }
78
77
  try {
79
78
  const handlerResult = await tool.handler(toolArgs);
80
- const result = { content: toContentBlocks(handlerResult) };
79
+ const result = isCallToolResult(handlerResult)
80
+ ? handlerResult
81
+ : { content: toContentBlocks(handlerResult) };
81
82
  return { result };
82
83
  }
83
84
  catch (err) {
85
+ if (err instanceof ToolError) {
86
+ return {
87
+ error: {
88
+ code: err.code,
89
+ message: err.message,
90
+ },
91
+ };
92
+ }
84
93
  const errorMessage = err instanceof Error ? err.message : String(err);
85
94
  const result = {
86
95
  content: [{ type: "text", text: `Error: ${errorMessage}` }],
@@ -103,7 +112,7 @@ export function createServer(options) {
103
112
  return;
104
113
  }
105
114
  const { request, isNotification } = parsed;
106
- const { result, error } = await handleRequest(request.method, request.params);
115
+ const { result, error } = await server.handleMessage(request.method, request.params);
107
116
  if (isNotification) {
108
117
  return;
109
118
  }
@@ -115,16 +124,13 @@ export function createServer(options) {
115
124
  write(formatSuccessResponse(requestWithId.id, result) + "\n");
116
125
  }
117
126
  };
118
- const sendNotification = async (method) => {
127
+ const broadcastNotification = (method) => {
119
128
  const notification = {
120
129
  jsonrpc: "2.0",
121
130
  method,
122
131
  };
123
- if (activeSDKTransport) {
124
- await activeSDKTransport.send(notification);
125
- }
126
- else if (activeTransport) {
127
- activeTransport.writable.write(JSON.stringify(notification) + "\n");
132
+ for (const listener of notificationListeners) {
133
+ listener(notification);
128
134
  }
129
135
  };
130
136
  const server = {
@@ -137,14 +143,21 @@ export function createServer(options) {
137
143
  });
138
144
  return server;
139
145
  },
146
+ onNotification(listener) {
147
+ notificationListeners.add(listener);
148
+ return () => {
149
+ notificationListeners.delete(listener);
150
+ };
151
+ },
140
152
  removeTool(name) {
141
153
  return tools.delete(name);
142
154
  },
143
155
  async notifyToolsChanged() {
144
156
  if (initialized) {
145
- await sendNotification("notifications/tools/list_changed");
157
+ broadcastNotification("notifications/tools/list_changed");
146
158
  }
147
159
  },
160
+ handleMessage,
148
161
  async listen() {
149
162
  return server.connect({
150
163
  readable: process.stdin,
@@ -152,9 +165,10 @@ export function createServer(options) {
152
165
  });
153
166
  },
154
167
  async connect(transport) {
155
- activeTransport = transport;
156
- activeSDKTransport = null;
157
168
  return new Promise((resolve) => {
169
+ const unsubscribe = server.onNotification((notification) => {
170
+ transport.writable.write(`${JSON.stringify(notification)}\n`);
171
+ });
158
172
  const rl = readline.createInterface({
159
173
  input: transport.readable,
160
174
  crlfDelay: Infinity,
@@ -163,15 +177,16 @@ export function createServer(options) {
163
177
  processLine(line, (data) => transport.writable.write(data));
164
178
  });
165
179
  rl.on("close", () => {
166
- activeTransport = null;
180
+ unsubscribe();
167
181
  resolve();
168
182
  });
169
183
  });
170
184
  },
171
185
  async connectSDK(transport) {
172
- activeSDKTransport = transport;
173
- activeTransport = null;
174
186
  return new Promise((resolve) => {
187
+ const unsubscribe = server.onNotification((notification) => {
188
+ void transport.send(notification);
189
+ });
175
190
  transport.onmessage = async (message) => {
176
191
  // Ignore responses (we only handle requests/notifications)
177
192
  if (!("method" in message)) {
@@ -179,11 +194,11 @@ export function createServer(options) {
179
194
  }
180
195
  // Handle notifications (no id) - don't respond
181
196
  if (!("id" in message) || message.id === undefined) {
182
- await handleRequest(message.method, message.params);
197
+ await server.handleMessage(message.method, message.params);
183
198
  return;
184
199
  }
185
200
  const request = message;
186
- const { result, error } = await handleRequest(request.method, request.params);
201
+ const { result, error } = await server.handleMessage(request.method, request.params);
187
202
  if (error) {
188
203
  const response = {
189
204
  jsonrpc: "2.0",
@@ -202,7 +217,7 @@ export function createServer(options) {
202
217
  }
203
218
  };
204
219
  transport.onclose = () => {
205
- activeSDKTransport = null;
220
+ unsubscribe();
206
221
  resolve();
207
222
  };
208
223
  transport.start();
@@ -211,3 +226,9 @@ export function createServer(options) {
211
226
  };
212
227
  return server;
213
228
  }
229
+ function isCallToolResult(value) {
230
+ if (typeof value !== "object" || value === null || !("content" in value)) {
231
+ return false;
232
+ }
233
+ return Array.isArray(value.content);
234
+ }
package/dist/types.d.ts CHANGED
@@ -22,6 +22,10 @@ export declare const JSON_RPC_ERROR_CODES: {
22
22
  readonly INVALID_PARAMS: -32602;
23
23
  readonly INTERNAL_ERROR: -32603;
24
24
  };
25
+ export declare class ToolError extends Error {
26
+ readonly code: number;
27
+ constructor(code: number, message: string);
28
+ }
25
29
  export interface ToolsCapability {
26
30
  listChanged?: boolean;
27
31
  }
@@ -44,6 +48,13 @@ export interface CallToolResult {
44
48
  content: ContentItem[];
45
49
  isError?: boolean;
46
50
  }
51
+ export interface HandleResult {
52
+ result?: unknown;
53
+ error?: {
54
+ code: number;
55
+ message: string;
56
+ };
57
+ }
47
58
  export type ContentItem = {
48
59
  type: "text";
49
60
  text: string;
@@ -80,11 +91,8 @@ export interface ServerOptions {
80
91
  name: string;
81
92
  version: string;
82
93
  }
83
- import type { Image } from "./content/image.js";
84
- import type { Audio } from "./content/audio.js";
85
- import type { File } from "./content/file.js";
86
- export type ToolReturn = string | Image | Audio | File | ContentItem | Array<string | Image | Audio | File | ContentItem>;
87
- export type ToolHandler<T = Record<string, unknown>> = (args: T) => Promise<ToolReturn> | ToolReturn;
94
+ import type { ToolReturn } from "./content/index.js";
95
+ export type ToolHandler<T = Record<string, unknown>> = (args: T) => Promise<ToolReturn | CallToolResult> | ToolReturn | CallToolResult;
88
96
  export interface ToolDefinition<T = Record<string, unknown>> {
89
97
  name: string;
90
98
  description: string;
package/dist/types.js CHANGED
@@ -4,5 +4,13 @@ export const JSON_RPC_ERROR_CODES = {
4
4
  INVALID_REQUEST: -32600,
5
5
  METHOD_NOT_FOUND: -32601,
6
6
  INVALID_PARAMS: -32602,
7
- INTERNAL_ERROR: -32603,
7
+ INTERNAL_ERROR: -32603
8
8
  };
9
+ export class ToolError extends Error {
10
+ code;
11
+ constructor(code, message) {
12
+ super(message);
13
+ this.code = code;
14
+ this.name = "ToolError";
15
+ }
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tiny-stdio-mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Minimal MCP server over stdio with typed tools and rich content helpers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -9,6 +9,14 @@
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
11
  "import": "./dist/index.js"
12
+ },
13
+ "./jsonrpc": {
14
+ "types": "./dist/jsonrpc.d.ts",
15
+ "import": "./dist/jsonrpc.js"
16
+ },
17
+ "./testing": {
18
+ "types": "./dist/testing.d.ts",
19
+ "import": "./dist/testing.js"
12
20
  }
13
21
  },
14
22
  "scripts": {
@@ -16,15 +24,16 @@
16
24
  "prepublishOnly": "tsc"
17
25
  },
18
26
  "files": [
19
- "dist"
27
+ "dist",
28
+ "!dist/testing.*"
20
29
  ],
21
30
  "engines": {
22
- "node": ">=18"
31
+ "node": ">=20"
23
32
  },
24
33
  "repository": {
25
34
  "type": "git",
26
35
  "url": "https://github.com/poe-platform/poe-code.git",
27
- "directory": "packages/tiny-mcp-server"
36
+ "directory": "packages/tiny-stdio-mcp-server"
28
37
  },
29
38
  "license": "MIT",
30
39
  "keywords": [
package/dist/testing.d.ts DELETED
@@ -1,7 +0,0 @@
1
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
- import type { Server } from "./server.js";
3
- export interface TestPair {
4
- client: Client;
5
- cleanup: () => Promise<void>;
6
- }
7
- export declare function createTestPair(server: Server): Promise<TestPair>;
package/dist/testing.js DELETED
@@ -1,20 +0,0 @@
1
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
- import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3
- export async function createTestPair(server) {
4
- const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
5
- const client = new Client({
6
- name: "test-client",
7
- version: "1.0.0",
8
- });
9
- // Start server connection (runs in background)
10
- const serverPromise = server.connectSDK(serverTransport);
11
- // Connect client
12
- await client.connect(clientTransport);
13
- const cleanup = async () => {
14
- await client.close();
15
- await clientTransport.close();
16
- await serverTransport.close();
17
- await serverPromise;
18
- };
19
- return { client, cleanup };
20
- }