@czap/mcp-server 0.1.0

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/src/http.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * MCP HTTP transport — POST /, body is a JSON-RPC 2.0 request.
3
+ * Pure JSON-RPC handler logic lives here. The Node `createServer` +
4
+ * `listen` + `SIGINT`-await bootstrap lives in `./http-server.ts`,
5
+ * excluded from coverage because Windows can't deliver SIGINT to
6
+ * spawned subprocesses cleanly.
7
+ *
8
+ * Routes incoming bodies through `JsonRpcServer.parse` for the same
9
+ * conformance properties as the stdio transport: parse errors → -32700,
10
+ * notifications produce no body, batches handled per §6.
11
+ *
12
+ * @module
13
+ */
14
+
15
+ import { dispatch } from './dispatch.js';
16
+ import {
17
+ JsonRpcServer,
18
+ type JsonRpcResponse,
19
+ type ParseOutcome,
20
+ errorResponse,
21
+ ParseError,
22
+ InvalidRequest,
23
+ } from './jsonrpc.js';
24
+
25
+ /**
26
+ * Resolve a parse outcome to its wire response, or `null` if the spec
27
+ * requires no response (notification, or pure-notification batch).
28
+ *
29
+ * Exported so unit tests can exercise every branch without spinning up a
30
+ * real HTTP server (Windows can't deliver SIGINT to subprocess for the
31
+ * full integration path).
32
+ */
33
+ export async function respond(
34
+ outcome: ParseOutcome,
35
+ ): Promise<JsonRpcResponse | readonly JsonRpcResponse[] | null> {
36
+ switch (outcome.kind) {
37
+ case 'parse-error':
38
+ return errorResponse(null, ParseError, 'Parse error');
39
+ case 'invalid-request':
40
+ return errorResponse(outcome.id, InvalidRequest, 'Invalid Request');
41
+ case 'notification':
42
+ await dispatch(outcome.message);
43
+ return null;
44
+ case 'request':
45
+ return dispatch(outcome.message);
46
+ case 'batch': {
47
+ const responses: JsonRpcResponse[] = [];
48
+ for (const sub of outcome.outcomes) {
49
+ const r = await respond(sub);
50
+ if (r === null) continue;
51
+ if (Array.isArray(r)) responses.push(...r);
52
+ else responses.push(r as JsonRpcResponse);
53
+ }
54
+ return responses.length > 0 ? responses : null;
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Pure wire handler — accepts a JSON-RPC body string, returns the response
61
+ * envelope (or null for notification-only batches). Drives the HTTP server's
62
+ * request path; extracted so unit tests cover every parse-outcome branch
63
+ * without spawning a server process.
64
+ */
65
+ export async function handleRequest(
66
+ body: string,
67
+ ): Promise<JsonRpcResponse | readonly JsonRpcResponse[] | null> {
68
+ const outcome = JsonRpcServer.parse(body);
69
+ return respond(outcome);
70
+ }
71
+
72
+ // Re-export the bootstrap so callers (start.ts) can keep using `import { runHttp } from './http.js'`.
73
+ // The bootstrap module also installs a top-level direct-invoke guard for
74
+ // the integration spawn entrypoint (`tsx packages/mcp-server/src/http.ts ...`).
75
+ // We import that module for its side effect so the spawn keeps working.
76
+ import './http-server.js';
77
+
78
+ export { runHttp } from './http-server.js';
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ /** `@czap/mcp-server` — MCP bridge for **LiteShip**; forwards tools to the `czap` CLI + capsule factory. */
2
+
3
+ export { start } from './start.js';
4
+ export type { StartOpts } from './start.js';
5
+ export { listTools, dispatchToolCall, dispatch } from './dispatch.js';
6
+ export type { McpToolCall, McpToolResult } from './dispatch.js';
7
+ export { runStdio } from './stdio.js';
8
+ export { runHttp } from './http.js';
9
+
10
+ // JSON-RPC 2.0 kernel — reusable beyond MCP.
11
+ export {
12
+ JsonRpcServer,
13
+ jsonRpcServerCapsule,
14
+ parse,
15
+ errorResponse,
16
+ successResponse,
17
+ ParseError,
18
+ InvalidRequest,
19
+ MethodNotFound,
20
+ InvalidParams,
21
+ InternalError,
22
+ } from './jsonrpc.js';
23
+ export type {
24
+ JsonRpcId,
25
+ JsonRpcRequest,
26
+ JsonRpcNotification,
27
+ JsonRpcResponse,
28
+ JsonRpcSuccess,
29
+ JsonRpcErrorResponse,
30
+ ParseOutcome,
31
+ } from './jsonrpc.js';
package/src/jsonrpc.ts ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * JsonRpcServer — framework-free JSON-RPC 2.0 kernel.
3
+ *
4
+ * Parses incoming wire bytes, classifies them as Request | Notification |
5
+ * Batch | InvalidRequest | ParseError, and produces responses (or null
6
+ * for notifications, which MUST NOT receive a response per §4.1).
7
+ *
8
+ * Exposed as a `pureTransform` arm capsule `mcp.jsonrpc-server` so it
9
+ * appears in the manifest and can be reused by future JSON-RPC surfaces
10
+ * beyond MCP.
11
+ *
12
+ * Conformance: JSON-RPC 2.0 specification (https://www.jsonrpc.org/specification).
13
+ * §3 — `jsonrpc: "2.0"` required.
14
+ * §4 — Request vs Notification distinguished by presence of `id`.
15
+ * §4.1 — A Notification MUST NOT receive a Response.
16
+ * §4.2 — Parse errors MUST emit a Response with code -32700, id null.
17
+ * §5 — Response is `result` XOR `error`.
18
+ * §5.1 — Standard error codes.
19
+ * §6 — Batch: array of requests/notifications. Empty array → -32600.
20
+ *
21
+ * @module
22
+ */
23
+
24
+ import { Schema } from 'effect';
25
+ import { defineCapsule } from '@czap/core';
26
+
27
+ // ---------- JSON-RPC 2.0 types (wire-shape) ----------
28
+
29
+ /** Per §4: `id` is string, number, or null. Absent = notification. */
30
+ export type JsonRpcId = string | number | null;
31
+
32
+ /** A JSON-RPC 2.0 request (has `id`). */
33
+ export interface JsonRpcRequest {
34
+ readonly jsonrpc: '2.0';
35
+ readonly id: JsonRpcId;
36
+ readonly method: string;
37
+ readonly params?: readonly unknown[] | Record<string, unknown>;
38
+ }
39
+
40
+ /** A JSON-RPC 2.0 notification (no `id`). Per §4.1 MUST NOT be responded to. */
41
+ export interface JsonRpcNotification {
42
+ readonly jsonrpc: '2.0';
43
+ readonly method: string;
44
+ readonly params?: readonly unknown[] | Record<string, unknown>;
45
+ }
46
+
47
+ /** Successful response per §5. */
48
+ export interface JsonRpcSuccess {
49
+ readonly jsonrpc: '2.0';
50
+ readonly id: JsonRpcId;
51
+ readonly result: unknown;
52
+ }
53
+
54
+ /** Error response per §5 + §5.1. */
55
+ export interface JsonRpcErrorResponse {
56
+ readonly jsonrpc: '2.0';
57
+ readonly id: JsonRpcId;
58
+ readonly error: { readonly code: number; readonly message: string; readonly data?: unknown };
59
+ }
60
+
61
+ /** Either a success or error response. */
62
+ export type JsonRpcResponse = JsonRpcSuccess | JsonRpcErrorResponse;
63
+
64
+ // ---------- Standard error codes (§5.1) ----------
65
+
66
+ export const ParseError = -32700 as const;
67
+ export const InvalidRequest = -32600 as const;
68
+ export const MethodNotFound = -32601 as const;
69
+ export const InvalidParams = -32602 as const;
70
+ export const InternalError = -32603 as const;
71
+
72
+ // ---------- Parser output classification ----------
73
+
74
+ /** Discriminated union of every parse outcome the kernel produces. */
75
+ export type ParseOutcome =
76
+ | { readonly kind: 'request'; readonly message: JsonRpcRequest }
77
+ | { readonly kind: 'notification'; readonly message: JsonRpcNotification }
78
+ | { readonly kind: 'batch'; readonly outcomes: readonly ParseOutcome[] }
79
+ | { readonly kind: 'parse-error' }
80
+ | { readonly kind: 'invalid-request'; readonly id: JsonRpcId };
81
+
82
+ /**
83
+ * Parse a single JSON-RPC line. Distinguishes:
84
+ * - parse failure → `parse-error` (§4.2)
85
+ * - empty array → `invalid-request` per §6
86
+ * - non-object scalar → `invalid-request`
87
+ * - object with bad `jsonrpc`/`method` → `invalid-request`
88
+ * - object with `id` present → `request`
89
+ * - object without `id` → `notification`
90
+ * - non-empty array → `batch` with per-element outcomes
91
+ *
92
+ * Note (§4 id-vs-notification): `"id": null` is a Request with id null,
93
+ * not a notification. Only an absent id field marks a notification.
94
+ */
95
+ function _parse(line: string): ParseOutcome {
96
+ let raw: unknown;
97
+ try {
98
+ raw = JSON.parse(line);
99
+ } catch {
100
+ const parseFailure: ParseOutcome = { kind: 'parse-error' };
101
+ return parseFailure;
102
+ }
103
+ if (Array.isArray(raw)) {
104
+ if (raw.length === 0) return { kind: 'invalid-request', id: null };
105
+ return { kind: 'batch', outcomes: raw.map(_classify) };
106
+ }
107
+ return _classify(raw);
108
+ }
109
+
110
+ function _classify(raw: unknown): ParseOutcome {
111
+ if (typeof raw !== 'object' || raw === null) {
112
+ return { kind: 'invalid-request', id: null };
113
+ }
114
+ const obj = raw as Record<string, unknown>;
115
+ if (obj.jsonrpc !== '2.0' || typeof obj.method !== 'string') {
116
+ const id =
117
+ typeof obj.id === 'string' || typeof obj.id === 'number' || obj.id === null
118
+ ? (obj.id as JsonRpcId)
119
+ : null;
120
+ return { kind: 'invalid-request', id };
121
+ }
122
+ if (!('id' in obj) || obj.id === undefined) {
123
+ return { kind: 'notification', message: obj as unknown as JsonRpcNotification };
124
+ }
125
+ return { kind: 'request', message: obj as unknown as JsonRpcRequest };
126
+ }
127
+
128
+ /** Construct a -32700 / -32600 / -32601 / -32602 / -32603 error response. */
129
+ function _errorResponse(
130
+ id: JsonRpcId,
131
+ code: number,
132
+ message: string,
133
+ data?: unknown,
134
+ ): JsonRpcErrorResponse {
135
+ return data !== undefined
136
+ ? { jsonrpc: '2.0', id, error: { code, message, data } }
137
+ : { jsonrpc: '2.0', id, error: { code, message } };
138
+ }
139
+
140
+ /** Construct a success response (§5). */
141
+ function _successResponse(id: JsonRpcId, result: unknown): JsonRpcSuccess {
142
+ return { jsonrpc: '2.0', id, result };
143
+ }
144
+
145
+ // Re-export pure functions for direct import sites.
146
+ export const parse = _parse;
147
+ export const errorResponse = _errorResponse;
148
+ export const successResponse = _successResponse;
149
+
150
+ // ---------- Capsule declaration (pureTransform arm) ----------
151
+ //
152
+ // Schemas are deliberately structural: the harness uses them to drive
153
+ // `Schema.decodeUnknownEffect` against `fc.anything()` inputs, so we
154
+ // only need to express enough shape for it to filter the property test.
155
+ const JsonRpcInputSchema = Schema.String;
156
+ const ParseOutcomeKindSchema = Schema.Union([
157
+ Schema.Literal('request'),
158
+ Schema.Literal('notification'),
159
+ Schema.Literal('batch'),
160
+ Schema.Literal('parse-error'),
161
+ Schema.Literal('invalid-request'),
162
+ ]);
163
+ const ParseOutcomeSchema = Schema.Struct({ kind: ParseOutcomeKindSchema });
164
+
165
+ /**
166
+ * Capsule definition for the kernel — placed in the catalog under the
167
+ * `pureTransform` arm so the factory compiler emits a generated test +
168
+ * bench pair and the manifest tracks the kernel's content address.
169
+ */
170
+ export const jsonRpcServerCapsule = defineCapsule({
171
+ _kind: 'pureTransform',
172
+ name: 'mcp.jsonrpc-server',
173
+ site: ['node', 'browser'],
174
+ capabilities: { reads: [], writes: [] },
175
+ input: JsonRpcInputSchema,
176
+ output: ParseOutcomeSchema,
177
+ budgets: { p95Ms: 1, allocClass: 'bounded' },
178
+ invariants: [
179
+ {
180
+ name: 'malformed-json-yields-parse-error',
181
+ check: (input: string, _output): boolean => {
182
+ // Behavioral invariant: an input that is NOT valid JSON MUST be
183
+ // classified as parse-error. The TS union proves syntactic
184
+ // shape; this proves the parser actually rejects bad input.
185
+ try {
186
+ JSON.parse(input);
187
+ return true; // valid JSON — not the negative case we're testing
188
+ } catch {
189
+ return _parse(input).kind === 'parse-error';
190
+ }
191
+ },
192
+ message: 'inputs that JSON.parse rejects must yield kind: parse-error',
193
+ },
194
+ {
195
+ name: 'absent-id-classifies-as-notification',
196
+ check: (input: string, _output): boolean => {
197
+ // Behavioral invariant: a well-formed object with jsonrpc:'2.0'
198
+ // and method:string but NO id field MUST be a notification, not
199
+ // a request. This is the §4.1 distinction the strike force flagged.
200
+ let obj: unknown;
201
+ try {
202
+ obj = JSON.parse(input);
203
+ } catch {
204
+ const skipAbsentIdCase = true;
205
+ return skipAbsentIdCase;
206
+ }
207
+ if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return true;
208
+ const o = obj as Record<string, unknown>;
209
+ if (o.jsonrpc !== '2.0' || typeof o.method !== 'string') return true;
210
+ if ('id' in o && o.id !== undefined) return true; // request, not the test case
211
+ return _parse(input).kind === 'notification';
212
+ },
213
+ message: 'well-formed messages without an id field must classify as notifications (§4.1)',
214
+ },
215
+ ],
216
+ run: (input: string): { kind: string } => _parse(input),
217
+ });
218
+
219
+ // ---------- Namespace surface (ADR-0001) ----------
220
+
221
+ /** Namespaced public surface of the kernel. */
222
+ export const JsonRpcServer = {
223
+ parse: _parse,
224
+ errorResponse: _errorResponse,
225
+ successResponse: _successResponse,
226
+ } as const;
227
+
228
+ export declare namespace JsonRpcServer {
229
+ /** Discriminated parse outcome. */
230
+ export type Outcome = ParseOutcome;
231
+ /** Wire-shape request (§4). */
232
+ export type Request = JsonRpcRequest;
233
+ /** Wire-shape notification (§4.1). */
234
+ export type Notification = JsonRpcNotification;
235
+ /** Wire-shape response (§5). */
236
+ export type Response = JsonRpcResponse;
237
+ /** Wire-shape success response. */
238
+ export type Success = JsonRpcSuccess;
239
+ /** Wire-shape error response. */
240
+ export type Error = JsonRpcErrorResponse;
241
+ /** Id type per §4. */
242
+ export type Id = JsonRpcId;
243
+ }
package/src/start.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * start — pick an MCP transport. Default is stdio; pass `{ http: ':3838' }`
3
+ * to bind HTTP instead.
4
+ *
5
+ * @module
6
+ */
7
+
8
+ import { runStdio } from './stdio.js';
9
+
10
+ /** Options for `start`. */
11
+ export interface StartOpts {
12
+ readonly http?: string;
13
+ }
14
+
15
+ /** Start the MCP server on the requested transport. */
16
+ export async function start(opts: StartOpts = {}): Promise<void> {
17
+ if (opts.http !== undefined) {
18
+ const { runHttp } = await import('./http.js');
19
+ await runHttp(opts.http);
20
+ return;
21
+ }
22
+ await runStdio();
23
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * MCP stdio server bootstrap. Provides the tsx direct-invoke entrypoint
3
+ * for `tests/integration/mcp/stdio-spawn.test.ts`. Excluded from
4
+ * coverage because the only way to exercise this guard is by spawning
5
+ * the script as the entrypoint of a Node process — which is what the
6
+ * integration test does. The pure read-line-write loop lives in
7
+ * `stdio.ts` (`runStdio` / `processLine`) and is fully unit-tested.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import { runStdio } from './stdio.js';
13
+
14
+ if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('stdio-server.ts') || process.argv[1]?.endsWith('stdio.ts')) {
15
+ runStdio().catch((err: unknown) => {
16
+ process.stderr.write(JSON.stringify({ error: String(err) }) + '\n');
17
+ process.exit(1);
18
+ });
19
+ }
package/src/stdio.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * MCP stdio server — reads JSON-RPC 2.0 framed messages line-by-line from
3
+ * stdin, writes responses to stdout. Routes every line through
4
+ * `JsonRpcServer.parse` so parse errors emit -32700 (was silently dropped)
5
+ * and notifications produce no response (§4.1).
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import { createInterface } from 'node:readline/promises';
11
+ import type { Readable, Writable } from 'node:stream';
12
+ import { handleRequest } from './http.js';
13
+
14
+ /**
15
+ * Process a single JSON-RPC stdio line and return the wire payload (a
16
+ * JSON-encoded string) or `null` when no response should be emitted
17
+ * (notification or pure-notification batch). Empty/whitespace-only lines
18
+ * are also `null` so the stdio loop can skip them silently.
19
+ *
20
+ * Exported so unit tests cover the exact line-handling logic without
21
+ * spinning up a child process and pumping stdin.
22
+ */
23
+ export async function processLine(line: string): Promise<string | null> {
24
+ if (!line.trim()) return null;
25
+ const response = await handleRequest(line);
26
+ if (response === null) return null;
27
+ return JSON.stringify(response);
28
+ }
29
+
30
+ /**
31
+ * Run the MCP stdio loop until the input stream closes. Defaults to
32
+ * `process.stdin` / `process.stdout` so the production CLI bootstrap
33
+ * stays a one-liner (`runStdio()`); tests inject a pre-populated
34
+ * Readable + a sink Writable to exercise the full read-line-write loop
35
+ * without spawning a child process.
36
+ */
37
+ export async function runStdio(
38
+ input: Readable = process.stdin,
39
+ output: Writable = process.stdout,
40
+ ): Promise<void> {
41
+ const rl = createInterface({ input });
42
+ for await (const line of rl) {
43
+ const wire = await processLine(line);
44
+ if (wire !== null) {
45
+ output.write(wire + '\n');
46
+ }
47
+ }
48
+ }
49
+
50
+ // Side-effect import installs the tsx direct-invoke guard so the integration
51
+ // spawn (`tsx packages/mcp-server/src/stdio.ts`) keeps working. Bootstrap
52
+ // lives in `./stdio-server.ts` because Windows-spawn coverage can't be
53
+ // merged back through c8 ignore (source-mapped TS line numbers don't match).
54
+ import './stdio-server.js';