@husar.ai/cli 0.2.14 → 0.3.2

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/src/mcp.ts CHANGED
@@ -2,73 +2,144 @@
2
2
  /* eslint-disable @typescript-eslint/no-explicit-any */
3
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
- import * as fs from 'node:fs/promises';
6
- import * as path from 'node:path';
7
5
  import { z } from 'zod';
8
6
  import { ConfigMaker } from 'config-maker';
9
- import { spawn } from 'node:child_process';
7
+ import { parser } from './functions/parser.js';
8
+ import { generateCms } from './functions/generate.js';
9
+ import path from 'path';
10
+ import { HusarConfigType } from '@/types/config.js';
11
+ import {
12
+ buildClientSchema,
13
+ printSchema,
14
+ buildSchema,
15
+ GraphQLSchema,
16
+ isObjectType,
17
+ getNamedType,
18
+ isNonNullType,
19
+ isListType,
20
+ GraphQLObjectType,
21
+ } from 'graphql';
10
22
 
11
23
  const server = new McpServer({ name: 'mcp-husar', version: '1.0.0' });
12
24
 
13
- let config: ConfigMaker<{ host: string; adminToken: string }> | undefined;
14
-
15
- const getWorkspaceRoot = async (): Promise<string> => {
16
- const envRoot = process.env.WORKSPACE_ROOT;
17
- if (envRoot) return envRoot;
18
- let cur = process.cwd();
19
- for (let i = 0; i < 10; i++) {
20
- try {
21
- const pkgPath = path.join(cur, 'package.json');
22
- const st = await fs.stat(pkgPath);
23
- if (st.isFile()) {
24
- const txt = await fs.readFile(pkgPath, 'utf8');
25
- const pkg = JSON.parse(txt) as { workspaces?: unknown };
26
- if (pkg && Object.prototype.hasOwnProperty.call(pkg, 'workspaces')) return cur;
25
+ // Minimal GraphQL introspection query compatible with GraphQL 16
26
+ const INTROSPECTION_QUERY = `
27
+ query IntrospectionQuery {
28
+ __schema {
29
+ queryType { name }
30
+ mutationType { name }
31
+ subscriptionType { name }
32
+ types {
33
+ kind
34
+ name
35
+ description
36
+ fields(includeDeprecated: true) {
37
+ name
38
+ description
39
+ args { name description type { kind name ofType { kind name ofType { kind name } } } defaultValue }
40
+ type { kind name ofType { kind name ofType { kind name } } }
41
+ isDeprecated
42
+ deprecationReason
43
+ }
44
+ inputFields { name description type { kind name ofType { kind name ofType { kind name } } } defaultValue }
45
+ interfaces { kind name ofType { kind name ofType { kind name } } }
46
+ enumValues(includeDeprecated: true) { name description isDeprecated deprecationReason }
47
+ possibleTypes { kind name ofType { kind name ofType { kind name } } }
27
48
  }
28
- } catch {
29
- /* empty */
49
+ directives { name description locations args { name description type { kind name ofType { kind name ofType { kind name } } } defaultValue } }
30
50
  }
31
- const parent = path.dirname(cur);
32
- if (parent === cur) break;
33
- cur = parent;
34
51
  }
35
- return process.cwd();
36
- };
52
+ `;
37
53
 
38
- const resolveWorkspacePath = async (p: string): Promise<string> => {
39
- if (path.isAbsolute(p)) return p;
40
- const root = await getWorkspaceRoot();
41
- return path.resolve(root, p);
42
- };
54
+ // Heuristic, local-only query suggester based on SDL and a text task
55
+ function tokenize(s: string): string[] {
56
+ return (s || '')
57
+ .toLowerCase()
58
+ .replace(/[^a-z0-9_]+/g, ' ')
59
+ .split(' ')
60
+ .filter(Boolean);
61
+ }
43
62
 
44
- const getConfig = async () => {
45
- if (config) return config;
46
- const root = await getWorkspaceRoot();
47
- config = new ConfigMaker<{ host: string; adminToken: string }>('husar', {
48
- decoders: {},
49
- pathToProject: root,
50
- });
51
- return config;
52
- };
63
+ function scoreField(taskTokens: string[], fieldName: string, returnTypeName: string, argNames: string[]): number {
64
+ const nameTokens = tokenize(fieldName).concat(tokenize(returnTypeName)).concat(argNames.flatMap(tokenize));
65
+ let score = 0;
66
+ for (const t of taskTokens) if (nameTokens.includes(t)) score += 2;
67
+ // lightweight substring bonus
68
+ for (const t of taskTokens) if (fieldName.includes(t)) score += 1;
69
+ return score;
70
+ }
53
71
 
54
- const getAuth = async () => {
55
- const envHost = process.env.HUSAR_MCP_HOST;
56
- const envToken = process.env.HUSAR_MCP_ADMIN_TOKEN;
57
- if (envHost && envToken) return { host: envHost, adminToken: envToken } as const;
72
+ function pickFieldsForSelection(type: GraphQLObjectType): string[] {
73
+ const fields = Object.values(type.getFields());
74
+ const preferred = ['id', '_id', 'slug', 'name', 'title', 'key', 'display', 'createdAt', 'updatedAt'];
75
+ const names = fields.map((f) => f.name);
76
+ const out: string[] = [];
77
+ for (const p of preferred) if (names.includes(p)) out.push(p);
78
+ for (const n of names) if (out.length < 6 && !out.includes(n)) out.push(n);
79
+ return out.slice(0, 6);
80
+ }
58
81
 
59
- // Non-interactive: read current config values only
60
- try {
61
- const cfg = await getConfig();
62
- const { host, adminToken } = cfg.get();
63
- if (host && adminToken) return { host, adminToken } as const;
64
- } catch {
65
- // ignore; we'll throw below
82
+ function variablesTemplateFromArgs(args: readonly { name: string; type: unknown }[]): Record<string, unknown> {
83
+ const tmpl: Record<string, unknown> = {};
84
+ for (const a of args) tmpl[a.name] = null;
85
+ return tmpl;
86
+ }
87
+
88
+ function unwrapNamed(type: any): { named: any; list: boolean } {
89
+ let t = type;
90
+ let list = false;
91
+ while (isNonNullType(t) || isListType(t)) {
92
+ if (isListType(t)) list = true;
93
+ t = (t as any).ofType;
94
+ }
95
+ return { named: t, list };
96
+ }
97
+
98
+ function suggestQueryFromSDL({ sdl, task, rootType = 'Query' }: { sdl: string; task: string; rootType?: string }) {
99
+ const schema: GraphQLSchema = buildSchema(sdl);
100
+ const root = schema.getType(rootType);
101
+ if (!root || !isObjectType(root)) throw new Error(`Root type ${rootType} not found or not an object`);
102
+ const fields = Object.values(root.getFields());
103
+ const taskTokens = tokenize(task);
104
+ let best = fields[0];
105
+ let bestScore = -Infinity;
106
+ for (const f of fields) {
107
+ const { named } = unwrapNamed(f.type as any);
108
+ const retName = (named as any).name || '';
109
+ const s = scoreField(taskTokens, f.name, retName, f.args.map((a) => a.name));
110
+ if (s > bestScore) {
111
+ best = f;
112
+ bestScore = s;
113
+ }
114
+ }
115
+ const argsDecl = best.args
116
+ .map((a) => `$${a.name}: ${(a.type as any).toString()}`)
117
+ .join(', ');
118
+ const argsUse = best.args.map((a) => `${a.name}: $${a.name}`).join(', ');
119
+ const { named, list } = unwrapNamed(best.type as any);
120
+ let selection = '__typename';
121
+ if (isObjectType(named)) {
122
+ const sel = pickFieldsForSelection(named as GraphQLObjectType);
123
+ selection = sel.join('\n ');
66
124
  }
125
+ const opName = 'GeneratedQuery';
126
+ const query = `query ${opName}${argsDecl ? `(${argsDecl})` : ''} {
127
+ ${best.name}${argsUse ? `(${argsUse})` : ''} {
128
+ ${list ? `nodes { ${selection} }` : selection}
129
+ }
130
+ }`;
131
+ const variables = variablesTemplateFromArgs(best.args);
132
+ const candidates = fields
133
+ .map((f) => ({ name: f.name, type: (getNamedType(f.type as any) as any)?.name || String(f.type) }))
134
+ .slice(0, 20);
135
+ return { query, variables, pickedField: best.name, rootType, candidates };
136
+ }
67
137
 
68
- throw new Error(
69
- 'Missing HUSAR_MCP_HOST and/or HUSAR_MCP_ADMIN_TOKEN. Provide via env or husar.json in workspace root. ' +
70
- (await getWorkspaceRoot()),
71
- );
138
+ const getConfig = async (pathToProject?: string) => {
139
+ return new ConfigMaker<HusarConfigType>('husar', {
140
+ decoders: {},
141
+ pathToProject,
142
+ });
72
143
  };
73
144
 
74
145
  const cmsParseFileInputFields = {
@@ -89,6 +160,12 @@ const cmsParseFileInputFields = {
89
160
  .describe(
90
161
  'Optional CMS name. Must not contain dashes (-). Prefer lowercase with underscores (e.g., examples_contactform). Defaults to the filename (lowercased).',
91
162
  ),
163
+ workspaceRoot: z
164
+ .string()
165
+ .optional()
166
+ .describe(
167
+ 'Target folder for where husar.json is located. Needed when run in monorepo where husar.json is located in child repo',
168
+ ),
92
169
  };
93
170
 
94
171
  const cmsParseFileInputSchema = z.object(cmsParseFileInputFields);
@@ -135,37 +212,33 @@ const redirectConsoleToStderr = () => {
135
212
  };
136
213
  };
137
214
 
138
- const runCli = async (args: string[], workspaceCwd: string) => {
139
- const npxBin = process.platform === 'win32' ? 'npx.cmd' : 'npx';
140
- return await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
141
- const child = spawn(npxBin, ['-y', '@husar.ai/cli', ...args], {
142
- cwd: workspaceCwd,
143
- stdio: ['ignore', 'pipe', 'pipe'],
144
- env: { ...process.env },
145
- shell: false,
146
- });
147
- let stdout = '';
148
- let stderr = '';
149
- child.stdout.on('data', (d) => (stdout += String(d)));
150
- child.stderr.on('data', (d) => (stderr += String(d)));
151
- child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
152
- });
153
- };
154
-
155
215
  const createCmsParseFileHandler = ({ timeoutMs = 30_000 }: { timeoutMs?: number }) => {
156
- return async ({ path: file, type, name: passed }: CmsParseFileInput) => {
216
+ return async ({ path: file, type, name: passed, workspaceRoot }: CmsParseFileInput) => {
157
217
  const restoreConsole = redirectConsoleToStderr();
158
218
  try {
159
- // Ensure config exists or env is set; if missing, CLI will error
160
- await getAuth();
161
- const absPath = await resolveWorkspacePath(file);
162
- const root = await getWorkspaceRoot();
163
- const name = (passed ?? (absPath.replace(/^.*\/(.*?)(\.[^.]+)?$/, '$1') || 'untitled')).toLowerCase();
164
- const res = await withTimeout(runCli(['copy', absPath, name, '-t', type ?? 'shape'], root), timeoutMs);
165
- if (res.code !== 0) {
166
- throw new Error(`CLI exited with code ${res.code}.\nSTDOUT:\n${res.stdout}\nSTDERR:\n${res.stderr}`);
219
+ const cfg = await getConfig(workspaceRoot);
220
+ const raw = cfg.get();
221
+ const inputFile = stripWorkspaceRoot(file, workspaceRoot);
222
+ const name = (passed ?? (inputFile.replace(/^.*\/(.*?)(\.[^.]+)?$/, '$1') || 'untitled')).toLowerCase();
223
+ if (!raw.host) {
224
+ throw new Error('Please implement host in husar.json');
167
225
  }
168
- return { content: [{ type: 'text', text: res.stdout || 'ok' }] };
226
+ if (!raw.adminToken) {
227
+ throw new Error('Please implement adminToken in husar.json');
228
+ }
229
+ const result = await withTimeout(
230
+ parser({
231
+ inputFile,
232
+ workspaceRoot,
233
+ opts: { type: (type ?? 'shape') as 'model' | 'shape', name },
234
+ authentication: {
235
+ HUSAR_MCP_HOST: raw.host,
236
+ HUSAR_MCP_ADMIN_TOKEN: raw.adminToken,
237
+ },
238
+ }),
239
+ timeoutMs,
240
+ );
241
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
169
242
  } catch (err) {
170
243
  const message = err instanceof Error ? err.message : String(err);
171
244
  const stack = err instanceof Error && err.stack ? `\nStack: ${err.stack}` : '';
@@ -181,14 +254,6 @@ const createCmsParseFileHandler = ({ timeoutMs = 30_000 }: { timeoutMs?: number
181
254
  };
182
255
 
183
256
  export const startMcpServer = async () => {
184
- // Tool: cms_parse_file (legacy)
185
- server.tool(
186
- 'cms_parse_file',
187
- 'Parse a CMS file into a shape or model',
188
- cmsParseFileInputFields,
189
- createCmsParseFileHandler({ timeoutMs: Number(process.env.MCP_PARSER_TIMEOUT_MS || 30000) }) as any,
190
- );
191
-
192
257
  // Tool: husar_copy (alias)
193
258
  server.tool(
194
259
  'husar_copy',
@@ -203,24 +268,34 @@ export const startMcpServer = async () => {
203
268
  .string()
204
269
  .optional()
205
270
  .describe('Target folder for cms scaffolding. Relative to workspace root; defaults to ".".'),
271
+ workspaceRoot: z
272
+ .string()
273
+ .optional()
274
+ .describe(
275
+ 'Target folder for where husar.json is located. Needed when run in monorepo where husar.json is located in child repo',
276
+ ),
206
277
  } as const;
207
278
  const husarGenerateSchema = z.object(husarGenerateInput);
279
+
208
280
  server.tool('husar_generate', 'Generate cms structure in the given path', husarGenerateInput, (async (
209
281
  args: unknown,
210
282
  ) => {
211
283
  try {
212
- const { folderPath } = husarGenerateSchema.parse(args ?? {});
213
- const root = await getWorkspaceRoot();
214
- // Ensure config exists; if missing, CLI will error
215
- await getAuth();
216
- const argsList = ['generate'];
217
- if (folderPath) argsList.push(folderPath);
218
- const res = await runCli(argsList, root);
219
- if (res.code !== 0) {
220
- throw new Error(`CLI exited with code ${res.code}.\nSTDOUT:\n${res.stdout}\nSTDERR:\n${res.stderr}`);
284
+ const { folderPath = './src', workspaceRoot } = husarGenerateSchema.parse(args ?? {});
285
+ const cfg = await getConfig(workspaceRoot);
286
+ const raw = cfg.get();
287
+ const host = raw.host;
288
+ if (!host) {
289
+ throw new Error('Missing host. Provide via husar.json (host) or HUSAR_MCP_HOST/HUSAR_HOST env.');
221
290
  }
222
- // The CLI doesn't print a JSON payload; return a simple ok message
223
- return { content: [{ type: 'text', text: res.stdout || 'ok' }] };
291
+ const base = workspaceRoot ? path.join(workspaceRoot, folderPath) : folderPath;
292
+ const cmsPath = await generateCms({
293
+ baseFolder: base,
294
+ host,
295
+ hostEnvironmentVariable: (raw as any).hostEnvironmentVariable,
296
+ authenticationEnvironmentVariable: (raw as any).authenticationEnvironmentVariable,
297
+ });
298
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'ok', folder: cmsPath }, null, 2) }] };
224
299
  } catch (err) {
225
300
  const message = err instanceof Error ? err.message : String(err);
226
301
  const stack = err instanceof Error && err.stack ? `\nStack: ${err.stack}` : '';
@@ -228,6 +303,385 @@ export const startMcpServer = async () => {
228
303
  }
229
304
  }) as any);
230
305
 
306
+ // Tool: husar_graphql_query (read-only helper)
307
+ const husarGraphQLQueryInput = {
308
+ query: z.string().min(1).describe('GraphQL query string.'),
309
+ variables: z
310
+ .union([z.string(), z.record(z.any())])
311
+ .optional()
312
+ .describe('Optional variables as JSON string or object.'),
313
+ endpointPath: z
314
+ .string()
315
+ .optional()
316
+ .default('api/graphql')
317
+ .describe('Endpoint path relative to host, defaults to api/graphql'),
318
+ workspaceRoot: z
319
+ .string()
320
+ .optional()
321
+ .describe('Location of husar.json if not in current working directory.'),
322
+ } as const;
323
+ const husarGraphQLQuerySchema = z.object(husarGraphQLQueryInput);
324
+
325
+ server.tool(
326
+ 'husar_graphql_query',
327
+ 'Run a read-only GraphQL query against the configured Husar host',
328
+ husarGraphQLQueryInput,
329
+ (async (args: unknown) => {
330
+ try {
331
+ const { query, variables, endpointPath, workspaceRoot } = husarGraphQLQuerySchema.parse(args ?? {});
332
+ const cfg = await getConfig(workspaceRoot);
333
+ const raw = cfg.get();
334
+ const host = raw.host;
335
+ const token = raw.adminToken;
336
+ if (!host) throw new Error('Missing host in husar.json');
337
+ if (!token) throw new Error('Missing adminToken in husar.json');
338
+ const url = new URL(endpointPath || 'api/graphql', host).toString();
339
+ const vars = typeof variables === 'string' && variables.trim().length
340
+ ? JSON.parse(variables)
341
+ : variables ?? {};
342
+ const res = await fetch(url, {
343
+ method: 'POST',
344
+ headers: { 'Content-Type': 'application/json', husar_token: token },
345
+ body: JSON.stringify({ query, variables: vars }),
346
+ });
347
+ const json = await res.json();
348
+ return { content: [{ type: 'text', text: JSON.stringify(json, null, 2) }] };
349
+ } catch (err) {
350
+ const message = err instanceof Error ? err.message : String(err);
351
+ const stack = err instanceof Error && err.stack ? `\nStack: ${err.stack}` : '';
352
+ return { isError: true, content: [{ type: 'text', text: `Error: ${message}${stack}` }] };
353
+ }
354
+ }) as any,
355
+ );
356
+
357
+ // Tool: husar_graphql_schema (admin)
358
+ const husarGraphQLSchemaInput = {
359
+ endpointPath: z
360
+ .string()
361
+ .optional()
362
+ .default('api/graphql')
363
+ .describe('Endpoint path relative to host, defaults to api/graphql'),
364
+ workspaceRoot: z
365
+ .string()
366
+ .optional()
367
+ .describe('Location of husar.json if not in current working directory.'),
368
+ } as const;
369
+ const husarGraphQLSchemaSchema = z.object(husarGraphQLSchemaInput);
370
+
371
+ server.tool(
372
+ 'husar_graphql_schema',
373
+ 'Fetch GraphQL schema (introspection) from Admin API',
374
+ husarGraphQLSchemaInput,
375
+ (async (args: unknown) => {
376
+ try {
377
+ const { endpointPath, workspaceRoot } = husarGraphQLSchemaSchema.parse(args ?? {});
378
+ const cfg = await getConfig(workspaceRoot);
379
+ const raw = cfg.get();
380
+ const host = raw.host;
381
+ const token = raw.adminToken;
382
+ if (!host) throw new Error('Missing host in husar.json');
383
+ if (!token) throw new Error('Missing adminToken in husar.json');
384
+ const url = new URL(endpointPath || 'api/graphql', host).toString();
385
+ const res = await fetch(url, {
386
+ method: 'POST',
387
+ headers: { 'Content-Type': 'application/json', husar_token: token },
388
+ body: JSON.stringify({ query: INTROSPECTION_QUERY }),
389
+ });
390
+ const json = await res.json();
391
+ return { content: [{ type: 'text', text: JSON.stringify(json, null, 2) }] };
392
+ } catch (err) {
393
+ const message = err instanceof Error ? err.message : String(err);
394
+ const stack = err instanceof Error && err.stack ? `\nStack: ${err.stack}` : '';
395
+ return { isError: true, content: [{ type: 'text', text: `Error: ${message}${stack}` }] };
396
+ }
397
+ }) as any,
398
+ );
399
+
400
+ // Tool: husar_graphql_schema_sdl (admin) — human-friendly SDL
401
+ server.tool(
402
+ 'husar_graphql_schema_sdl',
403
+ 'Fetch Admin GraphQL schema and return SDL',
404
+ husarGraphQLSchemaInput,
405
+ (async (args: unknown) => {
406
+ try {
407
+ const { endpointPath, workspaceRoot } = husarGraphQLSchemaSchema.parse(args ?? {});
408
+ const cfg = await getConfig(workspaceRoot);
409
+ const raw = cfg.get();
410
+ const host = raw.host;
411
+ const token = raw.adminToken;
412
+ if (!host) throw new Error('Missing host in husar.json');
413
+ if (!token) throw new Error('Missing adminToken in husar.json');
414
+ const url = new URL(endpointPath || 'api/graphql', host).toString();
415
+ const res = await fetch(url, {
416
+ method: 'POST',
417
+ headers: { 'Content-Type': 'application/json', husar_token: token },
418
+ body: JSON.stringify({ query: INTROSPECTION_QUERY }),
419
+ });
420
+ const json = await res.json();
421
+ if (!json?.data) throw new Error('No data from introspection');
422
+ const schema = buildClientSchema(json.data);
423
+ const sdl = printSchema(schema);
424
+ return { content: [{ type: 'text', text: sdl }] };
425
+ } catch (err) {
426
+ const message = err instanceof Error ? err.message : String(err);
427
+ const stack = err instanceof Error && err.stack ? `\nStack: ${err.stack}` : '';
428
+ return { isError: true, content: [{ type: 'text', text: `Error: ${message}${stack}` }] };
429
+ }
430
+ }) as any,
431
+ );
432
+
433
+ // Tool: husar_content_graphql_query (read-only helper for Content API)
434
+ const husarContentGraphQLQueryInput = {
435
+ query: z.string().min(1).describe('GraphQL query string.'),
436
+ variables: z
437
+ .union([z.string(), z.record(z.any())])
438
+ .optional()
439
+ .describe('Optional variables as JSON string or object.'),
440
+ endpointPath: z
441
+ .string()
442
+ .optional()
443
+ .default('content/graphql')
444
+ .describe('Endpoint path relative to host, defaults to content/graphql'),
445
+ apiKey: z.string().optional().describe('Content API key. If omitted, apiKeyEnv is used.'),
446
+ apiKeyEnv: z.string().optional().describe('Environment variable name containing the Content API key.'),
447
+ workspaceRoot: z
448
+ .string()
449
+ .optional()
450
+ .describe('Location of husar.json if not in current working directory.'),
451
+ } as const;
452
+ const husarContentGraphQLQuerySchema = z.object(husarContentGraphQLQueryInput);
453
+
454
+ server.tool(
455
+ 'husar_content_graphql_query',
456
+ 'Run a read-only GraphQL query against the Content API (api-key required)',
457
+ husarContentGraphQLQueryInput,
458
+ (async (args: unknown) => {
459
+ try {
460
+ const { query, variables, endpointPath, apiKey, apiKeyEnv, workspaceRoot } =
461
+ husarContentGraphQLQuerySchema.parse(args ?? {});
462
+ const cfg = await getConfig(workspaceRoot);
463
+ const raw = cfg.get();
464
+ const host = raw.host;
465
+ if (!host) throw new Error('Missing host in husar.json');
466
+ const keyFromEnv = apiKeyEnv ? process.env[apiKeyEnv] : undefined;
467
+ const keyFromConfig = (raw as any).contentApiKey as string | undefined;
468
+ const usedKey = apiKey || keyFromEnv || keyFromConfig;
469
+ if (!usedKey) throw new Error('Missing apiKey. Provide apiKey, apiKeyEnv, or set contentApiKey in husar.json');
470
+ const url = new URL(endpointPath || 'content/graphql', host).toString();
471
+ const vars = typeof variables === 'string' && variables.trim().length
472
+ ? JSON.parse(variables)
473
+ : variables ?? {};
474
+ const res = await fetch(url, {
475
+ method: 'POST',
476
+ headers: { 'Content-Type': 'application/json', 'api-key': usedKey },
477
+ body: JSON.stringify({ query, variables: vars }),
478
+ });
479
+ const json = await res.json();
480
+ return { content: [{ type: 'text', text: JSON.stringify(json, null, 2) }] };
481
+ } catch (err) {
482
+ const message = err instanceof Error ? err.message : String(err);
483
+ const stack = err instanceof Error && err.stack ? `\nStack: ${err.stack}` : '';
484
+ return { isError: true, content: [{ type: 'text', text: `Error: ${message}${stack}` }] };
485
+ }
486
+ }) as any,
487
+ );
488
+
489
+ // Tool: husar_content_graphql_schema (content)
490
+ const husarContentGraphQLSchemaInput = {
491
+ endpointPath: z
492
+ .string()
493
+ .optional()
494
+ .default('content/graphql')
495
+ .describe('Endpoint path relative to host, defaults to content/graphql'),
496
+ apiKey: z.string().optional().describe('Content API key. If omitted, apiKeyEnv is used.'),
497
+ apiKeyEnv: z.string().optional().describe('Environment variable name containing the Content API key.'),
498
+ workspaceRoot: z
499
+ .string()
500
+ .optional()
501
+ .describe('Location of husar.json if not in current working directory.'),
502
+ } as const;
503
+ const husarContentGraphQLSchemaSchema = z.object(husarContentGraphQLSchemaInput);
504
+
505
+ server.tool(
506
+ 'husar_content_graphql_schema',
507
+ 'Fetch GraphQL schema (introspection) from Content API',
508
+ husarContentGraphQLSchemaInput,
509
+ (async (args: unknown) => {
510
+ try {
511
+ const { endpointPath, apiKey, apiKeyEnv, workspaceRoot } = husarContentGraphQLSchemaSchema.parse(args ?? {});
512
+ const cfg = await getConfig(workspaceRoot);
513
+ const raw = cfg.get();
514
+ const host = raw.host;
515
+ if (!host) throw new Error('Missing host in husar.json');
516
+ const keyFromEnv = apiKeyEnv ? process.env[apiKeyEnv] : undefined;
517
+ const keyFromConfig = (raw as any).contentApiKey as string | undefined;
518
+ const usedKey = apiKey || keyFromEnv || keyFromConfig;
519
+ if (!usedKey) throw new Error('Missing apiKey. Provide apiKey, apiKeyEnv, or set contentApiKey in husar.json');
520
+ const url = new URL(endpointPath || 'content/graphql', host).toString();
521
+ const res = await fetch(url, {
522
+ method: 'POST',
523
+ headers: { 'Content-Type': 'application/json', 'api-key': usedKey },
524
+ body: JSON.stringify({ query: INTROSPECTION_QUERY }),
525
+ });
526
+ const json = await res.json();
527
+ return { content: [{ type: 'text', text: JSON.stringify(json, null, 2) }] };
528
+ } catch (err) {
529
+ const message = err instanceof Error ? err.message : String(err);
530
+ const stack = err instanceof Error && err.stack ? `\nStack: ${err.stack}` : '';
531
+ return { isError: true, content: [{ type: 'text', text: `Error: ${message}${stack}` }] };
532
+ }
533
+ }) as any,
534
+ );
535
+
536
+ // Tool: husar_content_graphql_schema_sdl (content) — human-friendly SDL
537
+ server.tool(
538
+ 'husar_content_graphql_schema_sdl',
539
+ 'Fetch Content GraphQL schema and return SDL',
540
+ husarContentGraphQLSchemaInput,
541
+ (async (args: unknown) => {
542
+ try {
543
+ const { endpointPath, apiKey, apiKeyEnv, workspaceRoot } = husarContentGraphQLSchemaSchema.parse(args ?? {});
544
+ const cfg = await getConfig(workspaceRoot);
545
+ const raw = cfg.get();
546
+ const host = raw.host;
547
+ if (!host) throw new Error('Missing host in husar.json');
548
+ const keyFromEnv = apiKeyEnv ? process.env[apiKeyEnv] : undefined;
549
+ const keyFromConfig = (raw as any).contentApiKey as string | undefined;
550
+ const usedKey = apiKey || keyFromEnv || keyFromConfig;
551
+ if (!usedKey) throw new Error('Missing apiKey. Provide apiKey, apiKeyEnv, or set contentApiKey in husar.json');
552
+ const url = new URL(endpointPath || 'content/graphql', host).toString();
553
+ const res = await fetch(url, {
554
+ method: 'POST',
555
+ headers: { 'Content-Type': 'application/json', 'api-key': usedKey },
556
+ body: JSON.stringify({ query: INTROSPECTION_QUERY }),
557
+ });
558
+ const json = await res.json();
559
+ if (!json?.data) throw new Error('No data from introspection');
560
+ const schema = buildClientSchema(json.data);
561
+ const sdl = printSchema(schema);
562
+ return { content: [{ type: 'text', text: sdl }] };
563
+ } catch (err) {
564
+ const message = err instanceof Error ? err.message : String(err);
565
+ const stack = err instanceof Error && err.stack ? `\nStack: ${err.stack}` : '';
566
+ return { isError: true, content: [{ type: 'text', text: `Error: ${message}${stack}` }] };
567
+ }
568
+ }) as any,
569
+ );
570
+
571
+ // Tool: husar_graphql_suggest_query — given SDL + task, build a query
572
+ const husarSuggestInput = {
573
+ sdl: z.string().min(1).describe('GraphQL schema SDL'),
574
+ task: z.string().min(1).describe('Task description, e.g., "list posts by tag"'),
575
+ rootType: z.string().optional().default('Query'),
576
+ } as const;
577
+ const husarSuggestSchema = z.object(husarSuggestInput);
578
+
579
+ server.tool(
580
+ 'husar_graphql_suggest_query',
581
+ 'Suggest a GraphQL query and variables template from SDL + task',
582
+ husarSuggestInput,
583
+ (async (args: unknown) => {
584
+ try {
585
+ const { sdl, task, rootType } = husarSuggestSchema.parse(args ?? {});
586
+ const suggestion = suggestQueryFromSDL({ sdl, task, rootType });
587
+ return { content: [{ type: 'text', text: JSON.stringify(suggestion, null, 2) }] };
588
+ } catch (err) {
589
+ const message = err instanceof Error ? err.message : String(err);
590
+ const stack = err instanceof Error && err.stack ? `\nStack: ${err.stack}` : '';
591
+ return { isError: true, content: [{ type: 'text', text: `Error: ${message}${stack}` }] };
592
+ }
593
+ }) as any,
594
+ );
595
+
596
+ // Tool: husar_graphql_run_suggestion — suggest and run (Admin API)
597
+ const husarRunSuggestionInput = {
598
+ sdl: z.string().min(1).describe('GraphQL schema SDL'),
599
+ task: z.string().min(1).describe('Task to execute'),
600
+ variables: z.union([z.string(), z.record(z.any())]).optional().describe('Override variables (JSON)'),
601
+ endpointPath: z.string().optional().default('api/graphql'),
602
+ workspaceRoot: z.string().optional(),
603
+ } as const;
604
+ const husarRunSuggestionSchema = z.object(husarRunSuggestionInput);
605
+
606
+ server.tool(
607
+ 'husar_graphql_run_suggestion',
608
+ 'Generate a query from SDL + task and run it on Admin API',
609
+ husarRunSuggestionInput,
610
+ (async (args: unknown) => {
611
+ try {
612
+ const { sdl, task, variables, endpointPath, workspaceRoot } = husarRunSuggestionSchema.parse(args ?? {});
613
+ const cfg = await getConfig(workspaceRoot);
614
+ const raw = cfg.get();
615
+ const host = raw.host;
616
+ const token = raw.adminToken;
617
+ if (!host) throw new Error('Missing host in husar.json');
618
+ if (!token) throw new Error('Missing adminToken in husar.json');
619
+ const { query, variables: tmpl } = suggestQueryFromSDL({ sdl, task, rootType: 'Query' });
620
+ const overrides = typeof variables === 'string' && variables.trim().length ? JSON.parse(variables) : variables ?? {};
621
+ const merged = { ...tmpl, ...overrides };
622
+ const url = new URL(endpointPath || 'api/graphql', host).toString();
623
+ const res = await fetch(url, {
624
+ method: 'POST',
625
+ headers: { 'Content-Type': 'application/json', husar_token: token },
626
+ body: JSON.stringify({ query, variables: merged }),
627
+ });
628
+ const json = await res.json();
629
+ return { content: [{ type: 'text', text: JSON.stringify({ query, variables: merged, result: json }, null, 2) }] };
630
+ } catch (err) {
631
+ const message = err instanceof Error ? err.message : String(err);
632
+ const stack = err instanceof Error && err.stack ? `\nStack: ${err.stack}` : '';
633
+ return { isError: true, content: [{ type: 'text', text: `Error: ${message}${stack}` }] };
634
+ }
635
+ }) as any,
636
+ );
637
+
638
+ // Tool: husar_content_graphql_run_suggestion — suggest and run (Content API)
639
+ const husarContentRunSuggestionInput = {
640
+ sdl: z.string().min(1).describe('GraphQL schema SDL'),
641
+ task: z.string().min(1).describe('Task to execute'),
642
+ variables: z.union([z.string(), z.record(z.any())]).optional().describe('Override variables (JSON)'),
643
+ endpointPath: z.string().optional().default('content/graphql'),
644
+ apiKey: z.string().optional().describe('Content API key'),
645
+ apiKeyEnv: z.string().optional().describe('Env var name containing Content API key'),
646
+ workspaceRoot: z.string().optional(),
647
+ } as const;
648
+ const husarContentRunSuggestionSchema = z.object(husarContentRunSuggestionInput);
649
+
650
+ server.tool(
651
+ 'husar_content_graphql_run_suggestion',
652
+ 'Generate a query from SDL + task and run it on Content API',
653
+ husarContentRunSuggestionInput,
654
+ (async (args: unknown) => {
655
+ try {
656
+ const { sdl, task, variables, endpointPath, apiKey, apiKeyEnv, workspaceRoot } =
657
+ husarContentRunSuggestionSchema.parse(args ?? {});
658
+ const cfg = await getConfig(workspaceRoot);
659
+ const raw = cfg.get();
660
+ const host = raw.host;
661
+ if (!host) throw new Error('Missing host in husar.json');
662
+ const keyFromEnv = apiKeyEnv ? process.env[apiKeyEnv] : undefined;
663
+ const keyFromConfig = (raw as any).contentApiKey as string | undefined;
664
+ const usedKey = apiKey || keyFromEnv || keyFromConfig;
665
+ if (!usedKey) throw new Error('Missing apiKey. Provide apiKey/apiKeyEnv or set contentApiKey in husar.json');
666
+ const { query, variables: tmpl } = suggestQueryFromSDL({ sdl, task, rootType: 'Query' });
667
+ const overrides = typeof variables === 'string' && variables.trim().length ? JSON.parse(variables) : variables ?? {};
668
+ const merged = { ...tmpl, ...overrides };
669
+ const url = new URL(endpointPath || 'content/graphql', host).toString();
670
+ const res = await fetch(url, {
671
+ method: 'POST',
672
+ headers: { 'Content-Type': 'application/json', 'api-key': usedKey },
673
+ body: JSON.stringify({ query, variables: merged }),
674
+ });
675
+ const json = await res.json();
676
+ return { content: [{ type: 'text', text: JSON.stringify({ query, variables: merged, result: json }, null, 2) }] };
677
+ } catch (err) {
678
+ const message = err instanceof Error ? err.message : String(err);
679
+ const stack = err instanceof Error && err.stack ? `\nStack: ${err.stack}` : '';
680
+ return { isError: true, content: [{ type: 'text', text: `Error: ${message}${stack}` }] };
681
+ }
682
+ }) as any,
683
+ );
684
+
231
685
  const transport = new StdioServerTransport();
232
686
  server.connect(transport);
233
687
  try {
@@ -261,3 +715,29 @@ if (import.meta.url === `file://${process.argv[1]}`) {
261
715
  // eslint-disable-next-line no-void
262
716
  void startMcpServer();
263
717
  }
718
+
719
+ /**
720
+ * Removes the workspaceRoot prefix from filePath if filePath is within workspaceRoot.
721
+ * - Returns the path relative to workspaceRoot when included.
722
+ * - Returns filePath unchanged when it's outside workspaceRoot.
723
+ * - If filePath equals workspaceRoot, returns an empty string.
724
+ */
725
+ export function stripWorkspaceRoot(filePath: string, workspaceRoot?: string): string {
726
+ if (!workspaceRoot) return filePath;
727
+
728
+ const rootAbs = path.resolve(workspaceRoot);
729
+ const fileAbs = path.resolve(filePath);
730
+
731
+ const isWin = path.sep === '\\';
732
+ const rootCmp = isWin ? rootAbs.toLowerCase() : rootAbs;
733
+ const fileCmp = isWin ? fileAbs.toLowerCase() : fileAbs;
734
+
735
+ if (fileCmp === rootCmp) return '';
736
+
737
+ // Ensure fileAbs is inside rootAbs before using relative
738
+ if (fileCmp.startsWith(rootCmp + path.sep)) {
739
+ return path.relative(rootAbs, fileAbs);
740
+ }
741
+
742
+ return filePath;
743
+ }