@happyvertical/smrt-app-cli 0.37.1 → 0.37.3
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/dist/bin/smrt-mcp-bridge.js +41 -22
- package/dist/bridge-sTKlA6Hz.js +2 -0
- package/dist/config-DB4AMYT0.js +213 -0
- package/dist/index.js +870 -964
- package/package.json +7 -7
- package/dist/config-Bgq_EQoJ.js +0 -206
|
@@ -1,29 +1,48 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { l as runMcpStdioBridge } from "../config-DB4AMYT0.js";
|
|
3
|
+
//#region src/bin/smrt-mcp-bridge.ts
|
|
4
|
+
/**
|
|
5
|
+
* `smrt-mcp-bridge` — generic stdio MCP bridge.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
*
|
|
9
|
+
* ```
|
|
10
|
+
* smrt-mcp-bridge --env-prefix=WILLGRIFFIN \
|
|
11
|
+
* --name=willgriffin-mcp --version=0.1.0
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* All options can also be passed as env vars:
|
|
15
|
+
*
|
|
16
|
+
* - `SMRT_MCP_ENV_PREFIX` — env-prefix the bridge uses to look up the app's
|
|
17
|
+
* server URL/token/config file.
|
|
18
|
+
* - `SMRT_MCP_APP_SLUG` — directory name under `~/.config`.
|
|
19
|
+
* - `SMRT_MCP_SERVER_NAME` / `SMRT_MCP_SERVER_VERSION` — local server identity.
|
|
20
|
+
* - `SMRT_MCP_DEFAULT_SERVER_URL` — fallback server URL.
|
|
21
|
+
*
|
|
22
|
+
* Apps that want their own branded bin should call `runMcpStdioBridge`
|
|
23
|
+
* directly from `@happyvertical/smrt-app-mcp/cli` instead of going through
|
|
24
|
+
* this generic entrypoint.
|
|
25
|
+
*/
|
|
3
26
|
function readArg(name) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
return process.argv[flagIdx + 1];
|
|
10
|
-
}
|
|
11
|
-
return void 0;
|
|
27
|
+
const prefix = `--${name}=`;
|
|
28
|
+
const match = process.argv.find((arg) => arg.startsWith(prefix));
|
|
29
|
+
if (match) return match.slice(prefix.length);
|
|
30
|
+
const flagIdx = process.argv.indexOf(`--${name}`);
|
|
31
|
+
if (flagIdx >= 0 && flagIdx + 1 < process.argv.length) return process.argv[flagIdx + 1];
|
|
12
32
|
}
|
|
13
|
-
|
|
33
|
+
var envPrefix = readArg("env-prefix") ?? process.env.SMRT_MCP_ENV_PREFIX ?? "";
|
|
14
34
|
if (!envPrefix) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
);
|
|
18
|
-
process.exit(2);
|
|
35
|
+
console.error("smrt-mcp-bridge: --env-prefix=<PREFIX> (or SMRT_MCP_ENV_PREFIX) is required.");
|
|
36
|
+
process.exit(2);
|
|
19
37
|
}
|
|
20
|
-
const appSlug = readArg("app-slug") ?? process.env.SMRT_MCP_APP_SLUG;
|
|
21
|
-
const defaultServerUrl = readArg("default-server-url") ?? process.env.SMRT_MCP_DEFAULT_SERVER_URL;
|
|
22
|
-
const serverName = readArg("name") ?? process.env.SMRT_MCP_SERVER_NAME ?? "smrt-app-mcp";
|
|
23
|
-
const serverVersion = readArg("version") ?? process.env.SMRT_MCP_SERVER_VERSION ?? "0.0.0";
|
|
24
38
|
await runMcpStdioBridge({
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
39
|
+
envPrefix,
|
|
40
|
+
appSlug: readArg("app-slug") ?? process.env.SMRT_MCP_APP_SLUG,
|
|
41
|
+
defaultServerUrl: readArg("default-server-url") ?? process.env.SMRT_MCP_DEFAULT_SERVER_URL,
|
|
42
|
+
serverInfo: {
|
|
43
|
+
name: readArg("name") ?? process.env.SMRT_MCP_SERVER_NAME ?? "smrt-app-mcp",
|
|
44
|
+
version: readArg("version") ?? process.env.SMRT_MCP_SERVER_VERSION ?? "0.0.0"
|
|
45
|
+
}
|
|
29
46
|
});
|
|
47
|
+
//#endregion
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
+
//#region src/bridge.ts
|
|
9
|
+
/**
|
|
10
|
+
* Stdio MCP bridge — pipes a remote SMRT app's HTTP MCP surface
|
|
11
|
+
* (`/api/mcp/tools` + `/api/mcp/call`) to a local stdio MCP server so that
|
|
12
|
+
* editors and AI clients can connect to it.
|
|
13
|
+
*
|
|
14
|
+
* Apps wire this up by providing their own bin script:
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* #!/usr/bin/env node
|
|
18
|
+
* import { runMcpStdioBridge } from '@happyvertical/smrt-app-cli';
|
|
19
|
+
* await runMcpStdioBridge({
|
|
20
|
+
* envPrefix: 'WILLGRIFFIN',
|
|
21
|
+
* serverInfo: { name: 'willgriffin-mcp', version: '0.1.0' },
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* The package also ships a `smrt-mcp-bridge` bin (see `bin/smrt-mcp-bridge`)
|
|
26
|
+
* that reads `--env-prefix=…` from argv for ad-hoc use without writing a
|
|
27
|
+
* package-specific entry point.
|
|
28
|
+
*
|
|
29
|
+
* @packageDocumentation
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Wire up the stdio server. Use `runMcpStdioBridge` for a one-call entry
|
|
33
|
+
* point in `bin/` scripts; this lower-level form is exposed for tests.
|
|
34
|
+
*/
|
|
35
|
+
function createMcpStdioBridge(options) {
|
|
36
|
+
const toolsPath = options.toolsPath ?? "/api/mcp/tools";
|
|
37
|
+
const callPath = options.callPath ?? "/api/mcp/call";
|
|
38
|
+
const server = new Server(options.serverInfo, { capabilities: { tools: {} } });
|
|
39
|
+
server.setRequestHandler(ListToolsRequestSchema, async (_request) => {
|
|
40
|
+
return requestJson(options, toolsPath, { method: "GET" }, { fetch: options.fetch });
|
|
41
|
+
});
|
|
42
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
43
|
+
const { name, arguments: args = {} } = request.params;
|
|
44
|
+
try {
|
|
45
|
+
return await requestJson(options, callPath, {
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
arguments: args,
|
|
48
|
+
name
|
|
49
|
+
}),
|
|
50
|
+
method: "POST"
|
|
51
|
+
}, { fetch: options.fetch });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return {
|
|
54
|
+
content: [{
|
|
55
|
+
text: error instanceof Error ? error.message : "MCP tool call failed.",
|
|
56
|
+
type: "text"
|
|
57
|
+
}],
|
|
58
|
+
isError: true
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return {
|
|
63
|
+
server,
|
|
64
|
+
connect: () => server.connect(new StdioServerTransport())
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* One-call entry point — instantiate the bridge and connect stdio. Returns
|
|
69
|
+
* a `Promise<void>` that resolves once the transport disconnects.
|
|
70
|
+
*/
|
|
71
|
+
async function runMcpStdioBridge(options) {
|
|
72
|
+
const { connect } = createMcpStdioBridge(options);
|
|
73
|
+
await connect();
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/config.ts
|
|
77
|
+
/**
|
|
78
|
+
* Shared CLI helpers for SMRT apps: a small config file format, env var
|
|
79
|
+
* resolution with a configurable prefix, and a minimal JSON HTTP client
|
|
80
|
+
* that knows how to bear the stored CLI token.
|
|
81
|
+
*
|
|
82
|
+
* The same helpers back the stdio bridge (`smrt-mcp-bridge`) and any
|
|
83
|
+
* app-specific `data`-style commands that talk to the app's HTTP API.
|
|
84
|
+
*
|
|
85
|
+
* @packageDocumentation
|
|
86
|
+
*/
|
|
87
|
+
var DEFAULT_LOCAL_SERVER = "http://localhost:5173";
|
|
88
|
+
function configFilePath(context) {
|
|
89
|
+
const override = process.env[`${context.envPrefix}_CLI_CONFIG`];
|
|
90
|
+
if (override) return override;
|
|
91
|
+
const slug = context.appSlug ?? context.envPrefix.toLowerCase();
|
|
92
|
+
return join(homedir(), ".config", slug, "config.json");
|
|
93
|
+
}
|
|
94
|
+
/** Read the CLI config file. Missing file → empty config. */
|
|
95
|
+
async function loadCliConfig(context) {
|
|
96
|
+
try {
|
|
97
|
+
const raw = await readFile(configFilePath(context), "utf8");
|
|
98
|
+
if (!raw.trim()) return {};
|
|
99
|
+
return JSON.parse(raw);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return {};
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Write the CLI config to disk with 0600 permissions (the token is a bearer
|
|
107
|
+
* credential — anyone who can read the file can impersonate the user).
|
|
108
|
+
*/
|
|
109
|
+
async function saveCliConfig(context, config) {
|
|
110
|
+
const path = configFilePath(context);
|
|
111
|
+
const dir = dirname(path);
|
|
112
|
+
await mkdir(dir, {
|
|
113
|
+
recursive: true,
|
|
114
|
+
mode: 448
|
|
115
|
+
});
|
|
116
|
+
await chmod(dir, 448).catch(() => void 0);
|
|
117
|
+
const tmp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
118
|
+
try {
|
|
119
|
+
await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 384 });
|
|
120
|
+
await chmod(tmp, 384);
|
|
121
|
+
await rename(tmp, path);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
await unlink(tmp).catch(() => void 0);
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Resolve the server URL: env var → config file → `defaultServerUrl`. */
|
|
128
|
+
async function getServerUrl(context, config) {
|
|
129
|
+
const resolved = config ?? await loadCliConfig(context);
|
|
130
|
+
return (process.env[`${context.envPrefix}_SERVER_URL`] ?? resolved.serverUrl ?? context.defaultServerUrl ?? DEFAULT_LOCAL_SERVER).replace(/\/+$/u, "");
|
|
131
|
+
}
|
|
132
|
+
/** Resolve the stored bearer token (env var wins over config). */
|
|
133
|
+
async function getStoredToken(context, config) {
|
|
134
|
+
const resolved = config ?? await loadCliConfig(context);
|
|
135
|
+
return process.env[`${context.envPrefix}_TOKEN`] ?? resolved.token;
|
|
136
|
+
}
|
|
137
|
+
/** Remove the token from the config file (e.g. on logout). */
|
|
138
|
+
async function clearStoredToken(context) {
|
|
139
|
+
const config = await loadCliConfig(context);
|
|
140
|
+
delete config.token;
|
|
141
|
+
await saveCliConfig(context, config);
|
|
142
|
+
}
|
|
143
|
+
/** Persist a login: writes both serverUrl and token to the config file. */
|
|
144
|
+
async function saveAuth(context, serverUrl, token) {
|
|
145
|
+
await saveCliConfig(context, {
|
|
146
|
+
...await loadCliConfig(context),
|
|
147
|
+
serverUrl: serverUrl.replace(/\/+$/u, ""),
|
|
148
|
+
token
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
var DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
|
|
152
|
+
/**
|
|
153
|
+
* JSON request helper that injects the stored bearer token. Returns the
|
|
154
|
+
* parsed JSON body on success; throws an `Error` with the server's `error`
|
|
155
|
+
* field (or `HTTP <status>`) on failure.
|
|
156
|
+
*/
|
|
157
|
+
async function requestJson(context, path, init = {}, options = {}) {
|
|
158
|
+
const config = options.loadedConfig ?? await loadCliConfig(context);
|
|
159
|
+
const serverUrl = (options.serverUrl ?? await getServerUrl(context, config)).replace(/\/+$/u, "");
|
|
160
|
+
const token = await getStoredToken(context, config);
|
|
161
|
+
const headers = new Headers(init.headers);
|
|
162
|
+
if (options.requireAuth && options.auth !== false && !token) throw new Error(`Not authenticated. Run \`${context.envPrefix.toLowerCase()} auth login\` first.`);
|
|
163
|
+
if (!headers.has("content-type") && init.body) headers.set("content-type", "application/json");
|
|
164
|
+
if (options.auth !== false && token) headers.set("authorization", `Bearer ${token}`);
|
|
165
|
+
const response = await (options.fetch ?? fetch)(`${serverUrl}${path}`, {
|
|
166
|
+
...init,
|
|
167
|
+
headers
|
|
168
|
+
});
|
|
169
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
170
|
+
const text = await readBodyWithCap(response, options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES);
|
|
171
|
+
const parsed = contentType.includes("application/json") && text ? JSON.parse(text) : text;
|
|
172
|
+
if (!response.ok) {
|
|
173
|
+
const message = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : `HTTP ${response.status}: ${response.statusText}`;
|
|
174
|
+
throw Object.assign(new Error(message), { status: response.status });
|
|
175
|
+
}
|
|
176
|
+
return parsed;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Read the response body into a UTF-8 string, capping at `maxBytes`. On
|
|
180
|
+
* overflow, throw with a clear message — better than OOM-ing the CLI
|
|
181
|
+
* when a server returns a multi-GB body. Streams chunk-by-chunk so the
|
|
182
|
+
* check fires before the whole body is buffered. (#1311 review A4.)
|
|
183
|
+
*/
|
|
184
|
+
async function readBodyWithCap(response, maxBytes) {
|
|
185
|
+
if (!response.body) return "";
|
|
186
|
+
const cl = Number(response.headers.get("content-length") ?? "");
|
|
187
|
+
if (Number.isFinite(cl) && cl > maxBytes) {
|
|
188
|
+
try {
|
|
189
|
+
await response.body.cancel();
|
|
190
|
+
} catch {}
|
|
191
|
+
throw new Error(`Response too large: ${cl} bytes exceeds ${maxBytes}-byte cap`);
|
|
192
|
+
}
|
|
193
|
+
const reader = response.body.getReader();
|
|
194
|
+
const chunks = [];
|
|
195
|
+
let size = 0;
|
|
196
|
+
try {
|
|
197
|
+
while (true) {
|
|
198
|
+
const { done, value } = await reader.read();
|
|
199
|
+
if (done) break;
|
|
200
|
+
if (!value) continue;
|
|
201
|
+
size += value.byteLength;
|
|
202
|
+
if (size > maxBytes) throw new Error(`Response too large: exceeded ${maxBytes}-byte cap mid-stream`);
|
|
203
|
+
chunks.push(value);
|
|
204
|
+
}
|
|
205
|
+
} finally {
|
|
206
|
+
try {
|
|
207
|
+
reader.releaseLock();
|
|
208
|
+
} catch {}
|
|
209
|
+
}
|
|
210
|
+
return new TextDecoder().decode(Buffer.concat(chunks.map((c) => Buffer.from(c))));
|
|
211
|
+
}
|
|
212
|
+
//#endregion
|
|
213
|
+
export { requestJson as a, createMcpStdioBridge as c, loadCliConfig as i, runMcpStdioBridge as l, getServerUrl as n, saveAuth as o, getStoredToken as r, saveCliConfig as s, clearStoredToken as t };
|