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