@baichen_yu/mcp-guard 0.3.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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +129 -0
  3. package/RELEASE.md +21 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +257 -0
  6. package/dist/mcp/jsonrpc.d.ts +23 -0
  7. package/dist/mcp/jsonrpc.js +124 -0
  8. package/dist/mcp/transport_stdio.d.ts +10 -0
  9. package/dist/mcp/transport_stdio.js +69 -0
  10. package/dist/mcp/types.d.ts +75 -0
  11. package/dist/mcp/types.js +1 -0
  12. package/dist/registry/validator.d.ts +20 -0
  13. package/dist/registry/validator.js +98 -0
  14. package/dist/report/json.d.ts +1 -0
  15. package/dist/report/json.js +8 -0
  16. package/dist/report/markdown.d.ts +2 -0
  17. package/dist/report/markdown.js +61 -0
  18. package/dist/report/sarif.d.ts +2 -0
  19. package/dist/report/sarif.js +33 -0
  20. package/dist/scan/scanner.d.ts +16 -0
  21. package/dist/scan/scanner.js +86 -0
  22. package/dist/security/profiles.d.ts +6 -0
  23. package/dist/security/profiles.js +25 -0
  24. package/dist/security/rules/path_traversal.d.ts +2 -0
  25. package/dist/security/rules/path_traversal.js +24 -0
  26. package/dist/security/rules/raw_args.d.ts +2 -0
  27. package/dist/security/rules/raw_args.js +24 -0
  28. package/dist/security/rules/shell_injection.d.ts +2 -0
  29. package/dist/security/rules/shell_injection.js +23 -0
  30. package/dist/security/scorer.d.ts +5 -0
  31. package/dist/security/scorer.js +4 -0
  32. package/dist/tests/contract/call_tool.d.ts +2 -0
  33. package/dist/tests/contract/call_tool.js +14 -0
  34. package/dist/tests/contract/cancellation.d.ts +2 -0
  35. package/dist/tests/contract/cancellation.js +22 -0
  36. package/dist/tests/contract/error_shapes.d.ts +2 -0
  37. package/dist/tests/contract/error_shapes.js +25 -0
  38. package/dist/tests/contract/large_payload.d.ts +2 -0
  39. package/dist/tests/contract/large_payload.js +15 -0
  40. package/dist/tests/contract/list_tools.d.ts +5 -0
  41. package/dist/tests/contract/list_tools.js +14 -0
  42. package/dist/tests/contract/timeout.d.ts +2 -0
  43. package/dist/tests/contract/timeout.js +25 -0
  44. package/dist/validate/schema_lints.d.ts +2 -0
  45. package/dist/validate/schema_lints.js +65 -0
  46. package/docs/assets/demo.gif +1 -0
  47. package/docs/cli.md +38 -0
  48. package/docs/github-action.md +23 -0
  49. package/docs/index.md +75 -0
  50. package/docs/quickstart.md +34 -0
  51. package/docs/releasing.md +39 -0
  52. package/docs/rules.md +18 -0
  53. package/docs/security-model.md +14 -0
  54. package/package.json +64 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # mcp-guard
