@happyvertical/smrt-app-cli 0.40.51 → 0.40.53
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/README.md +35 -0
- package/dist/bin/smrt-mcp-bridge.js +1 -1
- package/dist/bridge-BgtGXmYP.js +2 -0
- package/dist/config-CDhknIOW.js +422 -0
- package/dist/index.d.ts +119 -0
- package/dist/index.js +42 -12
- package/package.json +3 -3
- package/dist/bridge-D0LJc3mN.js +0 -2
- package/dist/config-DvxwoFks.js +0 -231
package/README.md
CHANGED
|
@@ -46,6 +46,41 @@ Resources and their commands are discovered from the app's `/_resources`
|
|
|
46
46
|
surface. JSON Schema becomes CLI flags, while unsupported schema shapes are
|
|
47
47
|
reported instead of silently guessed.
|
|
48
48
|
|
|
49
|
+
## Discovery conformance artifact
|
|
50
|
+
|
|
51
|
+
`createResourceListHandler()` includes an `artifact` beside the compatible
|
|
52
|
+
`user`, `warnings`, and `resources` response fields. The artifact has a stable
|
|
53
|
+
schema selector, version, canonical ordering, and a `sha256:<hex>` digest. The
|
|
54
|
+
CLI validates it before using its embedded discovery payload.
|
|
55
|
+
|
|
56
|
+
For a released downstream app, install the exact published
|
|
57
|
+
`@happyvertical/smrt-users` version and pin a captured artifact like this:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import {
|
|
61
|
+
validateDiscoveryConformanceArtifact,
|
|
62
|
+
} from '@happyvertical/smrt-users/app-contract';
|
|
63
|
+
|
|
64
|
+
const response = await fetch('https://app.example/api/_resources');
|
|
65
|
+
const body = await response.json();
|
|
66
|
+
const artifact = validateDiscoveryConformanceArtifact(body.artifact);
|
|
67
|
+
|
|
68
|
+
console.log(artifact.schema, artifact.version, artifact.integrity.digest);
|
|
69
|
+
// Store all three selectors and the canonical JSON in your packaged-runtime test.
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The result contract embedded in the artifact names the common `code`,
|
|
73
|
+
`message`, `details`, `retryable`, `correlationId`, `idempotencyKey`, and
|
|
74
|
+
`expectedVersion` metadata. `mcp tools` and `mcp call` print this as a JSON
|
|
75
|
+
success/error envelope. HTTP failures may use either a conventional top-level
|
|
76
|
+
metadata object or `{ error: metadata }`; both normalize to that same shape.
|
|
77
|
+
Generated action failures use the latter form with
|
|
78
|
+
`{ error: { ok: false, code, message, status, ... } }`; the CLI reports the
|
|
79
|
+
HTTP status beside that normalized metadata.
|
|
80
|
+
The stdio bridge carries it in MCP `_meta` under
|
|
81
|
+
`io.happyvertical/smrt`. It deliberately does not synthesize MCP
|
|
82
|
+
`structuredContent`.
|
|
83
|
+
|
|
49
84
|
## Add an app-specific command
|
|
50
85
|
|
|
51
86
|
```ts
|
|
@@ -0,0 +1,422 @@
|
|
|
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 { SMRT_MCP_RESULT_METADATA_KEY } from "@happyvertical/smrt-users/app-contract";
|
|
6
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
//#region src/bridge.ts
|
|
10
|
+
/**
|
|
11
|
+
* Stdio MCP bridge — pipes a remote SMRT app's HTTP MCP surface
|
|
12
|
+
* (`/api/mcp/tools` + `/api/mcp/call`) to a local stdio MCP server so that
|
|
13
|
+
* editors and AI clients can connect to it.
|
|
14
|
+
*
|
|
15
|
+
* Apps wire this up by providing their own bin script:
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* #!/usr/bin/env node
|
|
19
|
+
* import { runMcpStdioBridge } from '@happyvertical/smrt-app-cli';
|
|
20
|
+
* await runMcpStdioBridge({
|
|
21
|
+
* envPrefix: 'WILLGRIFFIN',
|
|
22
|
+
* serverInfo: { name: 'willgriffin-mcp', version: '0.1.0' },
|
|
23
|
+
* });
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* The package also ships a `smrt-mcp-bridge` bin (see `bin/smrt-mcp-bridge`)
|
|
27
|
+
* that reads `--env-prefix=…` from argv for ad-hoc use without writing a
|
|
28
|
+
* package-specific entry point.
|
|
29
|
+
*
|
|
30
|
+
* @packageDocumentation
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Wire up the stdio server. Use `runMcpStdioBridge` for a one-call entry
|
|
34
|
+
* point in `bin/` scripts; this lower-level form is exposed for tests.
|
|
35
|
+
*/
|
|
36
|
+
function createMcpStdioBridge(options) {
|
|
37
|
+
const toolsPath = options.toolsPath ?? "/api/mcp/tools";
|
|
38
|
+
const callPath = options.callPath ?? "/api/mcp/call";
|
|
39
|
+
const server = new Server(options.serverInfo, { capabilities: { tools: {} } });
|
|
40
|
+
server.setRequestHandler(ListToolsRequestSchema, async (_request) => {
|
|
41
|
+
const outcome = await requestJsonResult(options, toolsPath, { method: "GET" }, { fetch: options.fetch });
|
|
42
|
+
if (!outcome.ok) throw toMcpTransportError(outcome.error);
|
|
43
|
+
return withMcpMetadata(outcome.result, outcome.metadata);
|
|
44
|
+
});
|
|
45
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
46
|
+
const { name, arguments: args = {} } = request.params;
|
|
47
|
+
return formatMcpCallResult(await requestJsonResult(options, callPath, {
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
arguments: args,
|
|
50
|
+
name
|
|
51
|
+
}),
|
|
52
|
+
method: "POST"
|
|
53
|
+
}, { fetch: options.fetch }));
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
server,
|
|
57
|
+
connect: () => server.connect(new StdioServerTransport())
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Preserve the HTTP result/error envelope in protocol-permitted MCP metadata.
|
|
62
|
+
* This deliberately does not create `structuredContent`; #2149 owns declared
|
|
63
|
+
* output schema semantics for generated tools.
|
|
64
|
+
*/
|
|
65
|
+
function formatMcpCallResult(outcome) {
|
|
66
|
+
if (!outcome.ok) {
|
|
67
|
+
const error = sanitizeMetadata(outcome.error);
|
|
68
|
+
return withMcpMetadata({
|
|
69
|
+
content: [{
|
|
70
|
+
text: error.message ?? error.code,
|
|
71
|
+
type: "text"
|
|
72
|
+
}],
|
|
73
|
+
isError: true
|
|
74
|
+
}, error);
|
|
75
|
+
}
|
|
76
|
+
const metadata = isMcpErrorResult(outcome.result) ? {
|
|
77
|
+
...outcome.metadata,
|
|
78
|
+
code: outcome.metadata.code === "ok" ? "mcp_tool_error" : outcome.metadata.code,
|
|
79
|
+
message: outcome.metadata.message ?? resultText(outcome.result)
|
|
80
|
+
} : outcome.metadata;
|
|
81
|
+
return withMcpMetadata(isMcpErrorResult(outcome.result) ? redactMcpErrorContent(outcome.result) : outcome.result, metadata);
|
|
82
|
+
}
|
|
83
|
+
function withMcpMetadata(result, metadata) {
|
|
84
|
+
const existing = isRecord$1(result._meta) ? result._meta : {};
|
|
85
|
+
return {
|
|
86
|
+
...result,
|
|
87
|
+
_meta: {
|
|
88
|
+
...existing,
|
|
89
|
+
[SMRT_MCP_RESULT_METADATA_KEY]: sanitizeMetadata(metadata)
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function sanitizeMetadata(metadata) {
|
|
94
|
+
return {
|
|
95
|
+
...metadata,
|
|
96
|
+
...metadata.message ? { message: String(redactTransportValue(metadata.message)) } : {},
|
|
97
|
+
...metadata.details !== void 0 ? { details: redactTransportValue(metadata.details) } : {},
|
|
98
|
+
...metadata.correlationId ? { correlationId: String(redactTransportValue(metadata.correlationId)) } : {}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Convert an HTTP transport failure into an MCP JSON-RPC error safely. */
|
|
102
|
+
function toMcpTransportError(metadata) {
|
|
103
|
+
const sanitized = sanitizeMetadata(metadata);
|
|
104
|
+
return new McpError(ErrorCode.InternalError, sanitized.message ?? sanitized.code, { [SMRT_MCP_RESULT_METADATA_KEY]: sanitized });
|
|
105
|
+
}
|
|
106
|
+
function isMcpErrorResult(result) {
|
|
107
|
+
return result.isError === true;
|
|
108
|
+
}
|
|
109
|
+
function resultText(result) {
|
|
110
|
+
const content = result.content;
|
|
111
|
+
if (!Array.isArray(content)) return void 0;
|
|
112
|
+
return content.find((entry) => isRecord$1(entry) && typeof entry.text === "string")?.text;
|
|
113
|
+
}
|
|
114
|
+
function redactMcpErrorContent(result) {
|
|
115
|
+
const content = result.content;
|
|
116
|
+
if (!Array.isArray(content)) return result;
|
|
117
|
+
return {
|
|
118
|
+
...result,
|
|
119
|
+
content: content.map((entry) => isRecord$1(entry) && typeof entry.text === "string" ? {
|
|
120
|
+
...entry,
|
|
121
|
+
text: String(redactTransportValue(entry.text))
|
|
122
|
+
} : entry)
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function isRecord$1(value) {
|
|
126
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* One-call entry point — instantiate the bridge and connect stdio. Returns
|
|
130
|
+
* a `Promise<void>` that resolves once the transport disconnects.
|
|
131
|
+
*/
|
|
132
|
+
async function runMcpStdioBridge(options) {
|
|
133
|
+
const { connect } = createMcpStdioBridge(options);
|
|
134
|
+
await connect();
|
|
135
|
+
}
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/config.ts
|
|
138
|
+
/**
|
|
139
|
+
* Shared CLI helpers for SMRT apps: a small config file format, env var
|
|
140
|
+
* resolution with a configurable prefix, and a minimal JSON HTTP client
|
|
141
|
+
* that knows how to bear the stored CLI token.
|
|
142
|
+
*
|
|
143
|
+
* The same helpers back the stdio bridge (`smrt-mcp-bridge`) and any
|
|
144
|
+
* app-specific `data`-style commands that talk to the app's HTTP API.
|
|
145
|
+
*
|
|
146
|
+
* @packageDocumentation
|
|
147
|
+
*/
|
|
148
|
+
var DEFAULT_LOCAL_SERVER = "http://localhost:5173";
|
|
149
|
+
function configFilePath(context) {
|
|
150
|
+
const override = process.env[`${context.envPrefix}_CLI_CONFIG`];
|
|
151
|
+
if (override) return override;
|
|
152
|
+
const slug = context.appSlug ?? context.envPrefix.toLowerCase();
|
|
153
|
+
return join(homedir(), ".config", slug, "config.json");
|
|
154
|
+
}
|
|
155
|
+
/** Read the CLI config file. Missing file → empty config. */
|
|
156
|
+
async function loadCliConfig(context) {
|
|
157
|
+
try {
|
|
158
|
+
const raw = await readFile(configFilePath(context), "utf8");
|
|
159
|
+
if (!raw.trim()) return {};
|
|
160
|
+
return JSON.parse(raw);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return {};
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Write the CLI config to disk with 0600 permissions (the token is a bearer
|
|
168
|
+
* credential — anyone who can read the file can impersonate the user).
|
|
169
|
+
*/
|
|
170
|
+
async function saveCliConfig(context, config) {
|
|
171
|
+
const path = configFilePath(context);
|
|
172
|
+
const dir = dirname(path);
|
|
173
|
+
await mkdir(dir, {
|
|
174
|
+
recursive: true,
|
|
175
|
+
mode: 448
|
|
176
|
+
});
|
|
177
|
+
await chmod(dir, 448).catch(() => void 0);
|
|
178
|
+
const tmp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
179
|
+
try {
|
|
180
|
+
await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 384 });
|
|
181
|
+
await chmod(tmp, 384);
|
|
182
|
+
await rename(tmp, path);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
await unlink(tmp).catch(() => void 0);
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Resolve the server URL: env var → config file → `defaultServerUrl`. */
|
|
189
|
+
async function getServerUrl(context, config) {
|
|
190
|
+
const resolved = config ?? await loadCliConfig(context);
|
|
191
|
+
return (process.env[`${context.envPrefix}_SERVER_URL`] ?? resolved.serverUrl ?? context.defaultServerUrl ?? DEFAULT_LOCAL_SERVER).replace(/\/+$/u, "");
|
|
192
|
+
}
|
|
193
|
+
/** Resolve a bearer token only when it is bound to the exact target server. */
|
|
194
|
+
async function getStoredToken(context, config, serverUrl) {
|
|
195
|
+
const resolved = config ?? await loadCliConfig(context);
|
|
196
|
+
const targetServer = (serverUrl ?? await getServerUrl(context, resolved)).replace(/\/+$/u, "");
|
|
197
|
+
const environmentToken = process.env[`${context.envPrefix}_TOKEN`];
|
|
198
|
+
const environmentServer = process.env[`${context.envPrefix}_SERVER_URL`]?.replace(/\/+$/u, "");
|
|
199
|
+
if (environmentToken) return environmentServer === targetServer ? environmentToken : void 0;
|
|
200
|
+
const configuredServer = resolved.serverUrl?.replace(/\/+$/u, "");
|
|
201
|
+
if (!configuredServer || configuredServer !== targetServer) return void 0;
|
|
202
|
+
if (resolved.credentialIssuer) return resolved.tokensByIssuer?.[resolved.credentialIssuer];
|
|
203
|
+
return resolved.token;
|
|
204
|
+
}
|
|
205
|
+
/** Remove the token from the config file (e.g. on logout). */
|
|
206
|
+
async function clearStoredToken(context) {
|
|
207
|
+
const config = await loadCliConfig(context);
|
|
208
|
+
if (config.credentialIssuer && config.tokensByIssuer) {
|
|
209
|
+
delete config.tokensByIssuer[config.credentialIssuer];
|
|
210
|
+
if (Object.keys(config.tokensByIssuer).length === 0) delete config.tokensByIssuer;
|
|
211
|
+
}
|
|
212
|
+
delete config.credentialIssuer;
|
|
213
|
+
delete config.token;
|
|
214
|
+
await saveCliConfig(context, config);
|
|
215
|
+
}
|
|
216
|
+
/** Persist a login with the bearer token keyed by its exact issuer. */
|
|
217
|
+
async function saveAuth(context, serverUrl, token, issuer = serverUrl) {
|
|
218
|
+
const config = await loadCliConfig(context);
|
|
219
|
+
const normalizedServerUrl = serverUrl.replace(/\/+$/u, "");
|
|
220
|
+
if (!issuer.trim()) throw new Error("Credential issuer must not be empty.");
|
|
221
|
+
const exactIssuer = issuer;
|
|
222
|
+
await saveCliConfig(context, {
|
|
223
|
+
...config,
|
|
224
|
+
credentialIssuer: exactIssuer,
|
|
225
|
+
serverUrl: normalizedServerUrl,
|
|
226
|
+
token: void 0,
|
|
227
|
+
tokensByIssuer: { [exactIssuer]: token }
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
var DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
|
|
231
|
+
/** Error compatibility wrapper returned by the legacy throwing helper. */
|
|
232
|
+
var AppCliRequestError = class extends Error {
|
|
233
|
+
status;
|
|
234
|
+
metadata;
|
|
235
|
+
constructor(status, metadata) {
|
|
236
|
+
super(metadata.message ?? `HTTP ${status}`);
|
|
237
|
+
this.name = "AppCliRequestError";
|
|
238
|
+
this.status = status;
|
|
239
|
+
this.metadata = metadata;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
/**
|
|
243
|
+
* JSON request helper that injects the stored bearer token. Returns the
|
|
244
|
+
* parsed JSON body on success; throws an `Error` with the server's `error`
|
|
245
|
+
* field (or `HTTP <status>`) on failure.
|
|
246
|
+
*/
|
|
247
|
+
async function requestJson(context, path, init = {}, options = {}) {
|
|
248
|
+
const outcome = await requestJsonResult(context, path, init, options);
|
|
249
|
+
if (!outcome.ok) throw new AppCliRequestError(outcome.status, outcome.error);
|
|
250
|
+
return outcome.result;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Issue a JSON request without flattening a failure to an Error message.
|
|
254
|
+
*
|
|
255
|
+
* Metadata is deliberately copied only from conventional result/error fields
|
|
256
|
+
* and response headers. Every value crossing this boundary is redacted before
|
|
257
|
+
* a CLI or MCP client can observe it.
|
|
258
|
+
*/
|
|
259
|
+
async function requestJsonResult(context, path, init = {}, options = {}) {
|
|
260
|
+
const config = options.loadedConfig ?? await loadCliConfig(context);
|
|
261
|
+
const serverUrl = (options.serverUrl ?? await getServerUrl(context, config)).replace(/\/+$/u, "");
|
|
262
|
+
const token = await getStoredToken(context, config, serverUrl);
|
|
263
|
+
const headers = new Headers(init.headers);
|
|
264
|
+
if (options.requireAuth && options.auth !== false && !token) return failure(401, {
|
|
265
|
+
code: "not_authenticated",
|
|
266
|
+
message: `Not authenticated. Run \`${context.envPrefix.toLowerCase()} auth login\` first.`,
|
|
267
|
+
retryable: false
|
|
268
|
+
});
|
|
269
|
+
if (!headers.has("content-type") && init.body) headers.set("content-type", "application/json");
|
|
270
|
+
if (options.auth !== false && token) headers.set("authorization", `Bearer ${token}`);
|
|
271
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
272
|
+
let response;
|
|
273
|
+
try {
|
|
274
|
+
response = await fetchImpl(`${serverUrl}${path}`, {
|
|
275
|
+
...init,
|
|
276
|
+
headers
|
|
277
|
+
});
|
|
278
|
+
} catch (error) {
|
|
279
|
+
return failure(0, {
|
|
280
|
+
code: "network_error",
|
|
281
|
+
message: redactMessage(error instanceof Error ? error.message : String(error)),
|
|
282
|
+
retryable: true
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
286
|
+
const maxBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
287
|
+
let text;
|
|
288
|
+
try {
|
|
289
|
+
text = await readBodyWithCap(response, maxBytes);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (!(error instanceof ResponseTooLargeError)) return failure(0, {
|
|
292
|
+
code: "network_error",
|
|
293
|
+
message: redactMessage(error instanceof Error ? error.message : String(error)),
|
|
294
|
+
retryable: true
|
|
295
|
+
});
|
|
296
|
+
return failure(response.status, {
|
|
297
|
+
code: "response_too_large",
|
|
298
|
+
message: redactMessage(error instanceof Error ? error.message : String(error)),
|
|
299
|
+
retryable: response.status >= 500
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
let parsed = text;
|
|
303
|
+
if (contentType.includes("application/json") && text) try {
|
|
304
|
+
parsed = JSON.parse(text);
|
|
305
|
+
} catch {
|
|
306
|
+
return failure(response.status, {
|
|
307
|
+
code: "invalid_json_response",
|
|
308
|
+
message: "Server returned invalid JSON.",
|
|
309
|
+
retryable: response.status >= 500
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (!response.ok) return failure(response.status, metadataFromResponse(parsed, response.headers, `http_${response.status}`, response.status >= 500 || response.status === 408 || response.status === 429, `HTTP ${response.status}: ${response.statusText}`));
|
|
313
|
+
return {
|
|
314
|
+
ok: true,
|
|
315
|
+
result: parsed,
|
|
316
|
+
metadata: metadataFromResponse(parsed, response.headers, "ok", false)
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/** Redact conventional credential fields before emitting transport metadata. */
|
|
320
|
+
function redactTransportValue(value) {
|
|
321
|
+
if (typeof value === "string") return redactMessage(value);
|
|
322
|
+
if (Array.isArray(value)) return value.map(redactTransportValue);
|
|
323
|
+
if (!isRecord(value)) return value;
|
|
324
|
+
const redacted = {};
|
|
325
|
+
for (const [key, nested] of Object.entries(value)) redacted[key] = isSensitiveKey(key) ? "[REDACTED]" : redactTransportValue(nested);
|
|
326
|
+
return redacted;
|
|
327
|
+
}
|
|
328
|
+
function failure(status, error) {
|
|
329
|
+
return {
|
|
330
|
+
ok: false,
|
|
331
|
+
status,
|
|
332
|
+
error
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
function metadataFromResponse(payload, headers, fallbackCode, fallbackRetryable, fallbackMessage) {
|
|
336
|
+
const record = isRecord(payload) ? payload : void 0;
|
|
337
|
+
const error = record && isRecord(record.error) ? record.error : void 0;
|
|
338
|
+
const meta = firstRecord(error, record && isRecord(record.metadata) ? record.metadata : void 0, record && isRecord(record.meta) ? record.meta : void 0, getMcpMetadata(record), record);
|
|
339
|
+
const message = firstString(meta?.message, error?.message, typeof record?.error === "string" ? record.error : void 0, fallbackMessage);
|
|
340
|
+
const details = meta?.details ?? error?.details;
|
|
341
|
+
const correlationId = firstString(meta?.correlationId, error?.correlationId, headers.get("x-correlation-id") ?? void 0, headers.get("x-request-id") ?? void 0);
|
|
342
|
+
const idempotencyKey = declaredActionField(meta?.idempotencyKey ?? meta?.idempotency);
|
|
343
|
+
const expectedVersion = declaredActionField(meta?.expectedVersion);
|
|
344
|
+
return {
|
|
345
|
+
code: firstString(meta?.code, error?.code) ?? fallbackCode,
|
|
346
|
+
...message ? { message: redactMessage(message) } : {},
|
|
347
|
+
...details !== void 0 ? { details: redactTransportValue(details) } : {},
|
|
348
|
+
retryable: typeof meta?.retryable === "boolean" ? meta.retryable : typeof error?.retryable === "boolean" ? error.retryable : fallbackRetryable,
|
|
349
|
+
...correlationId ? { correlationId: redactMessage(correlationId) } : {},
|
|
350
|
+
...idempotencyKey ? { idempotencyKey } : {},
|
|
351
|
+
...expectedVersion ? { expectedVersion } : {}
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
function getMcpMetadata(record) {
|
|
355
|
+
if (!record || !isRecord(record._meta)) return void 0;
|
|
356
|
+
const meta = record._meta[SMRT_MCP_RESULT_METADATA_KEY];
|
|
357
|
+
return isRecord(meta) ? meta : void 0;
|
|
358
|
+
}
|
|
359
|
+
function declaredActionField(value) {
|
|
360
|
+
if (!isRecord(value) || typeof value.field !== "string") return void 0;
|
|
361
|
+
return {
|
|
362
|
+
field: value.field,
|
|
363
|
+
required: value.required === true
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function firstRecord(...values) {
|
|
367
|
+
return values.find((value) => value !== void 0);
|
|
368
|
+
}
|
|
369
|
+
function firstString(...values) {
|
|
370
|
+
return values.find((value) => typeof value === "string" && value !== "");
|
|
371
|
+
}
|
|
372
|
+
function isRecord(value) {
|
|
373
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
374
|
+
}
|
|
375
|
+
function isSensitiveKey(key) {
|
|
376
|
+
return /(?:authorization|token|secret|password|cookie|credential|api[-_]?key)/iu.test(key);
|
|
377
|
+
}
|
|
378
|
+
function redactMessage(message) {
|
|
379
|
+
return message.replace(/bearer\s+[a-z0-9._~+/=-]+/giu, "Bearer [REDACTED]").replace(/((?:token|secret|password|api[-_]?key|authorization)=)[^&\s,]+/giu, "$1[REDACTED]");
|
|
380
|
+
}
|
|
381
|
+
var ResponseTooLargeError = class extends Error {
|
|
382
|
+
constructor(message) {
|
|
383
|
+
super(message);
|
|
384
|
+
this.name = "ResponseTooLargeError";
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
/**
|
|
388
|
+
* Read the response body into a UTF-8 string, capping at `maxBytes`. On
|
|
389
|
+
* overflow, throw with a clear message — better than OOM-ing the CLI
|
|
390
|
+
* when a server returns a multi-GB body. Streams chunk-by-chunk so the
|
|
391
|
+
* check fires before the whole body is buffered. (#1311 review A4.)
|
|
392
|
+
*/
|
|
393
|
+
async function readBodyWithCap(response, maxBytes) {
|
|
394
|
+
if (!response.body) return "";
|
|
395
|
+
const cl = Number(response.headers.get("content-length") ?? "");
|
|
396
|
+
if (Number.isFinite(cl) && cl > maxBytes) {
|
|
397
|
+
try {
|
|
398
|
+
await response.body.cancel();
|
|
399
|
+
} catch {}
|
|
400
|
+
throw new ResponseTooLargeError(`Response too large: ${cl} bytes exceeds ${maxBytes}-byte cap`);
|
|
401
|
+
}
|
|
402
|
+
const reader = response.body.getReader();
|
|
403
|
+
const chunks = [];
|
|
404
|
+
let size = 0;
|
|
405
|
+
try {
|
|
406
|
+
while (true) {
|
|
407
|
+
const { done, value } = await reader.read();
|
|
408
|
+
if (done) break;
|
|
409
|
+
if (!value) continue;
|
|
410
|
+
size += value.byteLength;
|
|
411
|
+
if (size > maxBytes) throw new ResponseTooLargeError(`Response too large: exceeded ${maxBytes}-byte cap mid-stream`);
|
|
412
|
+
chunks.push(value);
|
|
413
|
+
}
|
|
414
|
+
} finally {
|
|
415
|
+
try {
|
|
416
|
+
reader.releaseLock();
|
|
417
|
+
} catch {}
|
|
418
|
+
}
|
|
419
|
+
return new TextDecoder().decode(Buffer.concat(chunks.map((c) => Buffer.from(c))));
|
|
420
|
+
}
|
|
421
|
+
//#endregion
|
|
422
|
+
export { loadCliConfig as a, requestJsonResult as c, createMcpStdioBridge as d, formatMcpCallResult as f, getStoredToken as i, saveAuth as l, toMcpTransportError as m, clearStoredToken as n, redactTransportValue as o, runMcpStdioBridge as p, getServerUrl as r, requestJson as s, AppCliRequestError as t, saveCliConfig as u };
|
package/dist/index.d.ts
CHANGED
|
@@ -43,6 +43,42 @@ export declare interface AppCliContext {
|
|
|
43
43
|
stderr: NodeJS.WriteStream;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** Error compatibility wrapper returned by the legacy throwing helper. */
|
|
47
|
+
export declare class AppCliRequestError extends Error {
|
|
48
|
+
readonly status: number;
|
|
49
|
+
readonly metadata: AppCliResultMetadata;
|
|
50
|
+
constructor(status: number, metadata: AppCliResultMetadata);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Structured metadata preserved for CLI JSON and MCP bridge consumers. */
|
|
54
|
+
export declare type AppCliResultMetadata = AppResultMetadata;
|
|
55
|
+
|
|
56
|
+
declare interface AppResultContract {
|
|
57
|
+
schema: typeof SMRT_APP_RESULT_SCHEMA;
|
|
58
|
+
version: typeof SMRT_APP_RESULT_VERSION;
|
|
59
|
+
mcpMetadataKey: typeof SMRT_MCP_RESULT_METADATA_KEY;
|
|
60
|
+
metadataFields: readonly [
|
|
61
|
+
'code',
|
|
62
|
+
'message',
|
|
63
|
+
'details',
|
|
64
|
+
'retryable',
|
|
65
|
+
'correlationId',
|
|
66
|
+
'idempotencyKey',
|
|
67
|
+
'expectedVersion'
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Machine-readable metadata shared by HTTP CLI and MCP bridge results. */
|
|
72
|
+
declare interface AppResultMetadata {
|
|
73
|
+
code: string;
|
|
74
|
+
message?: string;
|
|
75
|
+
details?: JsonValue;
|
|
76
|
+
retryable?: boolean;
|
|
77
|
+
correlationId?: string;
|
|
78
|
+
idempotencyKey?: DeclaredActionField;
|
|
79
|
+
expectedVersion?: DeclaredActionField;
|
|
80
|
+
}
|
|
81
|
+
|
|
46
82
|
export declare function buildFlagParser(schema: Record<string, unknown> | undefined, options?: ParserOptions): BuildParserResult;
|
|
47
83
|
|
|
48
84
|
export declare interface BuildParserResult {
|
|
@@ -129,12 +165,20 @@ declare interface CommandDefinition_2 {
|
|
|
129
165
|
description?: string;
|
|
130
166
|
/** JSONSchema describing the command's argv-flag surface. */
|
|
131
167
|
parameters?: Record<string, unknown>;
|
|
168
|
+
/** Retry and optimistic-concurrency fields declared by the action schema. */
|
|
169
|
+
requirements?: CommandRequirements;
|
|
132
170
|
}
|
|
133
171
|
|
|
134
172
|
export declare type CommandKind = CommandKind_2;
|
|
135
173
|
|
|
136
174
|
declare type CommandKind_2 = 'crud' | 'custom';
|
|
137
175
|
|
|
176
|
+
/** Retry and concurrency declarations projected from a command schema. */
|
|
177
|
+
declare interface CommandRequirements {
|
|
178
|
+
idempotencyKey?: DeclaredActionField;
|
|
179
|
+
expectedVersion?: DeclaredActionField;
|
|
180
|
+
}
|
|
181
|
+
|
|
138
182
|
export declare type CommandScope = CommandScope_2;
|
|
139
183
|
|
|
140
184
|
declare type CommandScope_2 = 'item' | 'collection';
|
|
@@ -183,6 +227,32 @@ export declare function createMcpStdioBridge(options: McpStdioBridgeOptions): {
|
|
|
183
227
|
connect: () => Promise<void>;
|
|
184
228
|
};
|
|
185
229
|
|
|
230
|
+
/** A declared input field which controls retry or optimistic-concurrency use. */
|
|
231
|
+
declare interface DeclaredActionField {
|
|
232
|
+
/** JSON Schema property name supplied by the action. */
|
|
233
|
+
field: string;
|
|
234
|
+
/** Whether the action marks the field required. */
|
|
235
|
+
required: boolean;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
declare interface DiscoveryArtifactIntegrity {
|
|
239
|
+
algorithm: 'sha256';
|
|
240
|
+
/** `sha256:<lowercase-hex>` over canonical unsigned artifact JSON. */
|
|
241
|
+
digest: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Versioned payload a consumer can validate and pin. */
|
|
245
|
+
declare interface DiscoveryConformanceArtifact {
|
|
246
|
+
schema: typeof SMRT_DISCOVERY_CONFORMANCE_SCHEMA;
|
|
247
|
+
version: typeof SMRT_DISCOVERY_CONFORMANCE_VERSION;
|
|
248
|
+
discovery: DiscoveryPayload;
|
|
249
|
+
resultContract: AppResultContract;
|
|
250
|
+
integrity: DiscoveryArtifactIntegrity;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The pre-artifact wire payload, kept for the existing `/_resources` fields. */
|
|
254
|
+
declare type DiscoveryPayload = Omit<ResourceListResponseBody, 'artifact'>;
|
|
255
|
+
|
|
186
256
|
/**
|
|
187
257
|
* Fetch the discovery payload.
|
|
188
258
|
*
|
|
@@ -241,6 +311,11 @@ declare interface InvokeOptions {
|
|
|
241
311
|
fetch?: typeof fetch;
|
|
242
312
|
}
|
|
243
313
|
|
|
314
|
+
/** JSON-safe value used by structured details and parameter schemas. */
|
|
315
|
+
declare type JsonValue = null | boolean | number | string | JsonValue[] | {
|
|
316
|
+
[key: string]: JsonValue;
|
|
317
|
+
};
|
|
318
|
+
|
|
244
319
|
/** Read the CLI config file. Missing file → empty config. */
|
|
245
320
|
export declare function loadCliConfig(context: CliConfigContext): Promise<CliConfig>;
|
|
246
321
|
|
|
@@ -332,6 +407,9 @@ declare interface ParserOptions {
|
|
|
332
407
|
positionalOnly?: boolean;
|
|
333
408
|
}
|
|
334
409
|
|
|
410
|
+
/** Redact conventional credential fields before emitting transport metadata. */
|
|
411
|
+
export declare function redactTransportValue(value: unknown): unknown;
|
|
412
|
+
|
|
335
413
|
/**
|
|
336
414
|
* Complete client registration against discovered authorization-server
|
|
337
415
|
* metadata. A Client ID Metadata Document needs no registration request: the
|
|
@@ -356,6 +434,12 @@ export declare interface RenderResult {
|
|
|
356
434
|
*/
|
|
357
435
|
export declare function requestJson<T = unknown>(context: CliConfigContext, path: string, init?: RequestInit, options?: RequestJsonOptions): Promise<T>;
|
|
358
436
|
|
|
437
|
+
declare interface RequestJsonFailure {
|
|
438
|
+
ok: false;
|
|
439
|
+
status: number;
|
|
440
|
+
error: AppCliResultMetadata;
|
|
441
|
+
}
|
|
442
|
+
|
|
359
443
|
/** Options for `requestJson`. */
|
|
360
444
|
export declare interface RequestJsonOptions {
|
|
361
445
|
/**
|
|
@@ -390,6 +474,24 @@ export declare interface RequestJsonOptions {
|
|
|
390
474
|
loadedConfig?: CliConfig;
|
|
391
475
|
}
|
|
392
476
|
|
|
477
|
+
/** Generic result/error envelope for a JSON request. */
|
|
478
|
+
export declare type RequestJsonResult<T> = RequestJsonSuccess<T> | RequestJsonFailure;
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Issue a JSON request without flattening a failure to an Error message.
|
|
482
|
+
*
|
|
483
|
+
* Metadata is deliberately copied only from conventional result/error fields
|
|
484
|
+
* and response headers. Every value crossing this boundary is redacted before
|
|
485
|
+
* a CLI or MCP client can observe it.
|
|
486
|
+
*/
|
|
487
|
+
export declare function requestJsonResult<T = unknown>(context: CliConfigContext, path: string, init?: RequestInit, options?: RequestJsonOptions): Promise<RequestJsonResult<T>>;
|
|
488
|
+
|
|
489
|
+
declare interface RequestJsonSuccess<T> {
|
|
490
|
+
ok: true;
|
|
491
|
+
result: T;
|
|
492
|
+
metadata: AppCliResultMetadata;
|
|
493
|
+
}
|
|
494
|
+
|
|
393
495
|
/**
|
|
394
496
|
* Select registration in MCP priority order: Client ID Metadata Documents
|
|
395
497
|
* first, then RFC 7591 DCR as a compatibility fallback.
|
|
@@ -405,6 +507,8 @@ declare interface ResourceListResponseBody {
|
|
|
405
507
|
};
|
|
406
508
|
warnings: string[];
|
|
407
509
|
resources: CliResource_2[];
|
|
510
|
+
/** Versioned deterministic artifact for consumers that integrity-pin discovery. */
|
|
511
|
+
artifact?: DiscoveryConformanceArtifact;
|
|
408
512
|
}
|
|
409
513
|
|
|
410
514
|
/**
|
|
@@ -431,4 +535,19 @@ export declare type SchemaSupportStatus = {
|
|
|
431
535
|
kind: 'missing';
|
|
432
536
|
};
|
|
433
537
|
|
|
538
|
+
/** Versioned selector for structured application results and errors. */
|
|
539
|
+
declare const SMRT_APP_RESULT_SCHEMA = "https://smrt.dev/schemas/app-result/v1";
|
|
540
|
+
|
|
541
|
+
/** Version of the structured application result contract. */
|
|
542
|
+
declare const SMRT_APP_RESULT_VERSION: 1;
|
|
543
|
+
|
|
544
|
+
/** Immutable selector for this discovery artifact family. */
|
|
545
|
+
declare const SMRT_DISCOVERY_CONFORMANCE_SCHEMA = "https://smrt.dev/schemas/discovery-conformance/v1";
|
|
546
|
+
|
|
547
|
+
/** Version of the discovery artifact payload. */
|
|
548
|
+
declare const SMRT_DISCOVERY_CONFORMANCE_VERSION: 1;
|
|
549
|
+
|
|
550
|
+
/** MCP result `_meta` member carrying the app-result metadata. */
|
|
551
|
+
export declare const SMRT_MCP_RESULT_METADATA_KEY = "io.happyvertical/smrt";
|
|
552
|
+
|
|
434
553
|
export { }
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as loadCliConfig, c as requestJsonResult, d as createMcpStdioBridge, i as getStoredToken, l as saveAuth, n as clearStoredToken, o as redactTransportValue, p as runMcpStdioBridge, r as getServerUrl, s as requestJson, t as AppCliRequestError, u as saveCliConfig } from "./config-CDhknIOW.js";
|
|
2
|
+
import { SMRT_MCP_RESULT_METADATA_KEY, validateDiscoveryConformanceArtifact } from "@happyvertical/smrt-users/app-contract";
|
|
2
3
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
4
|
//#region src/discovery.ts
|
|
4
5
|
/**
|
|
6
|
+
* Fetches the resource list from `GET /api/_resources` and re-exports the
|
|
7
|
+
* wire types so consumers can build typed extensions on top of the discovery
|
|
8
|
+
* output.
|
|
9
|
+
*
|
|
10
|
+
* Types are imported type-only from `@happyvertical/smrt-users/sveltekit`
|
|
11
|
+
* — the single source of truth for the wire contract. The CLI does not
|
|
12
|
+
* depend on smrt-users at runtime (the peer dep is `optional` in the
|
|
13
|
+
* package.json) so importing types is free of bundle cost.
|
|
14
|
+
*
|
|
15
|
+
* Importing type-only means a new field added to `CommandDefinition` /
|
|
16
|
+
* `CliResource` on the handler side automatically propagates to the CLI's
|
|
17
|
+
* typecheck — no manual mirror-update required. (#1311 review D-1.)
|
|
18
|
+
*
|
|
19
|
+
* @packageDocumentation
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
5
22
|
* Fetch the discovery payload.
|
|
6
23
|
*
|
|
7
24
|
* Translates a 401 into a friendlier error so the CLI can prompt the
|
|
@@ -9,11 +26,17 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
9
26
|
*/
|
|
10
27
|
async function fetchResourceList(context, options = {}) {
|
|
11
28
|
try {
|
|
12
|
-
|
|
29
|
+
const response = await requestJson(context, options.path ?? "/api/_resources", { method: "GET" }, {
|
|
13
30
|
fetch: options.fetch,
|
|
14
31
|
requireAuth: options.requireAuth,
|
|
15
32
|
loadedConfig: options.loadedConfig
|
|
16
33
|
});
|
|
34
|
+
if (!response.artifact) return response;
|
|
35
|
+
const artifact = validateDiscoveryConformanceArtifact(response.artifact);
|
|
36
|
+
return {
|
|
37
|
+
...artifact.discovery,
|
|
38
|
+
artifact
|
|
39
|
+
};
|
|
17
40
|
} catch (error) {
|
|
18
41
|
if (error instanceof Error && /401|unauthor/i.test(error.message)) throw new Error(`Not authenticated to ${context.envPrefix.toLowerCase()}. Run \`${context.envPrefix.toLowerCase()} auth login\` first.`);
|
|
19
42
|
throw error;
|
|
@@ -758,11 +781,13 @@ function openVerificationUrl(url) {
|
|
|
758
781
|
//#region src/commands/mcp.ts
|
|
759
782
|
async function runMcpCommand(options, args) {
|
|
760
783
|
const stdout = options.stdout ?? process.stdout;
|
|
784
|
+
const stderr = options.stderr ?? process.stderr;
|
|
761
785
|
const sub = args[0];
|
|
762
786
|
if (sub === "tools") {
|
|
763
|
-
const result = await
|
|
787
|
+
const result = await requestJsonResult(options.context, "/api/mcp/tools", { method: "GET" }, { fetch: options.fetch });
|
|
764
788
|
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
765
|
-
|
|
789
|
+
if (!result.ok) stderr.write(`${result.error.message ?? result.error.code}\n`);
|
|
790
|
+
return result.ok;
|
|
766
791
|
}
|
|
767
792
|
if (sub === "call") {
|
|
768
793
|
const name = args[1];
|
|
@@ -774,7 +799,7 @@ async function runMcpCommand(options, args) {
|
|
|
774
799
|
} catch (error) {
|
|
775
800
|
throw new Error(`Could not parse mcp call payload: ${error.message}`);
|
|
776
801
|
}
|
|
777
|
-
const result = await
|
|
802
|
+
const result = await requestJsonResult(options.context, "/api/mcp/call", {
|
|
778
803
|
body: JSON.stringify({
|
|
779
804
|
arguments: parsed,
|
|
780
805
|
name
|
|
@@ -782,7 +807,8 @@ async function runMcpCommand(options, args) {
|
|
|
782
807
|
method: "POST"
|
|
783
808
|
}, { fetch: options.fetch });
|
|
784
809
|
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
785
|
-
|
|
810
|
+
if (!result.ok) stderr.write(`${result.error.message ?? result.error.code}\n`);
|
|
811
|
+
return result.ok;
|
|
786
812
|
}
|
|
787
813
|
throw new Error("Usage: mcp tools | mcp call <tool> [<json>]");
|
|
788
814
|
}
|
|
@@ -847,7 +873,7 @@ function createAppCli(options) {
|
|
|
847
873
|
return {
|
|
848
874
|
run: (argv) => runCli(context, options, extraByName, argv),
|
|
849
875
|
startMcpBridge: async (serverInfo) => {
|
|
850
|
-
const { runMcpStdioBridge } = await import("./bridge-
|
|
876
|
+
const { runMcpStdioBridge } = await import("./bridge-BgtGXmYP.js");
|
|
851
877
|
await runMcpStdioBridge({
|
|
852
878
|
...context,
|
|
853
879
|
serverInfo: {
|
|
@@ -899,10 +925,14 @@ async function dispatchCli(context, options, extras, argv, stdout, stderr) {
|
|
|
899
925
|
if (sub === "logout") return runAuthLogout(opts);
|
|
900
926
|
throw new Error("Usage: auth login [--server <url>] [--no-open] | status | logout");
|
|
901
927
|
}
|
|
902
|
-
if (command === "mcp")
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
928
|
+
if (command === "mcp") {
|
|
929
|
+
if (!await runMcpCommand({
|
|
930
|
+
context,
|
|
931
|
+
stdout,
|
|
932
|
+
stderr
|
|
933
|
+
}, rest)) process.exitCode = 1;
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
906
936
|
if (command === "resources") return runResourcesCommand({
|
|
907
937
|
context,
|
|
908
938
|
stdout,
|
|
@@ -1000,4 +1030,4 @@ function similar(a, b) {
|
|
|
1000
1030
|
return true;
|
|
1001
1031
|
}
|
|
1002
1032
|
//#endregion
|
|
1003
|
-
export { buildFlagParser, buildUrl, classifySchema, clearStoredToken, createAppCli, createMcpClientIdMetadataDocument, createMcpStdioBridge, fetchResourceList, findCommand, findResourceBySlug, getServerUrl, getStoredToken, invokeCommand, loadCliConfig, registerMcpClient, renderResponse, requestJson, resolveMcpClientRegistration, runMcpStdioBridge, saveAuth, saveCliConfig };
|
|
1033
|
+
export { AppCliRequestError, SMRT_MCP_RESULT_METADATA_KEY, buildFlagParser, buildUrl, classifySchema, clearStoredToken, createAppCli, createMcpClientIdMetadataDocument, createMcpStdioBridge, fetchResourceList, findCommand, findResourceBySlug, getServerUrl, getStoredToken, invokeCommand, loadCliConfig, redactTransportValue, registerMcpClient, renderResponse, requestJson, requestJsonResult, resolveMcpClientRegistration, runMcpStdioBridge, saveAuth, saveCliConfig };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@happyvertical/smrt-app-cli",
|
|
3
|
-
"version": "0.40.
|
|
3
|
+
"version": "0.40.53",
|
|
4
4
|
"description": "Reusable CLI factory for SMRT apps — branded `<name> <resource> <command>` CLI + stdio MCP bridge with decorator-driven resource discovery",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
24
|
-
"@happyvertical/smrt-users": "0.40.
|
|
24
|
+
"@happyvertical/smrt-users": "0.40.53"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "24.13.2",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"vite": "8.1.4",
|
|
30
30
|
"vite-plugin-dts": "4.5.4",
|
|
31
31
|
"vitest": "4.1.10",
|
|
32
|
-
"@happyvertical/smrt-core": "0.40.
|
|
32
|
+
"@happyvertical/smrt-core": "0.40.53"
|
|
33
33
|
},
|
|
34
34
|
"engines": {
|
|
35
35
|
"node": ">=24.18.0"
|
package/dist/bridge-D0LJc3mN.js
DELETED
package/dist/config-DvxwoFks.js
DELETED
|
@@ -1,231 +0,0 @@
|
|
|
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 a bearer token only when it is bound to the exact target server. */
|
|
133
|
-
async function getStoredToken(context, config, serverUrl) {
|
|
134
|
-
const resolved = config ?? await loadCliConfig(context);
|
|
135
|
-
const targetServer = (serverUrl ?? await getServerUrl(context, resolved)).replace(/\/+$/u, "");
|
|
136
|
-
const environmentToken = process.env[`${context.envPrefix}_TOKEN`];
|
|
137
|
-
const environmentServer = process.env[`${context.envPrefix}_SERVER_URL`]?.replace(/\/+$/u, "");
|
|
138
|
-
if (environmentToken) return environmentServer === targetServer ? environmentToken : void 0;
|
|
139
|
-
const configuredServer = resolved.serverUrl?.replace(/\/+$/u, "");
|
|
140
|
-
if (!configuredServer || configuredServer !== targetServer) return void 0;
|
|
141
|
-
if (resolved.credentialIssuer) return resolved.tokensByIssuer?.[resolved.credentialIssuer];
|
|
142
|
-
return resolved.token;
|
|
143
|
-
}
|
|
144
|
-
/** Remove the token from the config file (e.g. on logout). */
|
|
145
|
-
async function clearStoredToken(context) {
|
|
146
|
-
const config = await loadCliConfig(context);
|
|
147
|
-
if (config.credentialIssuer && config.tokensByIssuer) {
|
|
148
|
-
delete config.tokensByIssuer[config.credentialIssuer];
|
|
149
|
-
if (Object.keys(config.tokensByIssuer).length === 0) delete config.tokensByIssuer;
|
|
150
|
-
}
|
|
151
|
-
delete config.credentialIssuer;
|
|
152
|
-
delete config.token;
|
|
153
|
-
await saveCliConfig(context, config);
|
|
154
|
-
}
|
|
155
|
-
/** Persist a login with the bearer token keyed by its exact issuer. */
|
|
156
|
-
async function saveAuth(context, serverUrl, token, issuer = serverUrl) {
|
|
157
|
-
const config = await loadCliConfig(context);
|
|
158
|
-
const normalizedServerUrl = serverUrl.replace(/\/+$/u, "");
|
|
159
|
-
if (!issuer.trim()) throw new Error("Credential issuer must not be empty.");
|
|
160
|
-
const exactIssuer = issuer;
|
|
161
|
-
await saveCliConfig(context, {
|
|
162
|
-
...config,
|
|
163
|
-
credentialIssuer: exactIssuer,
|
|
164
|
-
serverUrl: normalizedServerUrl,
|
|
165
|
-
token: void 0,
|
|
166
|
-
tokensByIssuer: { [exactIssuer]: token }
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
var DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
|
|
170
|
-
/**
|
|
171
|
-
* JSON request helper that injects the stored bearer token. Returns the
|
|
172
|
-
* parsed JSON body on success; throws an `Error` with the server's `error`
|
|
173
|
-
* field (or `HTTP <status>`) on failure.
|
|
174
|
-
*/
|
|
175
|
-
async function requestJson(context, path, init = {}, options = {}) {
|
|
176
|
-
const config = options.loadedConfig ?? await loadCliConfig(context);
|
|
177
|
-
const serverUrl = (options.serverUrl ?? await getServerUrl(context, config)).replace(/\/+$/u, "");
|
|
178
|
-
const token = await getStoredToken(context, config, serverUrl);
|
|
179
|
-
const headers = new Headers(init.headers);
|
|
180
|
-
if (options.requireAuth && options.auth !== false && !token) throw new Error(`Not authenticated. Run \`${context.envPrefix.toLowerCase()} auth login\` first.`);
|
|
181
|
-
if (!headers.has("content-type") && init.body) headers.set("content-type", "application/json");
|
|
182
|
-
if (options.auth !== false && token) headers.set("authorization", `Bearer ${token}`);
|
|
183
|
-
const response = await (options.fetch ?? fetch)(`${serverUrl}${path}`, {
|
|
184
|
-
...init,
|
|
185
|
-
headers
|
|
186
|
-
});
|
|
187
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
188
|
-
const text = await readBodyWithCap(response, options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES);
|
|
189
|
-
const parsed = contentType.includes("application/json") && text ? JSON.parse(text) : text;
|
|
190
|
-
if (!response.ok) {
|
|
191
|
-
const message = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : `HTTP ${response.status}: ${response.statusText}`;
|
|
192
|
-
throw Object.assign(new Error(message), { status: response.status });
|
|
193
|
-
}
|
|
194
|
-
return parsed;
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* Read the response body into a UTF-8 string, capping at `maxBytes`. On
|
|
198
|
-
* overflow, throw with a clear message — better than OOM-ing the CLI
|
|
199
|
-
* when a server returns a multi-GB body. Streams chunk-by-chunk so the
|
|
200
|
-
* check fires before the whole body is buffered. (#1311 review A4.)
|
|
201
|
-
*/
|
|
202
|
-
async function readBodyWithCap(response, maxBytes) {
|
|
203
|
-
if (!response.body) return "";
|
|
204
|
-
const cl = Number(response.headers.get("content-length") ?? "");
|
|
205
|
-
if (Number.isFinite(cl) && cl > maxBytes) {
|
|
206
|
-
try {
|
|
207
|
-
await response.body.cancel();
|
|
208
|
-
} catch {}
|
|
209
|
-
throw new Error(`Response too large: ${cl} bytes exceeds ${maxBytes}-byte cap`);
|
|
210
|
-
}
|
|
211
|
-
const reader = response.body.getReader();
|
|
212
|
-
const chunks = [];
|
|
213
|
-
let size = 0;
|
|
214
|
-
try {
|
|
215
|
-
while (true) {
|
|
216
|
-
const { done, value } = await reader.read();
|
|
217
|
-
if (done) break;
|
|
218
|
-
if (!value) continue;
|
|
219
|
-
size += value.byteLength;
|
|
220
|
-
if (size > maxBytes) throw new Error(`Response too large: exceeded ${maxBytes}-byte cap mid-stream`);
|
|
221
|
-
chunks.push(value);
|
|
222
|
-
}
|
|
223
|
-
} finally {
|
|
224
|
-
try {
|
|
225
|
-
reader.releaseLock();
|
|
226
|
-
} catch {}
|
|
227
|
-
}
|
|
228
|
-
return new TextDecoder().decode(Buffer.concat(chunks.map((c) => Buffer.from(c))));
|
|
229
|
-
}
|
|
230
|
-
//#endregion
|
|
231
|
-
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 };
|