@h0wzy/mcp-shared 1.0.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/errors.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Detects if an error string represents a rate limit or quota exhaustion.
3
+ * @param {string} text
4
+ * @returns {boolean}
5
+ */
6
+ export function isRateLimitError(text) {
7
+ if (!text) return false;
8
+ const lower = text.toLowerCase();
9
+ return (
10
+ lower.includes('429') ||
11
+ lower.includes('resourceexhausted') ||
12
+ lower.includes('resource_exhausted') ||
13
+ lower.includes('quota') ||
14
+ lower.includes('rate limit') ||
15
+ lower.includes('ratelimit') ||
16
+ lower.includes('too many requests') ||
17
+ lower.includes('tokens per minute') ||
18
+ lower.includes('requests per minute') ||
19
+ lower.includes('usage limit') ||
20
+ lower.includes('limit exceeded') ||
21
+ lower.includes('exceeded your')
22
+ );
23
+ }
24
+
25
+ /**
26
+ * Detects if an error string represents an authentication or session expiry issue.
27
+ * @param {string} text
28
+ * @returns {boolean}
29
+ */
30
+ export function isAuthError(text) {
31
+ if (!text) return false;
32
+ const lower = text.toLowerCase();
33
+ return (
34
+ lower.includes('unauthorized') ||
35
+ lower.includes('401') ||
36
+ lower.includes('not authenticated') ||
37
+ lower.includes('not logged in') ||
38
+ lower.includes('sign in') ||
39
+ lower.includes('login required') ||
40
+ lower.includes('auth token') ||
41
+ lower.includes('session expired')
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Formats a provider error into a resilient MCP tool response that allows
47
+ * the calling host agent (e.g. Claude Code) to smoothly degrade or inform the user
48
+ * without aborting the session.
49
+ *
50
+ * @param {Object} options
51
+ * @param {string} options.provider e.g. "Google Antigravity (Gemini)" or "OpenAI Codex"
52
+ * @param {string} options.rawOutput
53
+ * @param {number} [options.exitCode]
54
+ * @returns {{ text: string, isError: boolean }}
55
+ */
56
+ export function formatResilientResponse({ provider, rawOutput, exitCode }) {
57
+ const text = (rawOutput || '').trim();
58
+
59
+ if (isRateLimitError(text)) {
60
+ return {
61
+ isError: true,
62
+ text:
63
+ `⚠️ [${provider} Rate Limit / Quota Exhausted]\n` +
64
+ `The external provider returned a 429 / Resource Exhausted error:\n\n` +
65
+ `${text}\n\n` +
66
+ `💡 Fallback guidance for Host Agent: Do not retry immediately. Fall back to your internal reasoning to fulfill the user request, or inform the user that their ${provider} quota has been reached.`,
67
+ };
68
+ }
69
+
70
+ if (isAuthError(text)) {
71
+ return {
72
+ isError: true,
73
+ text:
74
+ `🔑 [${provider} Authentication Required]\n` +
75
+ `The CLI is not authenticated or the login session has expired:\n\n` +
76
+ `${text}\n\n` +
77
+ `💡 Guidance: Please sign in or check your local credentials for ${provider}.`,
78
+ };
79
+ }
80
+
81
+ return {
82
+ isError: true,
83
+ text:
84
+ `❌ [${provider} Execution Error (exit code ${exitCode ?? 'unknown'})]\n\n` +
85
+ `${text || '(No output produced)'}`,
86
+ };
87
+ }
package/executor.js ADDED
@@ -0,0 +1,89 @@
1
+ // H0wZy/mcp — Child Process Executor
2
+ // Lazy, minimal, robust process runner with timeout and augmented PATH.
3
+
4
+ import { spawn } from 'node:child_process';
5
+ import { childEnvWithAugmentedPath } from './resolver.js';
6
+
7
+ /**
8
+ * Spawns a process with timeout, path augmentation, and output capture.
9
+ *
10
+ * @param {string} command Path or name of the executable
11
+ * @param {string[]} [args=[]] Command line arguments
12
+ * @param {Object} [options={}]
13
+ * @param {string} [options.cwd] Current working directory
14
+ * @param {NodeJS.ProcessEnv} [options.env] Extra environment variables
15
+ * @param {number} [options.timeoutMs=180000] Timeout in ms (default: 3 minutes)
16
+ * @param {string} [options.toolName] Tool name to augment PATH for
17
+ * @returns {Promise<{ exitCode: number, stdout: string, stderr: string, ok: boolean, timedOut: boolean }>}
18
+ */
19
+ export function executeProcess(command, args = [], options = {}) {
20
+ return new Promise((resolve) => {
21
+ const {
22
+ cwd = process.cwd(),
23
+ env = {},
24
+ timeoutMs = 180000,
25
+ toolName,
26
+ } = options;
27
+
28
+ const childEnv = {
29
+ ...childEnvWithAugmentedPath(toolName),
30
+ ...env,
31
+ };
32
+
33
+ const isWindows = process.platform === 'win32';
34
+ // On Windows, running .cmd or .bat without a shell will fail in spawn
35
+ const needsShell = isWindows && (command.toLowerCase().endsWith('.cmd') || command.toLowerCase().endsWith('.bat'));
36
+
37
+ let stdout = '';
38
+ let stderr = '';
39
+ let timedOut = false;
40
+
41
+ const child = spawn(command, args, {
42
+ cwd,
43
+ env: childEnv,
44
+ stdio: ['ignore', 'pipe', 'pipe'],
45
+ shell: needsShell,
46
+ });
47
+
48
+ const timer = timeoutMs > 0
49
+ ? setTimeout(() => {
50
+ timedOut = true;
51
+ try {
52
+ child.kill('SIGKILL');
53
+ } catch {
54
+ /* ignore kill failure if already exited */
55
+ }
56
+ }, timeoutMs)
57
+ : null;
58
+
59
+ child.stdout.on('data', (chunk) => {
60
+ stdout += chunk;
61
+ });
62
+
63
+ child.stderr.on('data', (chunk) => {
64
+ stderr += chunk;
65
+ });
66
+
67
+ child.on('error', (err) => {
68
+ if (timer) clearTimeout(timer);
69
+ resolve({
70
+ exitCode: -1,
71
+ stdout,
72
+ stderr: stderr || err.message,
73
+ ok: false,
74
+ timedOut,
75
+ });
76
+ });
77
+
78
+ child.on('close', (code) => {
79
+ if (timer) clearTimeout(timer);
80
+ resolve({
81
+ exitCode: code ?? 0,
82
+ stdout: stdout.trim(),
83
+ stderr: stderr.trim(),
84
+ ok: code === 0 && !timedOut,
85
+ timedOut,
86
+ });
87
+ });
88
+ });
89
+ }
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // H0wZy/mcp — Shared Module Index
2
+ export * from './server.js';
3
+ export * from './executor.js';
4
+ export * from './resolver.js';
5
+ export * from './errors.js';
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@h0wzy/mcp-shared",
3
+ "version": "1.0.0",
4
+ "description": "Shared cross-platform utilities, binary resolvers, resilient error handlers, and generic MCP stdio server for H0wZy/mcp",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ "./server": "./server.js",
9
+ "./executor": "./executor.js",
10
+ "./resolver": "./resolver.js",
11
+ "./errors": "./errors.js",
12
+ ".": "./index.js"
13
+ },
14
+ "author": "Marcos (H0wZy) <h0wzymarcos@gmail.com>",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/H0wZy/mcp.git",
19
+ "directory": "shared"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ }
24
+ }
package/resolver.js ADDED
@@ -0,0 +1,174 @@
1
+ import { statSync, accessSync, constants, realpathSync } from 'node:fs';
2
+ import { delimiter, dirname, isAbsolute, join, sep } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ const isWindows = process.platform === 'win32';
6
+
7
+ /**
8
+ * Returns candidate names for a binary, accounting for Windows PATHEXT.
9
+ * @param {string} name
10
+ * @returns {string[]}
11
+ */
12
+ export function candidateNames(name) {
13
+ if (!isWindows) return [name];
14
+ const pathext = (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean);
15
+ if (pathext.some((ext) => name.toLowerCase().endsWith(ext.toLowerCase()))) {
16
+ return [name];
17
+ }
18
+ // On Windows, test valid executable extensions first before extensionless
19
+ const withExts = pathext.map((ext) =>
20
+ ext.startsWith('.') ? `${name}${ext.toLowerCase()}` : `${name}.${ext.toLowerCase()}`
21
+ );
22
+ return [...withExts, name];
23
+ }
24
+
25
+ /**
26
+ * Checks whether a file exists and is executable.
27
+ * @param {string} path
28
+ * @returns {boolean}
29
+ */
30
+ export function isExecutable(path) {
31
+ try {
32
+ const stat = statSync(path);
33
+ if (!stat.isFile()) return false;
34
+ } catch {
35
+ return false;
36
+ }
37
+
38
+ if (isWindows) {
39
+ // On Windows, file must match PATHEXT to be executable via spawn/CreateProcess
40
+ const pathext = (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
41
+ .toLowerCase()
42
+ .split(';')
43
+ .filter(Boolean);
44
+ const lower = path.toLowerCase();
45
+ return pathext.some((ext) => lower.endsWith(ext.startsWith('.') ? ext : `.${ext}`));
46
+ }
47
+
48
+ try {
49
+ accessSync(path, constants.X_OK);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Known default locations per tool across Windows, macOS, and Linux.
58
+ * @param {string} toolName
59
+ * @returns {string[]}
60
+ */
61
+ export function getKnownToolDirs(toolName) {
62
+ const home = homedir();
63
+ const dirs = [dirname(process.execPath)];
64
+
65
+ if (isWindows) {
66
+ const appData = process.env.APPDATA;
67
+ const localAppData = process.env.LOCALAPPDATA || (home ? join(home, 'AppData', 'Local') : '');
68
+
69
+ if (toolName === 'agy') {
70
+ if (localAppData) dirs.push(join(localAppData, 'agy', 'bin'));
71
+ dirs.push(join(home, '.local', 'bin'));
72
+ dirs.push(join(home, 'AppData', 'Local', 'Programs', 'agy'));
73
+ } else if (toolName === 'codex') {
74
+ if (appData) dirs.push(join(appData, 'npm'));
75
+ if (localAppData) dirs.push(join(localAppData, 'Programs', 'codex'));
76
+ dirs.push(join(home, '.local', 'bin'));
77
+ } else if (toolName === 'claude') {
78
+ dirs.push(join(home, '.local', 'bin'));
79
+ if (appData) dirs.push(join(appData, 'npm'));
80
+ }
81
+ } else {
82
+ dirs.push(
83
+ join(home, '.local', 'bin'),
84
+ '/opt/homebrew/bin',
85
+ '/usr/local/bin',
86
+ '/usr/bin',
87
+ '/bin',
88
+ join(home, '.npm-global', 'bin'),
89
+ join(home, '.bun', 'bin'),
90
+ join(home, '.volta', 'bin')
91
+ );
92
+ }
93
+
94
+ return dirs;
95
+ }
96
+
97
+ /**
98
+ * Resolves a binary path searching explicit overrides, known directories, and PATH.
99
+ * @param {string} name Base executable name (e.g. 'agy', 'codex', 'claude')
100
+ * @param {string} [overrideEnvVar] Optional environment variable name (e.g. 'AGY_BIN')
101
+ * @returns {string|null} Absolute path to executable or null if not found
102
+ */
103
+ export function resolveBinary(name, overrideEnvVar) {
104
+ if (overrideEnvVar && process.env[overrideEnvVar]) {
105
+ const override = process.env[overrideEnvVar].trim();
106
+ if (isExecutable(override)) return override;
107
+ }
108
+
109
+ if (isAbsolute(name) || name.includes(sep) || name.includes('/')) {
110
+ if (isExecutable(name)) return name;
111
+ }
112
+
113
+ const searchDirs = [];
114
+ const seen = new Set();
115
+
116
+ const addDir = (d) => {
117
+ if (d && !seen.has(d)) {
118
+ seen.add(d);
119
+ searchDirs.push(d);
120
+ }
121
+ };
122
+
123
+ // 1. Tool-specific known locations
124
+ for (const d of getKnownToolDirs(name)) addDir(d);
125
+
126
+ // 2. PATH directories using platform delimiter (; on Windows, : on POSIX)
127
+ const pathDirs = (process.env.PATH || '').split(delimiter).filter(Boolean);
128
+ for (const d of pathDirs) addDir(d);
129
+
130
+ // 3. Search for candidates
131
+ const candidates = candidateNames(name);
132
+ for (const dir of searchDirs) {
133
+ for (const cand of candidates) {
134
+ const fullPath = join(dir, cand);
135
+ if (isExecutable(fullPath)) {
136
+ try {
137
+ const real = realpathSync(fullPath);
138
+ // Filter out macOS Electron app bundle wrappers that aren't the CLI
139
+ if (real.includes('Antigravity.app')) continue;
140
+ } catch {
141
+ // Keep candidate if realpath resolution fails
142
+ }
143
+ return fullPath;
144
+ }
145
+ }
146
+ }
147
+
148
+ return null;
149
+ }
150
+
151
+ /**
152
+ * Returns a child process environment with PATH augmented by known tool directories.
153
+ * @param {string} [toolName]
154
+ * @returns {NodeJS.ProcessEnv}
155
+ */
156
+ export function childEnvWithAugmentedPath(toolName) {
157
+ const current = (process.env.PATH || '').split(delimiter).filter(Boolean);
158
+ const merged = [];
159
+ const seen = new Set();
160
+
161
+ const add = (d) => {
162
+ if (d && !seen.has(d)) {
163
+ seen.add(d);
164
+ merged.push(d);
165
+ }
166
+ };
167
+
168
+ for (const d of current) add(d);
169
+ if (toolName) {
170
+ for (const d of getKnownToolDirs(toolName)) add(d);
171
+ }
172
+
173
+ return { ...process.env, PATH: merged.join(delimiter) };
174
+ }
package/server.js ADDED
@@ -0,0 +1,125 @@
1
+ // H0wZy/mcp — Generic JSON-RPC 2.0 stdio MCP Server
2
+ // Lazy, robust, zero-dependency server engine handling the complete MCP lifecycle.
3
+
4
+ import readline from 'node:readline';
5
+ import { formatResilientResponse } from './errors.js';
6
+
7
+ /**
8
+ * @typedef {Object} MCPTool
9
+ * @property {string} name
10
+ * @property {string} description
11
+ * @property {Object} inputSchema
12
+ * @property {(args: any) => Promise<{ text: string, isError?: boolean } | string>} handler
13
+ */
14
+
15
+ /**
16
+ * Creates an MCP server instance over stdio.
17
+ *
18
+ * @param {Object} config
19
+ * @param {string} config.name Server name (e.g. "antigravity", "codex")
20
+ * @param {string} config.version Server semantic version
21
+ * @param {MCPTool[]} config.tools Tools exposed by this server
22
+ * @returns {{ start: () => void, handleMessage: (msg: any) => Promise<any> }}
23
+ */
24
+ export function createMcpServer({ name, version, tools = [] }) {
25
+ const toolMap = new Map(tools.map((t) => [t.name, t]));
26
+
27
+ function send(msg) {
28
+ process.stdout.write(JSON.stringify(msg) + '\n');
29
+ }
30
+
31
+ function ok(id, result) {
32
+ send({ jsonrpc: '2.0', id, result });
33
+ }
34
+
35
+ function fail(id, code, message) {
36
+ send({ jsonrpc: '2.0', id, error: { code, message } });
37
+ }
38
+
39
+ async function handleMessage(msg) {
40
+ if (!msg || typeof msg !== 'object') return;
41
+ const { id, method, params } = msg;
42
+
43
+ // Notifications carry no id and expect no response
44
+ if (method === 'notifications/initialized' || method === 'initialized') return;
45
+
46
+ switch (method) {
47
+ case 'initialize':
48
+ return ok(id, {
49
+ protocolVersion: params?.protocolVersion || '2025-06-18',
50
+ capabilities: { tools: {} },
51
+ serverInfo: { name, version: version || '1.0.0' },
52
+ });
53
+
54
+ case 'ping':
55
+ return ok(id, {});
56
+
57
+ case 'tools/list':
58
+ return ok(id, {
59
+ tools: tools.map((t) => ({
60
+ name: t.name,
61
+ description: t.description,
62
+ inputSchema: t.inputSchema,
63
+ })),
64
+ });
65
+
66
+ case 'tools/call': {
67
+ const toolName = params?.name;
68
+ const args = params?.arguments || {};
69
+ const tool = toolMap.get(toolName);
70
+
71
+ if (!tool) {
72
+ return fail(id, -32602, `Unknown tool: ${toolName}`);
73
+ }
74
+
75
+ try {
76
+ const result = await tool.handler(args);
77
+ if (typeof result === 'string') {
78
+ return ok(id, {
79
+ content: [{ type: 'text', text: result }],
80
+ isError: false,
81
+ });
82
+ }
83
+
84
+ return ok(id, {
85
+ content: [{ type: 'text', text: result.text || '' }],
86
+ isError: Boolean(result.isError),
87
+ });
88
+ } catch (err) {
89
+ const formatted = formatResilientResponse({
90
+ provider: name,
91
+ rawOutput: err?.message || String(err),
92
+ });
93
+ return ok(id, {
94
+ content: [{ type: 'text', text: formatted.text }],
95
+ isError: true,
96
+ });
97
+ }
98
+ }
99
+
100
+ default:
101
+ if (id !== undefined) {
102
+ fail(id, -32601, `Method not found: ${method}`);
103
+ }
104
+ }
105
+ }
106
+
107
+ function start() {
108
+ const rl = readline.createInterface({
109
+ input: process.stdin,
110
+ terminal: false,
111
+ });
112
+ rl.on('line', async (line) => {
113
+ const trimmed = line.trim();
114
+ if (!trimmed) return;
115
+ try {
116
+ const msg = JSON.parse(trimmed);
117
+ await handleMessage(msg);
118
+ } catch {
119
+ // Ignore unparseable lines silently per JSON-RPC over stdio
120
+ }
121
+ });
122
+ }
123
+
124
+ return { start, handleMessage };
125
+ }
@@ -0,0 +1,78 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createMcpServer } from '../server.js';
4
+ import { resolveBinary } from '../resolver.js';
5
+ import { isRateLimitError, isAuthError } from '../errors.js';
6
+
7
+ test('createMcpServer responds to initialize, ping, and tools/list', async () => {
8
+ let lastOutput = null;
9
+ const originalWrite = process.stdout.write;
10
+ process.stdout.write = (chunk) => {
11
+ lastOutput = JSON.parse(chunk.toString().trim());
12
+ return true;
13
+ };
14
+
15
+ try {
16
+ const server = createMcpServer({
17
+ name: 'test-server',
18
+ version: '1.2.3',
19
+ tools: [
20
+ {
21
+ name: 'test_tool',
22
+ description: 'A test tool',
23
+ inputSchema: { type: 'object' },
24
+ handler: async () => 'Hello from test tool!',
25
+ },
26
+ ],
27
+ });
28
+
29
+ // 1. initialize
30
+ await server.handleMessage({ jsonrpc: '2.0', id: 1, method: 'initialize' });
31
+ assert.equal(lastOutput.id, 1);
32
+ assert.equal(lastOutput.result.serverInfo.name, 'test-server');
33
+ assert.equal(lastOutput.result.serverInfo.version, '1.2.3');
34
+
35
+ // 2. ping
36
+ await server.handleMessage({ jsonrpc: '2.0', id: 2, method: 'ping' });
37
+ assert.equal(lastOutput.id, 2);
38
+ assert.deepEqual(lastOutput.result, {});
39
+
40
+ // 3. tools/list
41
+ await server.handleMessage({ jsonrpc: '2.0', id: 3, method: 'tools/list' });
42
+ assert.equal(lastOutput.id, 3);
43
+ assert.equal(lastOutput.result.tools.length, 1);
44
+ assert.equal(lastOutput.result.tools[0].name, 'test_tool');
45
+
46
+ // 4. tools/call
47
+ await server.handleMessage({
48
+ jsonrpc: '2.0',
49
+ id: 4,
50
+ method: 'tools/call',
51
+ params: { name: 'test_tool', arguments: {} },
52
+ });
53
+ assert.equal(lastOutput.id, 4);
54
+ assert.equal(lastOutput.result.isError, false);
55
+ assert.equal(lastOutput.result.content[0].text, 'Hello from test tool!');
56
+ } finally {
57
+ process.stdout.write = originalWrite;
58
+ }
59
+ });
60
+
61
+ test('resolver finds executables on system or honors override', () => {
62
+ const nodeBin = resolveBinary('node');
63
+ assert.ok(nodeBin, 'node executable should be resolved on all platforms');
64
+ assert.match(nodeBin, /node(\.exe)?$/i);
65
+
66
+ // Verify override support
67
+ process.env.TEST_CUSTOM_BIN = process.execPath;
68
+ const custom = resolveBinary('custom', 'TEST_CUSTOM_BIN');
69
+ assert.equal(custom, process.execPath);
70
+ delete process.env.TEST_CUSTOM_BIN;
71
+ });
72
+
73
+ test('errors detects rate limits and auth errors', () => {
74
+ assert.equal(isRateLimitError('ResourceExhausted: Quota exceeded 429'), true);
75
+ assert.equal(isRateLimitError('Normal response'), false);
76
+ assert.equal(isAuthError('Please sign in with: agy login'), true);
77
+ assert.equal(isAuthError('Success'), false);
78
+ });