2
+
3
+ [![CI](https://img.shields.io/github/actions/workflow/status/TomAs-1226/MCP-shariff/ci.yml?label=CI)](./.github/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/%40baichen_yu%2Fmcp-guard)](https://www.npmjs.com/package/@baichen_yu/mcp-guard)
5
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
6
+
7
+ Security auditing and policy gating for MCP servers (local + CI), with deterministic tests and Markdown/SARIF outputs.
8
+
9
+ > Formerly **mcp-doctor**.
10
+
11
+ > [!IMPORTANT]
12
+ > Remote mode supports **HTTP JSON-RPC only** (`--http`). SSE is not implemented.
13
+
14
+ ## Package name
15
+
16
+ - npm package: `@baichen_yu/mcp-guard` (scoped to avoid name collisions)
17
+ - CLI command after install: `mcp-guard`
18
+ - first scoped publish: `npm publish --access public`
19
+
20
+ ## 30-second quickstart
21
+
22
+ ### A) No install (npx)
23
+
24
+ ```bash
25
+ npx @baichen_yu/mcp-guard audit --stdio "node fixtures/servers/hello-mcp-server/server.cjs" --out reports --fail-on off
26
+ ```
27
+
28
+ ### B) Global install
29
+
30
+ ```bash
31
+ npm i -g @baichen_yu/mcp-guard
32
+ mcp-guard --help
33
+ ```
34
+
35
+ ### C) GitHub Action (paste into workflow)
36
+
37
+ ```yaml
38
+ jobs:
39
+ mcp-audit:
40
+ runs-on: ubuntu-latest
41
+ permissions:
42
+ security-events: write
43
+ actions: read
44
+ contents: read
45
+ steps:
46
+ - uses: actions/checkout@v4
47
+ - uses: actions/setup-node@v4
48
+ with:
49
+ node-version: 20
50
+ - uses: ./.github/actions/mcp-guard
51
+ with:
52
+ stdio_command: node fixtures/servers/hello-mcp-server/server.cjs
53
+ fail_on: high
54
+ ```
55
+
56
+ ## Report preview
57
+
58
+ ```text
59
+ # MCP Guard Report
60
+ - Risk score: 100/100
61
+ - Key findings: 0
62
+ - Contract tests: 6/6
63
+ - Target: node fixtures/servers/hello-mcp-server/server.cjs (stdio)
64
+ ```
65
+
66
+ ## Architecture
67
+
68
+ ```mermaid
69
+ graph LR
70
+ CLI[mcp-guard CLI] --> T[Transports: stdio/http]
71
+ T --> RPC[JSON-RPC]
72
+ RPC --> RULES[Rules + Profiles]
73
+ RULES --> REP[Reports: md/json/sarif]
74
+ REP --> GATE[Policy Gate (--fail-on)]
75
+ GATE --> CI[CI / Code Scanning]
76
+ ```
77
+
78
+ ## Commands
79
+
80
+ ```bash
81
+ mcp-guard validate --stdio "node server.cjs" --profile default --out reports
82
+ mcp-guard test --stdio "node server.cjs" --out reports
83
+ mcp-guard audit --stdio "node server.cjs" --profile strict --fail-on medium --sarif reports/report.sarif
84
+ mcp-guard audit --http "http://127.0.0.1:4010" --timeout-ms 30000 --fail-on off
85
+ mcp-guard scan --repo . --format md --out reports
86
+ mcp-guard registry lint registry/servers.yaml
87
+ mcp-guard registry verify registry/servers.yaml --sample 5
88
+ mcp-guard registry score registry/servers.yaml
89
+ ```
90
+
91
+ ## Docs site (GitHub Pages)
92
+
93
+ - Docs URL pattern: `https://<owner>.github.io/MCP-shariff/`
94
+ - One-time setup: GitHub repo **Settings → Pages → Source → GitHub Actions**
95
+ - Deploy workflow: `.github/workflows/deploy-pages.yml`
96
+
97
+ ## Links
98
+
99
+ - Docs: https://tomas-1226.github.io/MCP-shariff/
100
+ - GitHub: https://github.com/TomAs-1226/MCP-shariff
101
+ - npm: https://www.npmjs.com/package/@baichen_yu/mcp-guard
102
+
103
+ ## Troubleshooting
104
+
105
+ - Node `>=20` required
106
+ - On Windows, wrap `--stdio` command in double quotes
107
+ - If startup is slow, increase `--timeout-ms`
108
+ - HTTP target must accept JSON-RPC over POST
109
+
110
+ ## Releasing
111
+
112
+ See [`docs/releasing.md`](docs/releasing.md) and [`RELEASE.md`](RELEASE.md).
113
+
114
+ ### Local release bundle script
115
+
116
+ Use this helper to generate a release-ready tarball locally:
117
+
118
+ ```bash
119
+ ./scripts/build-release-local.sh
120
+ ```
121
+
122
+ ### Publishing troubleshooting
123
+
124
+ Run `npm publish` from the project root that contains `package.json`.
125
+ If you hit `ENOENT` for `package.json`, you are publishing from the wrong directory.
126
+
127
+ ## License
128
+
129
+ MIT. See [`LICENSE`](LICENSE).
package/RELEASE.md ADDED
@@ -0,0 +1,21 @@
1
+ # Release Checklist
2
+
3
+ 1. Freeze CLI/API flags for the target release.
4
+ 2. Set npm scope in `package.json` (`@<scope>/mcp-guard`) and verify package metadata.
5
+ 3. Run full checks:
6
+ - `npm run fixtures:gen`
7
+ - `npm run lint`
8
+ - `npm test`
9
+ - `npm run build`
10
+ - `npm run docs:build`
11
+ - `npm pack --dry-run`
12
+ 4. First scoped publish:
13
+ - `npm publish --access public`
14
+ 5. Tag + release:
15
+ - `git tag v0.3.0`
16
+ - `git push origin v0.3.0`
17
+ 6. Create GitHub Release notes with:
18
+ - highlights
19
+ - migration note (“formerly mcp-doctor”)
20
+ - limitation note (HTTP JSON-RPC only; no SSE)
21
+ 7. Confirm docs site is live on GitHub Pages.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, readFile } from 'node:fs/promises';
3
+ import { basename } from 'node:path';
4
+ import { Command } from 'commander';
5
+ import { HttpJsonRpcClient, StdioJsonRpcClient } from './mcp/jsonrpc.js';
6
+ import { StdioTransport } from './mcp/transport_stdio.js';
7
+ import { writeJsonReport } from './report/json.js';
8
+ import { writeMarkdownReport } from './report/markdown.js';
9
+ import { writeSarif } from './report/sarif.js';
10
+ import { verifyRegistry, lintRegistry, parseRegistry } from './registry/validator.js';
11
+ import { tuneFindingsForProfile } from './security/profiles.js';
12
+ import { pathTraversalRule } from './security/rules/path_traversal.js';
13
+ import { rawArgsRule } from './security/rules/raw_args.js';
14
+ import { shellInjectionRule } from './security/rules/shell_injection.js';
15
+ import { computeRiskScore } from './security/scorer.js';
16
+ import { scanConfigs } from './scan/scanner.js';
17
+ import { runCallToolTest } from './tests/contract/call_tool.js';
18
+ import { runCancellationTest } from './tests/contract/cancellation.js';
19
+ import { runErrorShapesTest } from './tests/contract/error_shapes.js';
20
+ import { runLargePayloadTest } from './tests/contract/large_payload.js';
21
+ import { runListToolsTest } from './tests/contract/list_tools.js';
22
+ import { runTimeoutTest } from './tests/contract/timeout.js';
23
+ import { runSchemaLints } from './validate/schema_lints.js';
24
+ const program = new Command();
25
+ function parseProfile(value) {
26
+ if (value === 'default' || value === 'strict' || value === 'paranoid')
27
+ return value;
28
+ throw new Error(`Invalid profile: ${value}`);
29
+ }
30
+ function parseFailOn(value) {
31
+ if (value === 'off' || value === 'low' || value === 'medium' || value === 'high')
32
+ return value;
33
+ throw new Error(`Invalid fail_on value: ${value}`);
34
+ }
35
+ function shouldFailByPolicy(findings, failOn) {
36
+ if (failOn === 'off')
37
+ return false;
38
+ const rank = { off: 99, low: 0, medium: 1, high: 2 };
39
+ const severityRank = { low: 0, medium: 1, high: 2 };
40
+ return findings.some((finding) => severityRank[finding.severity] >= rank[failOn]);
41
+ }
42
+ async function buildClient(options) {
43
+ if (!options.stdio && !options.http)
44
+ throw new Error('One of --stdio or --http is required.');
45
+ if (options.stdio && options.http)
46
+ throw new Error('Use only one transport: --stdio or --http.');
47
+ if (options.stdio) {
48
+ const transport = new StdioTransport();
49
+ await transport.start({ stdioCommand: options.stdio, silent: options.silent });
50
+ return { client: new StdioJsonRpcClient(transport, options.timeoutMs), target: options.stdio, transport: 'stdio' };
51
+ }
52
+ return { client: new HttpJsonRpcClient(options.http, options.timeoutMs), target: options.http, transport: 'http' };
53
+ }
54
+ async function executeSuite(params) {
55
+ const { client, target, transport } = await buildClient({ stdio: params.stdio, http: params.http, timeoutMs: params.timeoutMs, silent: false });
56
+ const findings = [];
57
+ let tools = [];
58
+ const tests = [];
59
+ let protocolVersion = 'unknown';
60
+ try {
61
+ const init = await client.request('initialize', {
62
+ clientInfo: { name: 'mcp-guard', version: '0.3.0' }
63
+ });
64
+ protocolVersion = init.protocolVersion ?? 'unknown';
65
+ const listResult = await runListToolsTest(client);
66
+ tools = listResult.tools;
67
+ if (params.runTests)
68
+ tests.push(listResult.result);
69
+ if (params.runTests) {
70
+ tests.push(await runCallToolTest(client));
71
+ tests.push(await runErrorShapesTest(client));
72
+ tests.push(await runCancellationTest(client));
73
+ tests.push(await runLargePayloadTest(client));
74
+ tests.push(await runTimeoutTest(client));
75
+ }
76
+ if (params.runAudit) {
77
+ findings.push(...runSchemaLints(tools, params.profile));
78
+ findings.push(...pathTraversalRule(tools));
79
+ findings.push(...shellInjectionRule(tools));
80
+ findings.push(...rawArgsRule(tools));
81
+ }
82
+ }
83
+ finally {
84
+ await client.close();
85
+ }
86
+ const tunedFindings = tuneFindingsForProfile(findings, params.profile);
87
+ const { score, breakdown } = computeRiskScore(tunedFindings, params.profile);
88
+ const report = {
89
+ server: { target, transport, protocolVersion },
90
+ tools,
91
+ findings: tunedFindings,
92
+ tests,
93
+ score,
94
+ scoreBreakdown: breakdown,
95
+ generatedAt: new Date().toISOString()
96
+ };
97
+ await mkdir(params.outDir, { recursive: true });
98
+ await writeJsonReport(report, params.outDir);
99
+ await writeMarkdownReport(report, params.outDir);
100
+ if (params.sarif) {
101
+ await writeSarif(tunedFindings, params.sarif);
102
+ }
103
+ if (tests.some((t) => !t.passed))
104
+ return 2;
105
+ if (shouldFailByPolicy(tunedFindings, params.failOn))
106
+ return 2;
107
+ if (tunedFindings.length > 0)
108
+ return 1;
109
+ return 0;
110
+ }
111
+ program
112
+ .name('mcp-guard')
113
+ .description('Validate, test, and security-audit MCP servers over STDIO or HTTP JSON-RPC')
114
+ .showHelpAfterError();
115
+ for (const commandName of ['validate', 'test', 'audit']) {
116
+ program.command(commandName)
117
+ .description(`${commandName} an MCP server`)
118
+ .option('--stdio <cmd>', 'Command used to launch MCP server over stdio')
119
+ .option('--http <url>', 'HTTP JSON-RPC endpoint URL')
120
+ .option('--out <dir>', 'Output directory', 'reports')
121
+ .option('--sarif <file>', 'SARIF output file (mainly for audit)', 'reports/report.sarif')
122
+ .option('--profile <profile>', 'Rule profile: default|strict|paranoid', parseProfile, 'default')
123
+ .option('--timeout-ms <n>', 'Request timeout in milliseconds', (value) => Number(value), 30000)
124
+ .option('--fail-on <level>', 'Policy threshold: off|low|medium|high', parseFailOn, 'high')
125
+ .action(async (options) => {
126
+ const doTests = commandName === 'test' || commandName === 'audit';
127
+ const doAudit = commandName === 'audit' || commandName === 'validate';
128
+ const code = await executeSuite({
129
+ stdio: options.stdio,
130
+ http: options.http,
131
+ outDir: options.out,
132
+ runTests: doTests,
133
+ runAudit: doAudit,
134
+ sarif: options.sarif,
135
+ profile: options.profile,
136
+ timeoutMs: options.timeoutMs,
137
+ failOn: options.failOn
138
+ });
139
+ process.exit(code);
140
+ });
141
+ }
142
+ program.command('scan')
143
+ .description('Discover MCP server configs in local files and repositories')
144
+ .option('--repo <path>', 'Repository path to scan for known config files')
145
+ .option('--path <file>', 'Direct config file to parse')
146
+ .option('--format <format>', 'Output format: md|json|sarif', 'md')
147
+ .option('--out <dir>', 'Output directory', 'reports')
148
+ .action(async (options) => {
149
+ const result = await scanConfigs(options.repo, options.path);
150
+ for (const server of result.servers) {
151
+ if (server.transport !== 'stdio' || !server.rawCommand)
152
+ continue;
153
+ try {
154
+ const { client } = await buildClient({ stdio: server.rawCommand, timeoutMs: 1000, silent: true });
155
+ await client.request('initialize', { clientInfo: { name: 'mcp-guard-scan', version: '0.3.0' } }, 1000);
156
+ const listed = (await client.request('tools/list', undefined, 1000)).tools;
157
+ const localFindings = [
158
+ ...runSchemaLints(listed, 'default'),
159
+ ...pathTraversalRule(listed),
160
+ ...shellInjectionRule(listed),
161
+ ...rawArgsRule(listed)
162
+ ];
163
+ server.reachable = true;
164
+ server.findingsCount = localFindings.length;
165
+ await client.close();
166
+ }
167
+ catch {
168
+ server.reachable = false;
169
+ }
170
+ }
171
+ const output = {
172
+ generatedAt: new Date().toISOString(),
173
+ scannedPaths: result.scannedPaths,
174
+ servers: result.servers
175
+ };
176
+ await mkdir(options.out, { recursive: true });
177
+ if (options.format === 'json') {
178
+ await writeJsonReport(output, options.out, 'scan_report.json');
179
+ }
180
+ else if (options.format === 'sarif') {
181
+ await writeSarif([], `${options.out}/scan_report.sarif`);
182
+ }
183
+ else {
184
+ const pseudoReport = {
185
+ server: { target: options.repo ?? options.path ?? '.', transport: 'stdio' },
186
+ tools: [],
187
+ findings: [],
188
+ tests: [],
189
+ score: 100,
190
+ scoreBreakdown: [],
191
+ generatedAt: new Date().toISOString()
192
+ };
193
+ await writeMarkdownReport(pseudoReport, options.out, 'scan_report.md');
194
+ const lines = ['# Scan Report', '', '| Source | Name | Transport | Reachable | Findings | Command |', '| --- | --- | --- | --- | --- | --- |'];
195
+ for (const server of result.servers) {
196
+ lines.push(`| ${server.source} | ${server.name} | ${server.transport} | ${server.reachable ?? false} | ${server.findingsCount ?? '-'} | ${server.command ?? ''} |`);
197
+ }
198
+ await import('node:fs/promises').then((fs) => fs.writeFile(`${options.out}/scan_report.md`, `${lines.join('\n')}\n`, 'utf8'));
199
+ }
200
+ // eslint-disable-next-line no-console
201
+ console.log(`Discovered ${result.servers.length} server definitions`);
202
+ process.exit(0);
203
+ });
204
+ const registry = program.command('registry').description('Registry utilities');
205
+ registry.command('lint')
206
+ .description('Validate registry schema and metadata')
207
+ .argument('<file>', 'Registry yaml file')
208
+ .action(async (file) => {
209
+ const raw = await readFile(file, 'utf8');
210
+ const issues = lintRegistry(raw);
211
+ if (issues.length === 0) {
212
+ // eslint-disable-next-line no-console
213
+ console.log(`Registry lint ok: ${parseRegistry(raw).length} entries`);
214
+ process.exit(0);
215
+ }
216
+ for (const issue of issues) {
217
+ // eslint-disable-next-line no-console
218
+ console.log(`${issue.severity.toUpperCase()} L${issue.line}: ${issue.message}`);
219
+ }
220
+ process.exit(issues.some((issue) => issue.severity === 'error') ? 2 : 1);
221
+ });
222
+ registry.command('score')
223
+ .description('Write local registry scoreboard')
224
+ .argument('<file>', 'Registry yaml file')
225
+ .action(async (file) => {
226
+ const raw = await readFile(file, 'utf8');
227
+ const entries = parseRegistry(raw);
228
+ const rows = ['# Registry Scoreboard', '', '| Server | Score |', '| --- | --- |'];
229
+ for (const entry of entries) {
230
+ const score = entry.permissions && entry.permissions.length > 0 ? 90 : 70;
231
+ rows.push(`| ${String(entry.name ?? basename(file))} | ${score} |`);
232
+ }
233
+ await mkdir('results', { recursive: true });
234
+ await import('node:fs/promises').then((fs) => fs.writeFile('results/scoreboard.md', `${rows.join('\n')}\n`, 'utf8'));
235
+ // eslint-disable-next-line no-console
236
+ console.log('Wrote results/scoreboard.md');
237
+ process.exit(0);
238
+ });
239
+ registry.command('verify')
240
+ .description('Offline validation for completeness and unsafe command patterns')
241
+ .argument('<file>', 'Registry yaml file')
242
+ .option('--sample <n>', 'Number of entries to verify', (value) => Number(value), 5)
243
+ .action(async (file, options) => {
244
+ const raw = await readFile(file, 'utf8');
245
+ const issues = verifyRegistry(raw, options.sample);
246
+ if (issues.length === 0) {
247
+ // eslint-disable-next-line no-console
248
+ console.log('Registry verify passed with no issues');
249
+ process.exit(0);
250
+ }
251
+ for (const issue of issues) {
252
+ // eslint-disable-next-line no-console
253
+ console.log(`${issue.severity.toUpperCase()} L${issue.line}: ${issue.message}`);
254
+ }
255
+ process.exit(issues.some((issue) => issue.severity === 'error') ? 2 : 1);
256
+ });
257
+ program.parseAsync(process.argv);
@@ -0,0 +1,23 @@
1
+ import { StdioTransport } from './transport_stdio.js';
2
+ import { NormalizedError, RpcClient } from './types.js';
3
+ export declare class StdioJsonRpcClient implements RpcClient {
4
+ private readonly transport;
5
+ private readonly timeoutMs;
6
+ private nextId;
7
+ private pending;
8
+ constructor(transport: StdioTransport, timeoutMs?: number);
9
+ request<T>(method: string, params?: unknown, timeoutOverrideMs?: number): Promise<T>;
10
+ normalizeError(error: unknown): NormalizedError;
11
+ close(): Promise<void>;
12
+ private handleLine;
13
+ }
14
+ export declare class HttpJsonRpcClient implements RpcClient {
15
+ private readonly url;
16
+ private readonly timeoutMs;
17
+ private nextId;
18
+ constructor(url: string, timeoutMs?: number);
19
+ request<T>(method: string, params?: unknown, timeoutOverrideMs?: number): Promise<T>;
20
+ normalizeError(error: unknown): NormalizedError;
21
+ close(): Promise<void>;
22
+ }
23
+ export declare function normalizeError(error: unknown): NormalizedError;
@@ -0,0 +1,124 @@
1
+ export class StdioJsonRpcClient {
2
+ transport;
3
+ timeoutMs;
4
+ nextId = 1;
5
+ pending = new Map();
6
+ constructor(transport, timeoutMs = 5000) {
7
+ this.transport = transport;
8
+ this.timeoutMs = timeoutMs;
9
+ this.transport.onLine((line) => this.handleLine(line));
10
+ }
11
+ async request(method, params, timeoutOverrideMs) {
12
+ const id = this.nextId++;
13
+ const payload = {
14
+ jsonrpc: '2.0',
15
+ id,
16
+ method,
17
+ params
18
+ };
19
+ return new Promise((resolve, reject) => {
20
+ const timer = setTimeout(() => {
21
+ this.pending.delete(id);
22
+ reject(new Error(`Request timed out: ${method}`));
23
+ }, timeoutOverrideMs ?? this.timeoutMs);
24
+ this.pending.set(id, { resolve: resolve, reject, timer });
25
+ this.transport.send(JSON.stringify(payload));
26
+ });
27
+ }
28
+ normalizeError(error) {
29
+ return normalizeError(error);
30
+ }
31
+ async close() {
32
+ await this.transport.stop();
33
+ }
34
+ handleLine(line) {
35
+ let parsed;
36
+ try {
37
+ parsed = JSON.parse(line);
38
+ }
39
+ catch {
40
+ return;
41
+ }
42
+ const pending = this.pending.get(parsed.id);
43
+ if (!pending) {
44
+ return;
45
+ }
46
+ clearTimeout(pending.timer);
47
+ this.pending.delete(parsed.id);
48
+ if ('error' in parsed) {
49
+ const err = parsed.error;
50
+ pending.reject(err);
51
+ return;
52
+ }
53
+ pending.resolve(parsed.result);
54
+ }
55
+ }
56
+ export class HttpJsonRpcClient {
57
+ url;
58
+ timeoutMs;
59
+ nextId = 1;
60
+ constructor(url, timeoutMs = 5000) {
61
+ this.url = url;
62
+ this.timeoutMs = timeoutMs;
63
+ }
64
+ async request(method, params, timeoutOverrideMs) {
65
+ const id = this.nextId++;
66
+ const payload = { jsonrpc: '2.0', id, method, params };
67
+ const maxAttempts = 3;
68
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
69
+ const controller = new AbortController();
70
+ const timeout = setTimeout(() => controller.abort(), timeoutOverrideMs ?? this.timeoutMs);
71
+ try {
72
+ const response = await fetch(this.url, {
73
+ method: 'POST',
74
+ headers: { 'content-type': 'application/json' },
75
+ body: JSON.stringify(payload),
76
+ signal: controller.signal
77
+ });
78
+ const parsed = (await response.json());
79
+ if ('error' in parsed) {
80
+ throw parsed.error;
81
+ }
82
+ return parsed.result;
83
+ }
84
+ catch (error) {
85
+ if (attempt === maxAttempts || !(error instanceof Error) || !error.message.includes('fetch')) {
86
+ throw error;
87
+ }
88
+ const backoff = 50 * 2 ** (attempt - 1);
89
+ await new Promise((resolve) => setTimeout(resolve, backoff));
90
+ }
91
+ finally {
92
+ clearTimeout(timeout);
93
+ }
94
+ }
95
+ throw new Error('Unreachable retry state');
96
+ }
97
+ normalizeError(error) {
98
+ return normalizeError(error);
99
+ }
100
+ async close() {
101
+ await Promise.resolve();
102
+ }
103
+ }
104
+ export function normalizeError(error) {
105
+ if (typeof error === 'object' && error !== null && 'code' in error && 'message' in error) {
106
+ const err = error;
107
+ return {
108
+ code: Number(err.code),
109
+ message: String(err.message),
110
+ data: err.data
111
+ };
112
+ }
113
+ if (error instanceof Error) {
114
+ return {
115
+ code: -32000,
116
+ message: error.message
117
+ };
118
+ }
119
+ return {
120
+ code: -32001,
121
+ message: 'Unknown error',
122
+ data: error
123
+ };
124
+ }
@@ -0,0 +1,10 @@
1
+ import { ServerConfig } from './types.js';
2
+ export declare class StdioTransport {
3
+ private proc;
4
+ private buffer;
5
+ private listeners;
6
+ start(config: ServerConfig): Promise<void>;
7
+ onLine(cb: (line: string) => void): void;
8
+ send(line: string): void;
9
+ stop(): Promise<void>;
10
+ }
@@ -0,0 +1,69 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { once } from 'node:events';
3
+ export class StdioTransport {
4
+ proc = null;
5
+ buffer = '';
6
+ listeners = [];
7
+ async start(config) {
8
+ if (this.proc) {
9
+ throw new Error('Transport already started');
10
+ }
11
+ if (!config.stdioCommand) {
12
+ throw new Error('stdioCommand is required for StdioTransport');
13
+ }
14
+ const env = { ...process.env, ...(config.env ?? {}) };
15
+ const proc = spawn(config.stdioCommand, {
16
+ cwd: config.cwd,
17
+ env,
18
+ shell: true,
19
+ stdio: ['pipe', 'pipe', 'pipe']
20
+ });
21
+ this.proc = proc;
22
+ proc.stdout.on('data', (chunk) => {
23
+ this.buffer += chunk.toString('utf8');
24
+ let idx = this.buffer.indexOf('\n');
25
+ while (idx >= 0) {
26
+ const line = this.buffer.slice(0, idx).trim();
27
+ this.buffer = this.buffer.slice(idx + 1);
28
+ if (line) {
29
+ this.listeners.forEach((cb) => cb(line));
30
+ }
31
+ idx = this.buffer.indexOf('\n');
32
+ }
33
+ });
34
+ proc.stderr.on('data', (chunk) => {
35
+ const message = chunk.toString('utf8').trim();
36
+ if (!config.silent && message.length > 0) {
37
+ // eslint-disable-next-line no-console
38
+ console.error(`[server stderr] ${message}`);
39
+ }
40
+ });
41
+ proc.once('exit', (code, signal) => {
42
+ if (!config.silent && code !== 0 && signal !== 'SIGTERM') {
43
+ // eslint-disable-next-line no-console
44
+ console.error(`Server exited unexpectedly (code=${code}, signal=${signal})`);
45
+ }
46
+ });
47
+ await once(proc, 'spawn');
48
+ }
49
+ onLine(cb) {
50
+ this.listeners.push(cb);
51
+ }
52
+ send(line) {
53
+ if (!this.proc) {
54
+ throw new Error('Transport not started');
55
+ }
56
+ this.proc.stdin.write(`${line}\n`);
57
+ }
58
+ async stop() {
59
+ if (!this.proc) {
60
+ return;
61
+ }
62
+ const proc = this.proc;
63
+ this.proc = null;
64
+ proc.kill('SIGTERM');
65
+ const timeout = setTimeout(() => proc.kill('SIGKILL'), 1000);
66
+ await once(proc, 'exit').catch(() => undefined);
67
+ clearTimeout(timeout);
68
+ }
69
+ }