@happyvertical/smrt-app-cli 0.37.2 → 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.
@@ -1,29 +1,48 @@
1
1
  #!/usr/bin/env node
2
- import { d as runMcpStdioBridge } from "../config-Bgq_EQoJ.js";
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
- const prefix = `--${name}=`;
5
- const match = process.argv.find((arg) => arg.startsWith(prefix));
6
- if (match) return match.slice(prefix.length);
7
- const flagIdx = process.argv.indexOf(`--${name}`);
8
- if (flagIdx >= 0 && flagIdx + 1 < process.argv.length) {
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
- const envPrefix = readArg("env-prefix") ?? process.env.SMRT_MCP_ENV_PREFIX ?? "";
33
+ var envPrefix = readArg("env-prefix") ?? process.env.SMRT_MCP_ENV_PREFIX ?? "";
14
34
  if (!envPrefix) {
15
- console.error(
16
- "smrt-mcp-bridge: --env-prefix=<PREFIX> (or SMRT_MCP_ENV_PREFIX) is required."
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
- envPrefix,
26
- appSlug,
27
- defaultServerUrl,
28
- serverInfo: { name: serverName, version: serverVersion }
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,2 @@
1
+ import { l as runMcpStdioBridge } from "./config-DB4AMYT0.js";
2
+ export { runMcpStdioBridge };
@@ -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 };