tiny-stdio-mcp-server 0.1.0 → 0.1.1

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: {} };
@@ -81,6 +80,14 @@ export function createServer(options) {
81
80
  return { result };
82
81
  }
83
82
  catch (err) {
83
+ if (err instanceof ToolError) {
84
+ return {
85
+ error: {
86
+ code: err.code,
87
+ message: err.message,
88
+ },
89
+ };
90
+ }
84
91
  const errorMessage = err instanceof Error ? err.message : String(err);
85
92
  const result = {
86
93
  content: [{ type: "text", text: `Error: ${errorMessage}` }],
@@ -103,7 +110,7 @@ export function createServer(options) {
103
110
  return;
104
111
  }
105
112
  const { request, isNotification } = parsed;
106
- const { result, error } = await handleRequest(request.method, request.params);
113
+ const { result, error } = await server.handleMessage(request.method, request.params);
107
114
  if (isNotification) {
108
115
  return;
109
116
  }
@@ -115,16 +122,13 @@ export function createServer(options) {
115
122
  write(formatSuccessResponse(requestWithId.id, result) + "\n");
116
123
  }
117
124
  };
118
- const sendNotification = async (method) => {
125
+ const broadcastNotification = (method) => {
119
126
  const notification = {
120
127
  jsonrpc: "2.0",
121
128
  method,
122
129
  };
123
- if (activeSDKTransport) {
124
- await activeSDKTransport.send(notification);
125
- }
126
- else if (activeTransport) {
127
- activeTransport.writable.write(JSON.stringify(notification) + "\n");
130
+ for (const listener of notificationListeners) {
131
+ listener(notification);
128
132
  }
129
133
  };
130
134
  const server = {
@@ -137,14 +141,21 @@ export function createServer(options) {
137
141
  });
138
142
  return server;
139
143
  },
144
+ onNotification(listener) {
145
+ notificationListeners.add(listener);
146
+ return () => {
147
+ notificationListeners.delete(listener);
148
+ };
149
+ },
140
150
  removeTool(name) {
141
151
  return tools.delete(name);
142
152
  },
143
153
  async notifyToolsChanged() {
144
154
  if (initialized) {
145
- await sendNotification("notifications/tools/list_changed");
155
+ broadcastNotification("notifications/tools/list_changed");
146
156
  }
147
157
  },
158
+ handleMessage,
148
159
  async listen() {
149
160
  return server.connect({
150
161
  readable: process.stdin,
@@ -152,9 +163,10 @@ export function createServer(options) {
152
163
  });
153
164
  },
154
165
  async connect(transport) {
155
- activeTransport = transport;
156
- activeSDKTransport = null;
157
166
  return new Promise((resolve) => {
167
+ const unsubscribe = server.onNotification((notification) => {
168
+ transport.writable.write(`${JSON.stringify(notification)}\n`);
169
+ });
158
170
  const rl = readline.createInterface({
159
171
  input: transport.readable,
160
172
  crlfDelay: Infinity,
@@ -163,15 +175,16 @@ export function createServer(options) {
163
175
  processLine(line, (data) => transport.writable.write(data));
164
176
  });
165
177
  rl.on("close", () => {
166
- activeTransport = null;
178
+ unsubscribe();
167
179
  resolve();
168
180
  });
169
181
  });
170
182
  },
171
183
  async connectSDK(transport) {
172
- activeSDKTransport = transport;
173
- activeTransport = null;
174
184
  return new Promise((resolve) => {
185
+ const unsubscribe = server.onNotification((notification) => {
186
+ void transport.send(notification);
187
+ });
175
188
  transport.onmessage = async (message) => {
176
189
  // Ignore responses (we only handle requests/notifications)
177
190
  if (!("method" in message)) {
@@ -179,11 +192,11 @@ export function createServer(options) {
179
192
  }
180
193
  // Handle notifications (no id) - don't respond
181
194
  if (!("id" in message) || message.id === undefined) {
182
- await handleRequest(message.method, message.params);
195
+ await server.handleMessage(message.method, message.params);
183
196
  return;
184
197
  }
185
198
  const request = message;
186
- const { result, error } = await handleRequest(request.method, request.params);
199
+ const { result, error } = await server.handleMessage(request.method, request.params);
187
200
  if (error) {
188
201
  const response = {
189
202
  jsonrpc: "2.0",
@@ -202,7 +215,7 @@ export function createServer(options) {
202
215
  }
203
216
  };
204
217
  transport.onclose = () => {
205
- activeSDKTransport = null;
218
+ unsubscribe();
206
219
  resolve();
207
220
  };
208
221
  transport.start();
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,10 +91,7 @@ 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>;
94
+ import type { ToolReturn } from "./content/index.js";
87
95
  export type ToolHandler<T = Record<string, unknown>> = (args: T) => Promise<ToolReturn> | ToolReturn;
88
96
  export interface ToolDefinition<T = Record<string, unknown>> {
89
97
  name: 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.1",
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
- }