@hs-x/mcp 0.3.0 → 0.3.7
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 +44 -7
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +67 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +47 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +784 -146
- package/dist/index.js.map +1 -1
- package/dist/stdio-framing.d.ts +22 -0
- package/dist/stdio-framing.d.ts.map +1 -0
- package/dist/stdio-framing.js +61 -0
- package/dist/stdio-framing.js.map +1 -0
- package/dist/stdio.js +31 -12
- package/dist/stdio.js.map +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { request as httpsRequest } from 'node:https';
|
|
2
3
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { validateProject } from '@hs-x/validator';
|
|
5
6
|
import { Redacted } from 'effect';
|
|
6
|
-
import { loadMcpConfig } from './config.js';
|
|
7
|
+
import { isPrivateOrLoopbackHostname, loadMcpConfig } from './config.js';
|
|
7
8
|
/**
|
|
8
9
|
* serverInfo.version is read from this package's own package.json at runtime —
|
|
9
10
|
* never hardcoded. (Hardcoded version constants shipped stale repeatedly:
|
|
@@ -31,6 +32,8 @@ export const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
|
31
32
|
const rootArgSchema = {
|
|
32
33
|
type: 'string',
|
|
33
34
|
description: 'Project root. Defaults to the MCP server working directory.',
|
|
35
|
+
minLength: 1,
|
|
36
|
+
maxLength: 4096,
|
|
34
37
|
};
|
|
35
38
|
const tools = [
|
|
36
39
|
{
|
|
@@ -38,6 +41,7 @@ const tools = [
|
|
|
38
41
|
description: 'Return local HS-X project status using the same validation substrate as the CLI.',
|
|
39
42
|
inputSchema: {
|
|
40
43
|
type: 'object',
|
|
44
|
+
additionalProperties: false,
|
|
41
45
|
properties: {
|
|
42
46
|
root: rootArgSchema,
|
|
43
47
|
},
|
|
@@ -48,79 +52,50 @@ const tools = [
|
|
|
48
52
|
description: 'Run hs-x check for a local project and return structured diagnostics.',
|
|
49
53
|
inputSchema: {
|
|
50
54
|
type: 'object',
|
|
55
|
+
additionalProperties: false,
|
|
51
56
|
properties: {
|
|
52
57
|
root: rootArgSchema,
|
|
53
58
|
},
|
|
54
59
|
},
|
|
55
60
|
},
|
|
56
61
|
{
|
|
57
|
-
name: 'hsx.dev.
|
|
58
|
-
description: '
|
|
59
|
-
inputSchema: {
|
|
60
|
-
type: 'object',
|
|
61
|
-
properties: {
|
|
62
|
-
root: rootArgSchema,
|
|
63
|
-
},
|
|
64
|
-
},
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
name: 'hsx.dev.invoke',
|
|
68
|
-
description: 'Invoke one capability of a local HS-X project through the PRODUCTION runtime router (payload validation, context, handler, result envelope — exactly what a deployed Worker runs). Fixture defaults fill the dispatch payload; pass input/enrolledObject/install to override. Works on freshly-migrated apps.',
|
|
69
|
-
inputSchema: {
|
|
70
|
-
type: 'object',
|
|
71
|
-
properties: {
|
|
72
|
-
root: rootArgSchema,
|
|
73
|
-
capabilityId: { type: 'string', description: 'Capability id (see hsx.dev.capabilities).' },
|
|
74
|
-
input: { type: 'object', description: 'Handler input (default: {}).' },
|
|
75
|
-
enrolledObject: {
|
|
76
|
-
type: 'object',
|
|
77
|
-
description: 'Enrolled object {id, objectType, properties} (default: fixture).',
|
|
78
|
-
},
|
|
79
|
-
install: { type: 'object', description: 'Install identity (default: local fixture).' },
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
name: 'hsx.dev.invoke_last',
|
|
85
|
-
description: 'Replay the last recorded invocation for a local HS-X project (.hs-x/last-invocation.json) — the failure-replay loop.',
|
|
62
|
+
name: 'hsx.dev.session.status',
|
|
63
|
+
description: 'Health + identity of an existing HS-X project dev session.',
|
|
86
64
|
inputSchema: {
|
|
87
65
|
type: 'object',
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
},
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
properties: { root: rootArgSchema },
|
|
91
68
|
},
|
|
92
69
|
},
|
|
93
70
|
{
|
|
94
|
-
name: 'hsx.dev.session.
|
|
95
|
-
description: '
|
|
71
|
+
name: 'hsx.dev.session.stop',
|
|
72
|
+
description: 'Stop the project dev session (SIGTERM to the recorded pid).',
|
|
96
73
|
inputSchema: {
|
|
97
74
|
type: 'object',
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
port: { type: 'number', description: 'Port (default 8787).' },
|
|
101
|
-
},
|
|
75
|
+
additionalProperties: false,
|
|
76
|
+
properties: { root: rootArgSchema },
|
|
102
77
|
},
|
|
103
78
|
},
|
|
104
|
-
{
|
|
105
|
-
name: 'hsx.dev.session.status',
|
|
106
|
-
description: 'Health + identity of the project dev session started via hsx.dev.session.start.',
|
|
107
|
-
inputSchema: { type: 'object', properties: { root: rootArgSchema } },
|
|
108
|
-
},
|
|
109
|
-
{
|
|
110
|
-
name: 'hsx.dev.session.stop',
|
|
111
|
-
description: 'Stop the project dev session (SIGTERM to the recorded pid).',
|
|
112
|
-
inputSchema: { type: 'object', properties: { root: rootArgSchema } },
|
|
113
|
-
},
|
|
114
79
|
{
|
|
115
80
|
name: 'hsx.dev.logs',
|
|
116
81
|
description: 'The project log surface: tails the dev session log (.hs-x/dev-server.log) AND, when the project has a deployed HubSpot app binding, includes HubSpot app-log rows (card/extension renders, proxy executions, webhooks, OAuth) — the same merged timeline as `hs-x logs`.',
|
|
117
82
|
inputSchema: {
|
|
118
83
|
type: 'object',
|
|
84
|
+
additionalProperties: false,
|
|
119
85
|
properties: {
|
|
120
86
|
root: rootArgSchema,
|
|
121
|
-
lines: {
|
|
87
|
+
lines: {
|
|
88
|
+
type: 'integer',
|
|
89
|
+
minimum: 1,
|
|
90
|
+
maximum: 1000,
|
|
91
|
+
default: 50,
|
|
92
|
+
description: 'Trailing local lines (default 50, max 1000).',
|
|
93
|
+
},
|
|
122
94
|
sinceMinutes: {
|
|
123
|
-
type: '
|
|
95
|
+
type: 'integer',
|
|
96
|
+
minimum: 1,
|
|
97
|
+
maximum: 10_080,
|
|
98
|
+
default: 15,
|
|
124
99
|
description: 'HubSpot app-log lookback window in minutes (default 15, max 10080).',
|
|
125
100
|
},
|
|
126
101
|
},
|
|
@@ -128,17 +103,22 @@ const tools = [
|
|
|
128
103
|
},
|
|
129
104
|
{
|
|
130
105
|
name: 'hsx.secrets.hubspot_oauth.set',
|
|
131
|
-
description: 'Store a HubSpot app OAuth client secret for one HS-X account/project/environment/app.',
|
|
106
|
+
description: 'Store a HubSpot app OAuth client secret for one HS-X account/project/environment/app. This credential mutation requires confirm: true.',
|
|
132
107
|
inputSchema: {
|
|
133
108
|
type: 'object',
|
|
109
|
+
additionalProperties: false,
|
|
110
|
+
required: ['accountId', 'projectId', 'hubSpotAppId', 'clientId', 'clientSecret', 'confirm'],
|
|
134
111
|
properties: {
|
|
135
|
-
accountId: { type: 'string' },
|
|
136
|
-
projectId: { type: 'string' },
|
|
137
|
-
hubSpotAppId: { type: '
|
|
138
|
-
clientId: { type: 'string' },
|
|
139
|
-
clientSecret: { type: 'string' },
|
|
112
|
+
accountId: { type: 'string', minLength: 1, maxLength: 128, pattern: '\\S' },
|
|
113
|
+
projectId: { type: 'string', minLength: 1, maxLength: 128, pattern: '\\S' },
|
|
114
|
+
hubSpotAppId: { type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER },
|
|
115
|
+
clientId: { type: 'string', minLength: 1, maxLength: 512, pattern: '\\S' },
|
|
116
|
+
clientSecret: { type: 'string', minLength: 1, maxLength: 4096, pattern: '\\S' },
|
|
140
117
|
environment: { type: 'string', enum: ['dev', 'staging', 'production'] },
|
|
141
|
-
|
|
118
|
+
confirm: {
|
|
119
|
+
type: 'boolean',
|
|
120
|
+
description: 'Must be true to explicitly confirm this credential mutation before HS-X reads a bearer credential or sends a network request.',
|
|
121
|
+
},
|
|
142
122
|
},
|
|
143
123
|
},
|
|
144
124
|
},
|
|
@@ -147,12 +127,23 @@ const tools = [
|
|
|
147
127
|
description: 'Search the HS-X documentation (guides, answers, reference) by keyword. Returns ranked pages with title, section, the markdown URL, and a summary. Follow up with hsx.docs.fetch to read a page in full.',
|
|
148
128
|
inputSchema: {
|
|
149
129
|
type: 'object',
|
|
130
|
+
additionalProperties: false,
|
|
131
|
+
required: ['query'],
|
|
150
132
|
properties: {
|
|
151
133
|
query: {
|
|
152
134
|
type: 'string',
|
|
135
|
+
minLength: 1,
|
|
136
|
+
maxLength: 512,
|
|
137
|
+
pattern: '\\S',
|
|
153
138
|
description: 'Keywords to search for, e.g. "webhook signature dedup".',
|
|
154
139
|
},
|
|
155
|
-
limit: {
|
|
140
|
+
limit: {
|
|
141
|
+
type: 'integer',
|
|
142
|
+
minimum: 1,
|
|
143
|
+
maximum: 25,
|
|
144
|
+
default: 8,
|
|
145
|
+
description: 'Max results (default 8, max 25).',
|
|
146
|
+
},
|
|
156
147
|
},
|
|
157
148
|
},
|
|
158
149
|
},
|
|
@@ -161,8 +152,16 @@ const tools = [
|
|
|
161
152
|
description: 'Fetch one HS-X documentation page as clean markdown. Pass a docs path or URL from hsx.docs.search (e.g. "/docs/guides/triggers" or "/docs/guides/triggers.md").',
|
|
162
153
|
inputSchema: {
|
|
163
154
|
type: 'object',
|
|
155
|
+
additionalProperties: false,
|
|
156
|
+
required: ['path'],
|
|
164
157
|
properties: {
|
|
165
|
-
path: {
|
|
158
|
+
path: {
|
|
159
|
+
type: 'string',
|
|
160
|
+
minLength: 1,
|
|
161
|
+
maxLength: 2048,
|
|
162
|
+
pattern: '\\S',
|
|
163
|
+
description: 'Docs path or URL (with or without the .md suffix).',
|
|
164
|
+
},
|
|
166
165
|
},
|
|
167
166
|
},
|
|
168
167
|
},
|
|
@@ -170,7 +169,50 @@ const tools = [
|
|
|
170
169
|
export function listMcpTools() {
|
|
171
170
|
return tools;
|
|
172
171
|
}
|
|
173
|
-
|
|
172
|
+
class JsonRpcError extends Error {
|
|
173
|
+
code;
|
|
174
|
+
constructor(code, message) {
|
|
175
|
+
super(message);
|
|
176
|
+
this.code = code;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function decodeJsonRpcRequest(input) {
|
|
180
|
+
if (!isRecord(input) || Array.isArray(input)) {
|
|
181
|
+
return { ok: false, message: 'Invalid Request: expected a JSON-RPC request object.' };
|
|
182
|
+
}
|
|
183
|
+
if (input.jsonrpc !== '2.0') {
|
|
184
|
+
return { ok: false, message: 'Invalid Request: jsonrpc must be "2.0".' };
|
|
185
|
+
}
|
|
186
|
+
if (typeof input.method !== 'string' || input.method.length === 0) {
|
|
187
|
+
return { ok: false, message: 'Invalid Request: method must be a non-empty string.' };
|
|
188
|
+
}
|
|
189
|
+
if (input.id !== undefined &&
|
|
190
|
+
!(typeof input.id === 'string' ||
|
|
191
|
+
(typeof input.id === 'number' && Number.isSafeInteger(input.id)))) {
|
|
192
|
+
return { ok: false, message: 'Invalid Request: id must be a string or integer.' };
|
|
193
|
+
}
|
|
194
|
+
if (input.params !== undefined && (!isRecord(input.params) || Array.isArray(input.params))) {
|
|
195
|
+
return { ok: false, message: 'Invalid Request: params must be an object when provided.' };
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
ok: true,
|
|
199
|
+
request: {
|
|
200
|
+
jsonrpc: '2.0',
|
|
201
|
+
method: input.method,
|
|
202
|
+
...(input.id !== undefined ? { id: input.id } : {}),
|
|
203
|
+
...(input.params !== undefined ? { params: input.params } : {}),
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function rpcFailure(id, code, message) {
|
|
208
|
+
return { jsonrpc: '2.0', id, error: { code, message } };
|
|
209
|
+
}
|
|
210
|
+
export async function handleMcpRequest(input, context) {
|
|
211
|
+
const decoded = decodeJsonRpcRequest(input);
|
|
212
|
+
if (!decoded.ok) {
|
|
213
|
+
return rpcFailure(null, -32600, decoded.message);
|
|
214
|
+
}
|
|
215
|
+
const request = decoded.request;
|
|
174
216
|
try {
|
|
175
217
|
const result = await dispatchMcpRequest(request, context);
|
|
176
218
|
if (request.id === undefined) {
|
|
@@ -182,18 +224,19 @@ export async function handleMcpRequest(request, context) {
|
|
|
182
224
|
if (request.id === undefined) {
|
|
183
225
|
return undefined;
|
|
184
226
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
id: request.id ?? null,
|
|
188
|
-
error: {
|
|
189
|
-
code: -32000,
|
|
190
|
-
message: error instanceof Error ? error.message : String(error),
|
|
191
|
-
},
|
|
192
|
-
};
|
|
227
|
+
const code = error instanceof JsonRpcError ? error.code : -32000;
|
|
228
|
+
return rpcFailure(request.id ?? null, code, error instanceof Error ? error.message : String(error));
|
|
193
229
|
}
|
|
194
230
|
}
|
|
195
231
|
export async function callMcpTool(name, args, context) {
|
|
196
|
-
const
|
|
232
|
+
const tool = tools.find((candidate) => candidate.name === name);
|
|
233
|
+
if (!tool)
|
|
234
|
+
throw new Error(`Unknown MCP tool: ${name}`);
|
|
235
|
+
validateToolArguments(tool, args);
|
|
236
|
+
if (name === 'hsx.secrets.hubspot_oauth.set' && args.confirm !== true) {
|
|
237
|
+
throw new Error('hsx.secrets.hubspot_oauth.set requires confirm: true before storing OAuth credentials.');
|
|
238
|
+
}
|
|
239
|
+
const root = await projectRoot(args, context);
|
|
197
240
|
if (name === 'hsx.status') {
|
|
198
241
|
const validation = await validateProject({ root });
|
|
199
242
|
const diagnosticsBySeverity = { error: 0, warning: 0 };
|
|
@@ -215,6 +258,7 @@ export async function callMcpTool(name, args, context) {
|
|
|
215
258
|
});
|
|
216
259
|
}
|
|
217
260
|
if (name === 'hsx.dev.capabilities') {
|
|
261
|
+
requireUnsafeProjectExecutionOptIn(context, name);
|
|
218
262
|
const { loadProjectWorkers, DevInvokeError } = await import('@hs-x/cli/dev/invoke');
|
|
219
263
|
try {
|
|
220
264
|
const workers = await loadProjectWorkers(root);
|
|
@@ -236,6 +280,7 @@ export async function callMcpTool(name, args, context) {
|
|
|
236
280
|
}
|
|
237
281
|
}
|
|
238
282
|
if (name === 'hsx.dev.invoke' || name === 'hsx.dev.invoke_last') {
|
|
283
|
+
requireUnsafeProjectExecutionOptIn(context, name);
|
|
239
284
|
const { invokeCapabilityLocally, loadLastInvocation, DevInvokeError } = await import('@hs-x/cli/dev/invoke');
|
|
240
285
|
let capabilityId;
|
|
241
286
|
let payloadOverrides = {};
|
|
@@ -283,74 +328,119 @@ export async function callMcpTool(name, args, context) {
|
|
|
283
328
|
}
|
|
284
329
|
}
|
|
285
330
|
if (name === 'hsx.dev.session.start') {
|
|
286
|
-
|
|
331
|
+
requireUnsafeProjectExecutionOptIn(context, name);
|
|
332
|
+
const port = optionalPort(args.port);
|
|
287
333
|
const existing = await readDevSession(root);
|
|
288
|
-
if (existing
|
|
289
|
-
|
|
334
|
+
if (existing) {
|
|
335
|
+
if ((await devSessionMatchesProcess(existing, root)) &&
|
|
336
|
+
(await devSessionHealthy(existing.port))) {
|
|
337
|
+
return content({
|
|
338
|
+
ok: true,
|
|
339
|
+
command: 'dev session start',
|
|
340
|
+
alreadyRunning: true,
|
|
341
|
+
...(await devSessionView(root, existing)),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
throw new Error('The dev-session record does not identify a live HS-X dev process for this project. Refusing to overwrite it; verify the process, then remove .hs-x/dev-session.json manually.');
|
|
290
345
|
}
|
|
291
346
|
const { spawn } = await import('node:child_process');
|
|
292
|
-
const { mkdir } = await import('node:fs/promises');
|
|
293
|
-
const { createWriteStream } = await import('node:fs');
|
|
294
|
-
const { join } = await import('node:path');
|
|
295
|
-
await mkdir(join(root, '.hs-x'), { recursive: true });
|
|
296
|
-
const logPath = join(root, '.hs-x', 'dev-server.log');
|
|
297
|
-
const log = createWriteStream(logPath, { flags: 'a' });
|
|
298
347
|
// HSX_MCP_DEV_BINARY lets tests point at the repo CLI; default = installed hs-x.
|
|
299
348
|
const binary = process.env.HSX_MCP_DEV_BINARY ?? 'hs-x';
|
|
300
349
|
const [command, ...prefixArgs] = binary.split(' ');
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
350
|
+
if (!command)
|
|
351
|
+
throw new Error('HSX_MCP_DEV_BINARY must name an executable.');
|
|
352
|
+
const { logPath, stream: log } = await openDevLogForAppend(root);
|
|
353
|
+
const child = (() => {
|
|
354
|
+
try {
|
|
355
|
+
return spawn(command, [...prefixArgs, 'dev', '--cwd', root, '--port', String(port)], {
|
|
356
|
+
detached: true,
|
|
357
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
log.end();
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
})();
|
|
365
|
+
let spawnError;
|
|
366
|
+
child.once('error', (error) => {
|
|
367
|
+
spawnError = error;
|
|
304
368
|
});
|
|
305
369
|
child.stdout?.pipe(log);
|
|
306
370
|
child.stderr?.pipe(log);
|
|
307
371
|
child.unref();
|
|
308
|
-
const
|
|
309
|
-
|
|
372
|
+
const childPid = child.pid;
|
|
373
|
+
if (!Number.isSafeInteger(childPid) || (childPid ?? 0) <= 0) {
|
|
374
|
+
log.end();
|
|
375
|
+
throw new Error('Could not start the HS-X dev process: no safe child pid was assigned.');
|
|
376
|
+
}
|
|
377
|
+
const session = { schemaVersion: 1, pid: childPid, port };
|
|
378
|
+
try {
|
|
379
|
+
await writeDevSession(root, session);
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
child.kill('SIGTERM');
|
|
383
|
+
log.end();
|
|
384
|
+
throw error;
|
|
385
|
+
}
|
|
310
386
|
// A source-loaded CLI can take longer to boot while the full CI suite is
|
|
311
387
|
// saturating the runner. Keep polling for 30s before declaring failure.
|
|
312
388
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
389
|
+
if (spawnError) {
|
|
390
|
+
await removeDevSession(root);
|
|
391
|
+
log.end();
|
|
392
|
+
throw new Error(`Could not start the HS-X dev process: ${spawnError.message}`);
|
|
393
|
+
}
|
|
313
394
|
if (await devSessionHealthy(port)) {
|
|
314
|
-
return content({
|
|
395
|
+
return content({
|
|
396
|
+
ok: true,
|
|
397
|
+
command: 'dev session start',
|
|
398
|
+
...(await devSessionView(root, session)),
|
|
399
|
+
});
|
|
315
400
|
}
|
|
316
401
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
317
402
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
403
|
+
child.kill('SIGTERM');
|
|
404
|
+
await removeDevSession(root);
|
|
405
|
+
log.end();
|
|
406
|
+
const logTail = (await readBoundedLogTail(root, 10)).join('\n');
|
|
322
407
|
throw new Error(`Dev server did not become healthy on port ${port} within 30s — read the log via hsx.dev.logs.${logTail ? `\n${logTail}` : ''}`);
|
|
323
408
|
}
|
|
324
409
|
if (name === 'hsx.dev.session.status') {
|
|
325
410
|
const session = await readDevSession(root);
|
|
326
411
|
if (!session)
|
|
327
412
|
return content({ ok: false, command: 'dev session status', running: false });
|
|
328
|
-
const
|
|
329
|
-
|
|
413
|
+
const processMatches = await devSessionMatchesProcess(session, root);
|
|
414
|
+
const healthy = processMatches && (await devSessionHealthy(session.port));
|
|
415
|
+
return content({
|
|
416
|
+
ok: healthy,
|
|
417
|
+
command: 'dev session status',
|
|
418
|
+
running: healthy,
|
|
419
|
+
...(await devSessionView(root, session)),
|
|
420
|
+
...(processMatches ? {} : { reason: 'recorded process identity mismatch' }),
|
|
421
|
+
});
|
|
330
422
|
}
|
|
331
423
|
if (name === 'hsx.dev.session.stop') {
|
|
332
424
|
const session = await readDevSession(root);
|
|
333
425
|
if (!session)
|
|
334
426
|
return content({ ok: true, command: 'dev session stop', running: false });
|
|
427
|
+
if (!(await devSessionMatchesProcess(session, root))) {
|
|
428
|
+
throw new Error('Refusing to stop the recorded pid because it is not the HS-X dev process for this project.');
|
|
429
|
+
}
|
|
335
430
|
try {
|
|
336
431
|
process.kill(session.pid, 'SIGTERM');
|
|
337
432
|
}
|
|
338
|
-
catch {
|
|
339
|
-
|
|
433
|
+
catch (error) {
|
|
434
|
+
throw new Error(`Could not stop HS-X dev pid ${session.pid}: ${error instanceof Error ? error.message : String(error)}`);
|
|
340
435
|
}
|
|
341
|
-
|
|
342
|
-
const { join } = await import('node:path');
|
|
343
|
-
await rm(join(root, '.hs-x', 'dev-session.json'), { force: true });
|
|
436
|
+
await removeDevSession(root);
|
|
344
437
|
return content({ ok: true, command: 'dev session stop', stopped: session.pid });
|
|
345
438
|
}
|
|
346
439
|
if (name === 'hsx.dev.logs') {
|
|
347
|
-
const lines =
|
|
348
|
-
const
|
|
349
|
-
const
|
|
350
|
-
const
|
|
351
|
-
const logPath = session?.logPath ?? join(root, '.hs-x', 'dev-server.log');
|
|
352
|
-
const raw = await readFile(logPath, 'utf8').catch(() => '');
|
|
353
|
-
const tail = raw.split('\n').filter(Boolean).slice(-lines);
|
|
440
|
+
const lines = optionalLogLines(args.lines);
|
|
441
|
+
const paths = await resolveDevStatePaths(root, false);
|
|
442
|
+
const logPath = paths.logPath;
|
|
443
|
+
const tail = await readBoundedLogTail(root, lines);
|
|
354
444
|
// One log surface: the same HubSpot app-log rows `hs-x logs` merges.
|
|
355
445
|
// Missing binding/credentials degrades to a reason, never an error.
|
|
356
446
|
const sinceMinutes = typeof args.sinceMinutes === 'number'
|
|
@@ -396,20 +486,36 @@ export async function callMcpTool(name, args, context) {
|
|
|
396
486
|
const clientSecret = requiredString(args, 'clientSecret');
|
|
397
487
|
const environment = optionalString(args, 'environment') ?? 'production';
|
|
398
488
|
const config = resolveMcpConfig(context);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
489
|
+
if (args.controlPlaneUrl !== undefined) {
|
|
490
|
+
throw new Error('controlPlaneUrl is server-configured and cannot be supplied by an MCP tool call.');
|
|
491
|
+
}
|
|
492
|
+
const addresses = await requirePublicControlPlaneResolution(config.controlPlaneUrl, context.lookup);
|
|
493
|
+
const target = new URL(`/v1/accounts/${encodeURIComponent(accountId)}/projects/${encodeURIComponent(projectId)}/hubspot-apps/${encodeURIComponent(String(hubSpotAppId))}/oauth-secret`, config.controlPlaneUrl);
|
|
494
|
+
const requestBody = JSON.stringify({ clientId, clientSecret, environment });
|
|
495
|
+
const credentialResponse = context.fetch
|
|
496
|
+
? await context.fetch(target, {
|
|
497
|
+
method: 'PUT',
|
|
498
|
+
redirect: 'error',
|
|
499
|
+
headers: {
|
|
500
|
+
'content-type': 'application/json',
|
|
501
|
+
authorization: `Bearer ${sessionToken}`,
|
|
502
|
+
},
|
|
503
|
+
body: requestBody,
|
|
504
|
+
})
|
|
505
|
+
: await (context.credentialRequest ?? requestPinnedCredentialJson)({
|
|
506
|
+
url: target,
|
|
507
|
+
addresses,
|
|
404
508
|
authorization: `Bearer ${sessionToken}`,
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const body =
|
|
409
|
-
|
|
509
|
+
body: requestBody,
|
|
510
|
+
});
|
|
511
|
+
const status = credentialResponse.status;
|
|
512
|
+
const body = credentialResponse instanceof Response
|
|
513
|
+
? await readBoundedResponseJson(credentialResponse, MAX_CREDENTIAL_RESPONSE_BYTES, 'Control-plane credential response')
|
|
514
|
+
: credentialResponse.body;
|
|
515
|
+
if (status < 200 || status >= 300) {
|
|
410
516
|
throw new Error(isRecord(body) && typeof body.message === 'string'
|
|
411
517
|
? body.message
|
|
412
|
-
: `Control plane returned ${
|
|
518
|
+
: `Control plane returned ${status}`);
|
|
413
519
|
}
|
|
414
520
|
// Whitelist non-sensitive response fields. Never echo client secret material
|
|
415
521
|
// (or any other token-shaped value) back through the MCP transport.
|
|
@@ -430,13 +536,17 @@ export async function callMcpTool(name, args, context) {
|
|
|
430
536
|
const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.min(Math.floor(args.limit), 25) : 8;
|
|
431
537
|
const docsUrl = resolveMcpConfig(context).docsUrl;
|
|
432
538
|
const catalogUrl = new URL('/llms.txt', docsUrl);
|
|
433
|
-
const response = await fetch
|
|
539
|
+
const response = await (context.fetch ?? fetch)(catalogUrl, {
|
|
540
|
+
signal: AbortSignal.timeout(8000),
|
|
541
|
+
}).catch((error) => {
|
|
434
542
|
throw new Error(`Could not reach the docs catalog at ${catalogUrl.toString()}: ${error instanceof Error ? error.message : String(error)}`);
|
|
435
543
|
});
|
|
436
544
|
if (!response.ok) {
|
|
545
|
+
void response.body?.cancel().catch(() => undefined);
|
|
437
546
|
throw new Error(`Docs catalog returned ${response.status} from ${catalogUrl.toString()}`);
|
|
438
547
|
}
|
|
439
|
-
const
|
|
548
|
+
const catalog = await readBoundedResponseText(response, MAX_DOCS_CATALOG_RESPONSE_BYTES, 'Docs catalog response');
|
|
549
|
+
const entries = parseDocsCatalog(catalog);
|
|
440
550
|
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
441
551
|
const results = entries
|
|
442
552
|
.map((entry) => ({ entry, score: scoreDocEntry(entry, terms) }))
|
|
@@ -476,17 +586,107 @@ export async function callMcpTool(name, args, context) {
|
|
|
476
586
|
if (!path.endsWith('.md'))
|
|
477
587
|
path = `${path.replace(/\/+$/, '')}.md`;
|
|
478
588
|
const target = new URL(path, docsUrl);
|
|
479
|
-
const response = await fetch
|
|
589
|
+
const response = await (context.fetch ?? fetch)(target, {
|
|
590
|
+
signal: AbortSignal.timeout(8000),
|
|
591
|
+
}).catch((error) => {
|
|
480
592
|
throw new Error(`Could not reach ${target.toString()}: ${error instanceof Error ? error.message : String(error)}`);
|
|
481
593
|
});
|
|
482
594
|
if (!response.ok) {
|
|
595
|
+
void response.body?.cancel().catch(() => undefined);
|
|
483
596
|
throw new Error(`Doc not found (${response.status}): ${path}`);
|
|
484
597
|
}
|
|
485
598
|
// Return the markdown directly — it IS the useful payload for an agent.
|
|
486
|
-
return {
|
|
599
|
+
return {
|
|
600
|
+
content: [
|
|
601
|
+
{
|
|
602
|
+
type: 'text',
|
|
603
|
+
text: await readBoundedResponseText(response, MAX_DOCS_PAGE_RESPONSE_BYTES, 'Docs page response'),
|
|
604
|
+
},
|
|
605
|
+
],
|
|
606
|
+
};
|
|
487
607
|
}
|
|
488
608
|
throw new Error(`Unknown MCP tool: ${name}`);
|
|
489
609
|
}
|
|
610
|
+
const MAX_DOCS_CATALOG_RESPONSE_BYTES = 1024 * 1024;
|
|
611
|
+
const MAX_DOCS_PAGE_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
612
|
+
async function readBoundedResponseJson(response, maxBytes, label) {
|
|
613
|
+
const text = await readBoundedResponseText(response, maxBytes, label);
|
|
614
|
+
return parseUpstreamJson(text, label);
|
|
615
|
+
}
|
|
616
|
+
async function readBoundedResponseText(response, maxBytes, label) {
|
|
617
|
+
if (declaredContentLengthExceeds(response.headers.get('content-length'), maxBytes)) {
|
|
618
|
+
void response.body?.cancel().catch(() => undefined);
|
|
619
|
+
throw new Error(`${label} exceeded ${formatByteLimit(maxBytes)}.`);
|
|
620
|
+
}
|
|
621
|
+
if (!response.body)
|
|
622
|
+
return '';
|
|
623
|
+
const reader = response.body.getReader();
|
|
624
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
625
|
+
let bytes = 0;
|
|
626
|
+
let text = '';
|
|
627
|
+
try {
|
|
628
|
+
while (true) {
|
|
629
|
+
const chunk = await reader.read();
|
|
630
|
+
if (chunk.done)
|
|
631
|
+
break;
|
|
632
|
+
bytes += chunk.value.byteLength;
|
|
633
|
+
if (bytes > maxBytes) {
|
|
634
|
+
throw new Error(`${label} exceeded ${formatByteLimit(maxBytes)}.`);
|
|
635
|
+
}
|
|
636
|
+
try {
|
|
637
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
throw new Error(`${label} was not valid UTF-8.`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
try {
|
|
644
|
+
return text + decoder.decode();
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
throw new Error(`${label} was not valid UTF-8.`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
catch (error) {
|
|
651
|
+
await reader.cancel(error).catch(() => undefined);
|
|
652
|
+
throw error;
|
|
653
|
+
}
|
|
654
|
+
finally {
|
|
655
|
+
reader.releaseLock();
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
function decodeUpstreamUtf8(bytes, label) {
|
|
659
|
+
try {
|
|
660
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
throw new Error(`${label} was not valid UTF-8.`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
function parseUpstreamJson(text, label) {
|
|
667
|
+
if (text.length === 0) {
|
|
668
|
+
throw new Error(`${label} was empty; expected JSON.`);
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
return JSON.parse(text);
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
throw new Error(`${label} was not valid JSON.`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
function declaredContentLengthExceeds(value, maxBytes) {
|
|
678
|
+
const normalized = value?.trim();
|
|
679
|
+
if (!normalized || !/^\d+$/.test(normalized))
|
|
680
|
+
return false;
|
|
681
|
+
return BigInt(normalized) > BigInt(maxBytes);
|
|
682
|
+
}
|
|
683
|
+
function formatByteLimit(maxBytes) {
|
|
684
|
+
if (maxBytes % (1024 * 1024) === 0)
|
|
685
|
+
return `${maxBytes / (1024 * 1024)} MiB`;
|
|
686
|
+
if (maxBytes % 1024 === 0)
|
|
687
|
+
return `${maxBytes / 1024} KiB`;
|
|
688
|
+
return `${maxBytes} bytes`;
|
|
689
|
+
}
|
|
490
690
|
/** Parse the docs site's /llms.txt catalog: `## Section` headers and
|
|
491
691
|
* `- [Title](/docs/...md): description` entries. */
|
|
492
692
|
function parseDocsCatalog(text) {
|
|
@@ -495,16 +695,20 @@ function parseDocsCatalog(text) {
|
|
|
495
695
|
for (const raw of text.split('\n')) {
|
|
496
696
|
const line = raw.trimEnd();
|
|
497
697
|
const sectionMatch = line.match(/^##\s+(.+)$/);
|
|
498
|
-
|
|
499
|
-
|
|
698
|
+
const sectionTitle = sectionMatch?.[1];
|
|
699
|
+
if (sectionTitle) {
|
|
700
|
+
section = sectionTitle.trim();
|
|
500
701
|
continue;
|
|
501
702
|
}
|
|
502
703
|
const entryMatch = line.match(/^-\s+\[([^\]]+)\]\(([^)]+)\):\s*(.+)$/);
|
|
503
|
-
|
|
704
|
+
const title = entryMatch?.[1];
|
|
705
|
+
const url = entryMatch?.[2];
|
|
706
|
+
const description = entryMatch?.[3];
|
|
707
|
+
if (title && url && description) {
|
|
504
708
|
entries.push({
|
|
505
|
-
title:
|
|
506
|
-
url:
|
|
507
|
-
description:
|
|
709
|
+
title: title.trim(),
|
|
710
|
+
url: url.trim(),
|
|
711
|
+
description: description.trim(),
|
|
508
712
|
section,
|
|
509
713
|
});
|
|
510
714
|
}
|
|
@@ -541,43 +745,160 @@ async function dispatchMcpRequest(request, context) {
|
|
|
541
745
|
}
|
|
542
746
|
if (request.method === 'tools/call') {
|
|
543
747
|
const params = parseToolCallParams(request.params);
|
|
544
|
-
|
|
748
|
+
if (!tools.some((tool) => tool.name === params.name)) {
|
|
749
|
+
throw new JsonRpcError(-32602, `Unknown MCP tool: ${params.name}`);
|
|
750
|
+
}
|
|
751
|
+
try {
|
|
752
|
+
return await callMcpTool(params.name, params.arguments, context);
|
|
753
|
+
}
|
|
754
|
+
catch (error) {
|
|
755
|
+
return toolError(params.name, error);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (request.method === 'notifications/initialized' ||
|
|
759
|
+
request.method === 'notifications/cancelled') {
|
|
760
|
+
return {};
|
|
545
761
|
}
|
|
546
|
-
throw new
|
|
762
|
+
throw new JsonRpcError(-32601, `Unsupported MCP method: ${request.method}`);
|
|
547
763
|
}
|
|
548
764
|
function parseToolCallParams(params) {
|
|
549
|
-
if (!isRecord(params) || typeof params.name !== 'string') {
|
|
550
|
-
throw new
|
|
765
|
+
if (!isRecord(params) || Array.isArray(params) || typeof params.name !== 'string') {
|
|
766
|
+
throw new JsonRpcError(-32602, 'tools/call requires params.name');
|
|
551
767
|
}
|
|
552
|
-
|
|
553
|
-
|
|
768
|
+
for (const key of Object.keys(params)) {
|
|
769
|
+
if (key !== 'name' && key !== 'arguments') {
|
|
770
|
+
throw new JsonRpcError(-32602, `Unexpected tools/call parameter: ${key}`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
if (params.name.length === 0 || params.name.length > 256) {
|
|
774
|
+
throw new JsonRpcError(-32602, 'tools/call params.name must be 1 to 256 characters');
|
|
775
|
+
}
|
|
776
|
+
if (params.arguments !== undefined &&
|
|
777
|
+
(!isRecord(params.arguments) || Array.isArray(params.arguments))) {
|
|
778
|
+
throw new JsonRpcError(-32602, 'tools/call params.arguments must be an object when provided');
|
|
554
779
|
}
|
|
555
780
|
return {
|
|
556
781
|
name: params.name,
|
|
557
782
|
arguments: params.arguments ?? {},
|
|
558
783
|
};
|
|
559
784
|
}
|
|
560
|
-
function
|
|
785
|
+
function validateToolArguments(tool, args) {
|
|
786
|
+
validateObjectAgainstSchema(args, tool.inputSchema, 'arguments');
|
|
787
|
+
}
|
|
788
|
+
function validateObjectAgainstSchema(value, schema, path) {
|
|
789
|
+
for (const required of schema.required ?? []) {
|
|
790
|
+
if (!(required in value) || value[required] === undefined) {
|
|
791
|
+
throw new Error(`${path}.${required} is required`);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
if (schema.additionalProperties === false) {
|
|
795
|
+
for (const key of Object.keys(value)) {
|
|
796
|
+
if (!schema.properties || !(key in schema.properties)) {
|
|
797
|
+
throw new Error(`${path}.${key} is not allowed`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
for (const [key, propertySchema] of Object.entries(schema.properties ?? {})) {
|
|
802
|
+
const propertyValue = value[key];
|
|
803
|
+
if (propertyValue === undefined)
|
|
804
|
+
continue;
|
|
805
|
+
validateValueAgainstSchema(propertyValue, propertySchema, `${path}.${key}`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
function validateValueAgainstSchema(value, schema, path) {
|
|
809
|
+
if (schema.type === 'string') {
|
|
810
|
+
if (typeof value !== 'string')
|
|
811
|
+
throw new Error(`${path} must be a string`);
|
|
812
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
|
813
|
+
throw new Error(`${path} must contain at least ${schema.minLength} character(s)`);
|
|
814
|
+
}
|
|
815
|
+
if (schema.maxLength !== undefined && value.length > schema.maxLength) {
|
|
816
|
+
throw new Error(`${path} must contain at most ${schema.maxLength} characters`);
|
|
817
|
+
}
|
|
818
|
+
if (schema.pattern && !new RegExp(schema.pattern, 'u').test(value)) {
|
|
819
|
+
throw new Error(`${path} does not match the required format`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
else if (schema.type === 'number' || schema.type === 'integer') {
|
|
823
|
+
if (typeof value !== 'number' ||
|
|
824
|
+
!Number.isFinite(value) ||
|
|
825
|
+
(schema.type === 'integer' && !Number.isSafeInteger(value))) {
|
|
826
|
+
throw new Error(`${path} must be ${schema.type === 'integer' ? 'an integer' : 'a number'}`);
|
|
827
|
+
}
|
|
828
|
+
if (schema.minimum !== undefined && value < schema.minimum) {
|
|
829
|
+
throw new Error(`${path} must be at least ${schema.minimum}`);
|
|
830
|
+
}
|
|
831
|
+
if (schema.maximum !== undefined && value > schema.maximum) {
|
|
832
|
+
throw new Error(`${path} must be at most ${schema.maximum}`);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
else if (schema.type === 'boolean') {
|
|
836
|
+
if (typeof value !== 'boolean')
|
|
837
|
+
throw new Error(`${path} must be a boolean`);
|
|
838
|
+
}
|
|
839
|
+
else {
|
|
840
|
+
if (!isRecord(value) || Array.isArray(value))
|
|
841
|
+
throw new Error(`${path} must be an object`);
|
|
842
|
+
if (schema.properties || schema.additionalProperties === false) {
|
|
843
|
+
validateObjectAgainstSchema(value, schema, path);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
847
|
+
throw new Error(`${path} must be one of: ${schema.enum.join(', ')}`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
async function projectRoot(args, context) {
|
|
561
851
|
if (args.root !== undefined && typeof args.root !== 'string') {
|
|
562
852
|
throw new Error('root must be a string when provided');
|
|
563
853
|
}
|
|
564
854
|
const rootArg = args.root ?? '.';
|
|
565
|
-
const allowedRoots = buildAllowedRoots(context);
|
|
566
855
|
const resolved = resolve(context.cwd, rootArg);
|
|
856
|
+
const containmentError = 'root must resolve within the MCP working directory or an HSX_MCP_ALLOWED_ROOTS entry';
|
|
857
|
+
if (!buildLexicalAllowedRoots(context).some((allowed) => isWithin(resolved, allowed))) {
|
|
858
|
+
throw new Error(containmentError);
|
|
859
|
+
}
|
|
860
|
+
const { realpath, stat } = await import('node:fs/promises');
|
|
861
|
+
const allowedRoots = await buildAllowedRoots(context);
|
|
862
|
+
let canonicalRoot;
|
|
863
|
+
try {
|
|
864
|
+
canonicalRoot = await realpath(resolved);
|
|
865
|
+
if (!(await stat(canonicalRoot)).isDirectory()) {
|
|
866
|
+
throw new Error('root must resolve to a directory');
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
catch (error) {
|
|
870
|
+
if (error instanceof Error && error.message === 'root must resolve to a directory')
|
|
871
|
+
throw error;
|
|
872
|
+
throw new Error('root must resolve to an existing directory');
|
|
873
|
+
}
|
|
567
874
|
for (const allowed of allowedRoots) {
|
|
568
|
-
if (isWithin(
|
|
569
|
-
return
|
|
875
|
+
if (isWithin(canonicalRoot, allowed)) {
|
|
876
|
+
return canonicalRoot;
|
|
570
877
|
}
|
|
571
878
|
}
|
|
572
|
-
throw new Error(
|
|
879
|
+
throw new Error(containmentError);
|
|
573
880
|
}
|
|
574
|
-
function
|
|
881
|
+
function buildLexicalAllowedRoots(context) {
|
|
575
882
|
const config = resolveMcpConfig(context);
|
|
576
|
-
const
|
|
577
|
-
|
|
883
|
+
const lexicalRoots = new Set();
|
|
884
|
+
lexicalRoots.add(resolve(context.cwd));
|
|
578
885
|
for (const entry of config.allowedRoots) {
|
|
579
886
|
if (isAbsolute(entry)) {
|
|
580
|
-
|
|
887
|
+
lexicalRoots.add(resolve(entry));
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return Array.from(lexicalRoots);
|
|
891
|
+
}
|
|
892
|
+
async function buildAllowedRoots(context) {
|
|
893
|
+
const { realpath } = await import('node:fs/promises');
|
|
894
|
+
const lexicalRoots = buildLexicalAllowedRoots(context);
|
|
895
|
+
const roots = new Set();
|
|
896
|
+
for (const lexicalRoot of lexicalRoots) {
|
|
897
|
+
try {
|
|
898
|
+
roots.add(await realpath(lexicalRoot));
|
|
899
|
+
}
|
|
900
|
+
catch {
|
|
901
|
+
// A configured root that does not exist cannot authorize anything.
|
|
581
902
|
}
|
|
582
903
|
}
|
|
583
904
|
return Array.from(roots);
|
|
@@ -605,6 +926,111 @@ function readSessionToken(_context) {
|
|
|
605
926
|
function resolveMcpConfig(context) {
|
|
606
927
|
return context.config ?? loadMcpConfig();
|
|
607
928
|
}
|
|
929
|
+
async function requirePublicControlPlaneResolution(origin, injectedLookup) {
|
|
930
|
+
const lookup = injectedLookup ??
|
|
931
|
+
(async (hostname) => {
|
|
932
|
+
const { lookup: dnsLookup } = await import('node:dns/promises');
|
|
933
|
+
return dnsLookup(hostname, { all: true, verbatim: true });
|
|
934
|
+
});
|
|
935
|
+
let addresses;
|
|
936
|
+
try {
|
|
937
|
+
addresses = await lookup(origin.hostname.replace(/^\[|\]$/g, ''));
|
|
938
|
+
}
|
|
939
|
+
catch (error) {
|
|
940
|
+
throw new Error(`Could not resolve the configured control-plane origin safely: ${error instanceof Error ? error.message : String(error)}`);
|
|
941
|
+
}
|
|
942
|
+
if (addresses.length === 0 ||
|
|
943
|
+
addresses.some((entry) => isPrivateOrLoopbackHostname(entry.address))) {
|
|
944
|
+
throw new Error('The configured control-plane origin resolves to a private or loopback address; refusing to send credentials.');
|
|
945
|
+
}
|
|
946
|
+
return addresses;
|
|
947
|
+
}
|
|
948
|
+
const MAX_CREDENTIAL_RESPONSE_BYTES = 64 * 1024;
|
|
949
|
+
class CredentialRequestPolicyError extends Error {
|
|
950
|
+
}
|
|
951
|
+
async function requestPinnedCredentialJson(input) {
|
|
952
|
+
if (input.addresses.length === 0) {
|
|
953
|
+
throw new Error('The configured control-plane origin resolved to no addresses.');
|
|
954
|
+
}
|
|
955
|
+
let lastError;
|
|
956
|
+
for (const pinned of input.addresses) {
|
|
957
|
+
try {
|
|
958
|
+
return await requestPinnedCredentialJsonAtAddress(input, pinned);
|
|
959
|
+
}
|
|
960
|
+
catch (error) {
|
|
961
|
+
if (error instanceof CredentialRequestPolicyError)
|
|
962
|
+
throw error;
|
|
963
|
+
lastError = error;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
throw lastError instanceof Error
|
|
967
|
+
? lastError
|
|
968
|
+
: new Error('Could not connect to any validated control-plane address.');
|
|
969
|
+
}
|
|
970
|
+
async function requestPinnedCredentialJsonAtAddress(input, pinned) {
|
|
971
|
+
const lookup = (_hostname, options, callback) => {
|
|
972
|
+
if (typeof options === 'object' && options.all) {
|
|
973
|
+
callback(null, [{ ...pinned }]);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
callback(null, pinned.address, pinned.family);
|
|
977
|
+
};
|
|
978
|
+
return new Promise((resolve, reject) => {
|
|
979
|
+
const request = httpsRequest(input.url, {
|
|
980
|
+
method: 'PUT',
|
|
981
|
+
lookup,
|
|
982
|
+
headers: {
|
|
983
|
+
accept: 'application/json',
|
|
984
|
+
authorization: input.authorization,
|
|
985
|
+
'content-type': 'application/json',
|
|
986
|
+
'content-length': Buffer.byteLength(input.body),
|
|
987
|
+
},
|
|
988
|
+
}, (response) => {
|
|
989
|
+
const status = response.statusCode ?? 0;
|
|
990
|
+
if (status >= 300 && status < 400) {
|
|
991
|
+
response.resume();
|
|
992
|
+
reject(new CredentialRequestPolicyError('Control-plane redirects are not allowed for credential requests.'));
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const declaredLength = response.headers['content-length'];
|
|
996
|
+
if (declaredContentLengthExceeds(Array.isArray(declaredLength) ? declaredLength[0] : declaredLength, MAX_CREDENTIAL_RESPONSE_BYTES)) {
|
|
997
|
+
response.resume();
|
|
998
|
+
reject(new CredentialRequestPolicyError(`Control-plane credential response exceeded ${formatByteLimit(MAX_CREDENTIAL_RESPONSE_BYTES)}.`));
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
const chunks = [];
|
|
1002
|
+
let bytes = 0;
|
|
1003
|
+
response.on('data', (chunk) => {
|
|
1004
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1005
|
+
bytes += buffer.byteLength;
|
|
1006
|
+
if (bytes > MAX_CREDENTIAL_RESPONSE_BYTES) {
|
|
1007
|
+
response.destroy(new CredentialRequestPolicyError(`Control-plane credential response exceeded ${formatByteLimit(MAX_CREDENTIAL_RESPONSE_BYTES)}.`));
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
chunks.push(buffer);
|
|
1011
|
+
});
|
|
1012
|
+
response.on('end', () => {
|
|
1013
|
+
try {
|
|
1014
|
+
const text = decodeUpstreamUtf8(Buffer.concat(chunks), 'Control-plane credential response');
|
|
1015
|
+
resolve({ status, body: parseUpstreamJson(text, 'Control-plane credential response') });
|
|
1016
|
+
}
|
|
1017
|
+
catch (error) {
|
|
1018
|
+
reject(new CredentialRequestPolicyError(error instanceof Error ? error.message : String(error)));
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
response.on('error', reject);
|
|
1022
|
+
});
|
|
1023
|
+
request.setTimeout(8_000, () => {
|
|
1024
|
+
request.destroy(new Error('Control-plane credential request timed out.'));
|
|
1025
|
+
});
|
|
1026
|
+
request.on('error', reject);
|
|
1027
|
+
request.end(input.body);
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
function requireUnsafeProjectExecutionOptIn(context, toolName) {
|
|
1031
|
+
void context;
|
|
1032
|
+
throw new Error(`${toolName} is not available: HS-X does not expose project-code execution through the local MCP process. Use the hs-x CLI directly until an isolated execution service exists.`);
|
|
1033
|
+
}
|
|
608
1034
|
function requiredString(args, key) {
|
|
609
1035
|
const value = args[key];
|
|
610
1036
|
if (typeof value !== 'string' || value.length === 0) {
|
|
@@ -633,24 +1059,236 @@ function content(value) {
|
|
|
633
1059
|
content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
634
1060
|
};
|
|
635
1061
|
}
|
|
1062
|
+
function toolError(toolName, error) {
|
|
1063
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1064
|
+
return {
|
|
1065
|
+
isError: true,
|
|
1066
|
+
content: [
|
|
1067
|
+
{
|
|
1068
|
+
type: 'text',
|
|
1069
|
+
text: JSON.stringify({ ok: false, tool: toolName, error: message }, null, 2),
|
|
1070
|
+
},
|
|
1071
|
+
],
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
636
1074
|
function isRecord(value) {
|
|
637
1075
|
return typeof value === 'object' && value !== null;
|
|
638
1076
|
}
|
|
639
1077
|
async function readDevSession(root) {
|
|
1078
|
+
const { constants } = await import('node:fs');
|
|
1079
|
+
const { open } = await import('node:fs/promises');
|
|
1080
|
+
const paths = await resolveDevStatePaths(root, false);
|
|
1081
|
+
if (!paths.stateDirectoryExists)
|
|
1082
|
+
return undefined;
|
|
1083
|
+
let handle;
|
|
1084
|
+
let raw;
|
|
640
1085
|
try {
|
|
641
|
-
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
|
|
1086
|
+
handle = await open(paths.sessionPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
1087
|
+
const metadata = await handle.stat();
|
|
1088
|
+
if (!metadata.isFile()) {
|
|
1089
|
+
throw new Error('The dev-session record is not a regular file; refusing to trust it.');
|
|
1090
|
+
}
|
|
1091
|
+
if (metadata.size > 16 * 1024) {
|
|
1092
|
+
throw new Error('The dev-session record is too large; refusing to trust it.');
|
|
1093
|
+
}
|
|
1094
|
+
raw = await handle.readFile('utf8');
|
|
1095
|
+
}
|
|
1096
|
+
catch (error) {
|
|
1097
|
+
if (isNodeError(error) && error.code === 'ENOENT')
|
|
1098
|
+
return undefined;
|
|
1099
|
+
throw error;
|
|
1100
|
+
}
|
|
1101
|
+
finally {
|
|
1102
|
+
await handle?.close();
|
|
1103
|
+
}
|
|
1104
|
+
let parsed;
|
|
1105
|
+
try {
|
|
1106
|
+
parsed = JSON.parse(raw);
|
|
645
1107
|
}
|
|
646
1108
|
catch {
|
|
647
|
-
|
|
1109
|
+
throw new Error('The dev-session record is not valid JSON; refusing to trust it.');
|
|
648
1110
|
}
|
|
1111
|
+
if (!isRecord(parsed) || Array.isArray(parsed)) {
|
|
1112
|
+
throw new Error('The dev-session record has an invalid schema; refusing to trust it.');
|
|
1113
|
+
}
|
|
1114
|
+
const keys = Object.keys(parsed).sort();
|
|
1115
|
+
if (keys.join(',') !== 'pid,port,schemaVersion' ||
|
|
1116
|
+
parsed.schemaVersion !== 1 ||
|
|
1117
|
+
!Number.isSafeInteger(parsed.pid) ||
|
|
1118
|
+
parsed.pid <= 0 ||
|
|
1119
|
+
!Number.isSafeInteger(parsed.port) ||
|
|
1120
|
+
parsed.port < 1 ||
|
|
1121
|
+
parsed.port > 65_535) {
|
|
1122
|
+
throw new Error('The dev-session record has an invalid schema; refusing to trust it.');
|
|
1123
|
+
}
|
|
1124
|
+
return parsed;
|
|
649
1125
|
}
|
|
650
1126
|
async function writeDevSession(root, session) {
|
|
651
|
-
const {
|
|
1127
|
+
const { open } = await import('node:fs/promises');
|
|
1128
|
+
const paths = await resolveDevStatePaths(root, true);
|
|
1129
|
+
let handle;
|
|
1130
|
+
try {
|
|
1131
|
+
handle = await open(paths.sessionPath, 'wx', 0o600);
|
|
1132
|
+
await handle.writeFile(`${JSON.stringify(session, null, 2)}\n`, 'utf8');
|
|
1133
|
+
}
|
|
1134
|
+
catch (error) {
|
|
1135
|
+
if (isNodeError(error) && error.code === 'EEXIST') {
|
|
1136
|
+
throw new Error('A dev-session record already exists; refusing to overwrite it.');
|
|
1137
|
+
}
|
|
1138
|
+
throw error;
|
|
1139
|
+
}
|
|
1140
|
+
finally {
|
|
1141
|
+
await handle?.close();
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
async function removeDevSession(root) {
|
|
1145
|
+
const { rm } = await import('node:fs/promises');
|
|
1146
|
+
const paths = await resolveDevStatePaths(root, false);
|
|
1147
|
+
if (paths.stateDirectoryExists)
|
|
1148
|
+
await rm(paths.sessionPath, { force: true });
|
|
1149
|
+
}
|
|
1150
|
+
async function devSessionView(root, session) {
|
|
1151
|
+
const paths = await resolveDevStatePaths(root, false);
|
|
1152
|
+
return {
|
|
1153
|
+
pid: session.pid,
|
|
1154
|
+
port: session.port,
|
|
1155
|
+
url: `http://127.0.0.1:${session.port}`,
|
|
1156
|
+
logPath: paths.logPath,
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
async function devSessionMatchesProcess(session, root) {
|
|
1160
|
+
if (!Number.isSafeInteger(session.pid) || session.pid <= 0)
|
|
1161
|
+
return false;
|
|
1162
|
+
const binary = process.env.HSX_MCP_DEV_BINARY ?? 'hs-x';
|
|
1163
|
+
const [, ...prefixArgs] = binary.split(' ');
|
|
1164
|
+
const expectedSuffix = [...prefixArgs, 'dev', '--cwd', root, '--port', String(session.port)].join(' ');
|
|
1165
|
+
try {
|
|
1166
|
+
const { execFile } = await import('node:child_process');
|
|
1167
|
+
const command = await new Promise((resolvePromise, reject) => {
|
|
1168
|
+
execFile('ps', ['-ww', '-p', String(session.pid), '-o', 'command='], { timeout: 1500, maxBuffer: 64 * 1024 }, (error, stdout) => {
|
|
1169
|
+
if (error)
|
|
1170
|
+
reject(error);
|
|
1171
|
+
else
|
|
1172
|
+
resolvePromise(stdout.trim());
|
|
1173
|
+
});
|
|
1174
|
+
});
|
|
1175
|
+
return command === expectedSuffix || command.endsWith(` ${expectedSuffix}`);
|
|
1176
|
+
}
|
|
1177
|
+
catch {
|
|
1178
|
+
return false;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
async function resolveDevStatePaths(root, create) {
|
|
1182
|
+
const { mkdir, realpath, stat } = await import('node:fs/promises');
|
|
652
1183
|
const { join } = await import('node:path');
|
|
653
|
-
|
|
1184
|
+
const canonicalRoot = await realpath(root);
|
|
1185
|
+
const lexicalStateDirectory = join(canonicalRoot, '.hs-x');
|
|
1186
|
+
if (create)
|
|
1187
|
+
await mkdir(lexicalStateDirectory, { recursive: true, mode: 0o700 });
|
|
1188
|
+
let canonicalStateDirectory;
|
|
1189
|
+
try {
|
|
1190
|
+
canonicalStateDirectory = await realpath(lexicalStateDirectory);
|
|
1191
|
+
}
|
|
1192
|
+
catch (error) {
|
|
1193
|
+
if (!create && isNodeError(error) && error.code === 'ENOENT') {
|
|
1194
|
+
return {
|
|
1195
|
+
stateDirectoryExists: false,
|
|
1196
|
+
stateDirectory: lexicalStateDirectory,
|
|
1197
|
+
sessionPath: join(lexicalStateDirectory, 'dev-session.json'),
|
|
1198
|
+
logPath: join(lexicalStateDirectory, 'dev-server.log'),
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
throw error;
|
|
1202
|
+
}
|
|
1203
|
+
if (!isWithin(canonicalStateDirectory, canonicalRoot)) {
|
|
1204
|
+
throw new Error('The .hs-x directory resolves outside the project root; refusing access.');
|
|
1205
|
+
}
|
|
1206
|
+
const state = await stat(canonicalStateDirectory);
|
|
1207
|
+
if (!state.isDirectory()) {
|
|
1208
|
+
throw new Error('The .hs-x path is not a directory; refusing access.');
|
|
1209
|
+
}
|
|
1210
|
+
return {
|
|
1211
|
+
stateDirectoryExists: true,
|
|
1212
|
+
stateDirectory: canonicalStateDirectory,
|
|
1213
|
+
sessionPath: join(canonicalStateDirectory, 'dev-session.json'),
|
|
1214
|
+
logPath: join(canonicalStateDirectory, 'dev-server.log'),
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
const MAX_LOG_LINES = 1000;
|
|
1218
|
+
const MAX_LOG_TAIL_BYTES = 256 * 1024;
|
|
1219
|
+
function optionalLogLines(value) {
|
|
1220
|
+
if (value === undefined)
|
|
1221
|
+
return 50;
|
|
1222
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > MAX_LOG_LINES) {
|
|
1223
|
+
throw new Error(`lines must be an integer from 1 to ${MAX_LOG_LINES}`);
|
|
1224
|
+
}
|
|
1225
|
+
return value;
|
|
1226
|
+
}
|
|
1227
|
+
function optionalPort(value) {
|
|
1228
|
+
if (value === undefined)
|
|
1229
|
+
return 8787;
|
|
1230
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 65_535) {
|
|
1231
|
+
throw new Error('port must be an integer from 1 to 65535');
|
|
1232
|
+
}
|
|
1233
|
+
return value;
|
|
1234
|
+
}
|
|
1235
|
+
async function readBoundedLogTail(root, lines) {
|
|
1236
|
+
const { constants } = await import('node:fs');
|
|
1237
|
+
const { open, realpath } = await import('node:fs/promises');
|
|
1238
|
+
const paths = await resolveDevStatePaths(root, false);
|
|
1239
|
+
if (!paths.stateDirectoryExists)
|
|
1240
|
+
return [];
|
|
1241
|
+
let canonicalLogPath;
|
|
1242
|
+
try {
|
|
1243
|
+
canonicalLogPath = await realpath(paths.logPath);
|
|
1244
|
+
}
|
|
1245
|
+
catch (error) {
|
|
1246
|
+
if (isNodeError(error) && error.code === 'ENOENT')
|
|
1247
|
+
return [];
|
|
1248
|
+
throw error;
|
|
1249
|
+
}
|
|
1250
|
+
if (!isWithin(canonicalLogPath, paths.stateDirectory)) {
|
|
1251
|
+
throw new Error('The dev log resolves outside .hs-x; refusing access.');
|
|
1252
|
+
}
|
|
1253
|
+
const handle = await open(canonicalLogPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
1254
|
+
try {
|
|
1255
|
+
const metadata = await handle.stat();
|
|
1256
|
+
if (!metadata.isFile()) {
|
|
1257
|
+
throw new Error('The dev log is not a regular file; refusing access.');
|
|
1258
|
+
}
|
|
1259
|
+
const bytes = Math.min(metadata.size, MAX_LOG_TAIL_BYTES);
|
|
1260
|
+
const start = metadata.size - bytes;
|
|
1261
|
+
const buffer = Buffer.alloc(bytes);
|
|
1262
|
+
const { bytesRead } = await handle.read(buffer, 0, bytes, start);
|
|
1263
|
+
let text = buffer.subarray(0, bytesRead).toString('utf8');
|
|
1264
|
+
if (start > 0)
|
|
1265
|
+
text = text.slice(Math.max(0, text.indexOf('\n') + 1));
|
|
1266
|
+
return text.split('\n').filter(Boolean).slice(-lines);
|
|
1267
|
+
}
|
|
1268
|
+
finally {
|
|
1269
|
+
await handle.close();
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
async function openDevLogForAppend(root) {
|
|
1273
|
+
const { constants } = await import('node:fs');
|
|
1274
|
+
const { open } = await import('node:fs/promises');
|
|
1275
|
+
const paths = await resolveDevStatePaths(root, true);
|
|
1276
|
+
const handle = await open(paths.logPath, constants.O_APPEND | constants.O_CREAT | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
1277
|
+
try {
|
|
1278
|
+
const metadata = await handle.stat();
|
|
1279
|
+
if (!metadata.isFile()) {
|
|
1280
|
+
throw new Error('The dev log is not a regular file; refusing access.');
|
|
1281
|
+
}
|
|
1282
|
+
await handle.chmod(0o600);
|
|
1283
|
+
return { logPath: paths.logPath, stream: handle.createWriteStream() };
|
|
1284
|
+
}
|
|
1285
|
+
catch (error) {
|
|
1286
|
+
await handle.close();
|
|
1287
|
+
throw error;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
function isNodeError(error) {
|
|
1291
|
+
return error instanceof Error && 'code' in error;
|
|
654
1292
|
}
|
|
655
1293
|
async function devSessionHealthy(port) {
|
|
656
1294
|
try {
|