@bridgeline-digital/hawk-app-cli 0.1.0-dev.20261020202308 → 0.1.0

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.
Files changed (80) hide show
  1. package/LICENSE +7 -7
  2. package/README.md +770 -770
  3. package/agents/AGENTS.md +192 -192
  4. package/agents/project/custom.md +21 -21
  5. package/agents/project/next-mui.md +21 -21
  6. package/agents/project/next.md +24 -24
  7. package/agents/project/vanilla.md +22 -22
  8. package/agents/project/vite.md +19 -19
  9. package/bin/hawk-app.mjs +0 -0
  10. package/docs/bridge/README.md +1315 -1315
  11. package/docs/showcase/README.md +47 -47
  12. package/docs/showcase/functions/echo.ts +12 -12
  13. package/docs/showcase/functions/long-task.ts +31 -31
  14. package/docs/showcase/functions/showcase.ts +186 -186
  15. package/docs/showcase/hawk-app.json +73 -73
  16. package/docs/showcase/queries/assistant-prompts-by-day.json +6 -6
  17. package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -8
  18. package/docs/showcase/queries/searches-by-day.json +6 -6
  19. package/docs/showcase/queries/searches-by-day.sql +7 -7
  20. package/docs/showcase/src/App.tsx +1436 -1436
  21. package/docs/showcase/src/main.tsx +10 -10
  22. package/docs/showcase/src/styles.css +71 -71
  23. package/docs/showcase/src/useHawkApp.ts +44 -44
  24. package/docs/showcase/src/vite-env.d.ts +1 -1
  25. package/docs/ui/README.md +166 -166
  26. package/package.json +1 -1
  27. package/schema/hawk-app.schema.json +611 -611
  28. package/src/accessibility.mjs +105 -105
  29. package/src/agents.mjs +145 -145
  30. package/src/aliases.mjs +30 -30
  31. package/src/app-icon.mjs +82 -82
  32. package/src/channel.mjs +116 -116
  33. package/src/cli.mjs +825 -825
  34. package/src/clients.mjs +221 -221
  35. package/src/config.mjs +148 -148
  36. package/src/create.mjs +186 -186
  37. package/src/credentials.mjs +56 -56
  38. package/src/data.mjs +169 -169
  39. package/src/deploy.mjs +349 -349
  40. package/src/dev.mjs +459 -459
  41. package/src/doctor.mjs +176 -176
  42. package/src/env.mjs +140 -140
  43. package/src/functions.mjs +313 -313
  44. package/src/login.mjs +166 -166
  45. package/src/logs.mjs +64 -64
  46. package/src/manifest.mjs +425 -425
  47. package/src/mcp-docs.mjs +168 -168
  48. package/src/mcp-hawksearch.mjs +342 -342
  49. package/src/mcp-reports.mjs +130 -130
  50. package/src/mcp-rules.mjs +109 -109
  51. package/src/mcp.mjs +539 -539
  52. package/src/platform.mjs +116 -116
  53. package/src/project-checks.mjs +153 -153
  54. package/src/queries.mjs +289 -289
  55. package/src/scope-check.mjs +91 -91
  56. package/src/secrets.mjs +86 -86
  57. package/src/slug-guard.mjs +80 -80
  58. package/src/sso.mjs +182 -182
  59. package/src/telemetry.mjs +50 -50
  60. package/src/upgrade.mjs +406 -406
  61. package/src/versions.mjs +102 -102
  62. package/templates/next/next.config.ts +13 -13
  63. package/templates/next/package.json +26 -26
  64. package/templates/next/src/lib/useHawkApp.ts +46 -46
  65. package/templates/next-mui/next.config.ts +13 -13
  66. package/templates/next-mui/package.json +30 -30
  67. package/templates/next-mui/src/app/layout.tsx +19 -19
  68. package/templates/next-mui/src/app/page.tsx +78 -78
  69. package/templates/next-mui/src/app/providers.tsx +10 -10
  70. package/templates/preact/package.json +26 -26
  71. package/templates/preact/src/useHawkApp.ts +44 -44
  72. package/templates/preact/src/vite-env.d.ts +1 -1
  73. package/templates/preact/tsconfig.json +17 -17
  74. package/templates/preact/vite.config.ts +11 -11
  75. package/templates/react/package.json +29 -29
  76. package/templates/react/src/useHawkApp.ts +44 -44
  77. package/templates/react/src/vite-env.d.ts +1 -1
  78. package/templates/react/tsconfig.json +16 -16
  79. package/templates/react/vite.config.ts +11 -11
  80. package/templates/vanilla/package.json +15 -15
package/src/mcp.mjs CHANGED
@@ -1,539 +1,539 @@
1
- // `hawk-app mcp`: a local MCP server (stdio) for AI coding assistants. Its tools run the CLI's own commands, so they
2
- // can't drift from it, with these limits:
3
- // - Always the dev platform (--env dev): prod, approvals, secrets and logins stay with people. Clients can be added
4
- // by name ("deploy to OTC"), never removed.
5
- // - No client names: outputs show client ids only. clients_add asks the person to pick the client in a form their
6
- // assistant shows (MCP elicitation); the answer comes back to this server, and the assistant hears only that a
7
- // client was added. Without forms, it sends the person to `hawk-app clients add --pick` in a terminal.
8
- // - No query results: `data run` would put client rows in the assistant's context. Table and column names only.
9
- // - The publisher key never passes through a tool: the commands use the saved login, as the CLI does.
10
- // - deploy asks nothing: the assistant's client asks the user before calling it (destructive hint),
11
- // and a question the CLI would ask (a teammate's app, serious vulnerabilities) cancels unless `force` is set.
12
- // stdout is the protocol: everything the commands print is captured and returned, and builds run with piped output.
13
- import { spawn } from 'node:child_process';
14
- import { existsSync, readFileSync } from 'node:fs';
15
- import { isAbsolute, join, relative, resolve } from 'node:path';
16
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
17
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
18
- import { z } from 'zod';
19
- import { main } from './cli.mjs';
20
- import { resolveSettings } from './env.mjs';
21
- import { searchDocs } from './mcp-docs.mjs';
22
- import { describeUi } from './mcp-ui.mjs';
23
- import { APIS, createHawkSearchReference } from './mcp-hawksearch.mjs';
24
- import { createReportCatalog } from './mcp-reports.mjs';
25
- import { createPlatformRules } from './mcp-rules.mjs';
26
- import { platformClient } from './platform.mjs';
27
-
28
- const MAX_OUTPUT = 20_000;
29
- const TAIL_LINES = 40;
30
-
31
- const INSTRUCTIONS = `Tools for building Hawk Apps: static web apps that run inside the HawkSearch dashboard and get their context and data through the bridge (@bridgeline-digital/hawk-app-bridge).
32
- - Read the app's AGENTS.md first. Before using a bridge call, look it up with \`docs\` (options, limits, error codes); the showcase sections are working examples of every feature.
33
- - Before building a screen, \`ui_components\` lists the UI kit's components for this app's template (Spark's look): use them instead of writing your own.
34
- - For analytics data, \`analytics_reports\` lists the reports app.analytics.report() can call, and gives one report's columns and filters: check it before writing a query.
35
- - For HawkSearch's own APIs (dashboard, search, recommendations, indexing), \`hawksearch_api\` gives an operation's fields, the bridge call and its scope, and says what apps can't call: check it before writing one.
36
- - \`validate\` after changing hawk-app.json, queries/ or functions/, and \`validate\` with build=true after changing the build setup.
37
- - \`deploy\` changes the app on the dev platform: only when the user asks (dryRun: true only shows what hawk-app.json would change). It never touches prod.
38
- - \`doctor\` says what's wrong with the setup; \`versions\` and \`why\` explain which version a client gets.
39
- - \`status\` shows the review checks approvers will see; fix failed ones before the user submits the app for approval.
40
- - To give a client the app ("deploy to OTC", "give it to Outdoor Demo"), call \`clients_add\` with the user's words for the client: it asks the user to pick the exact client. \`deploy\` first if they asked for a new version. Client names aren't shared with you; don't ask the user to type them into the chat.`;
41
-
42
- /** `npm run <script>` without inheriting stdout (the protocol): resolves with the output's last lines, rejects on failure. */
43
- function runQuiet(args, { cwd, env = process.env }) {
44
- return new Promise((resolvePromise, reject) => {
45
- const child = spawn('npm', args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' });
46
- const lines = [];
47
- const collect = (chunk) => {
48
- lines.push(...chunk.toString('utf8').split(/\r?\n/).filter(Boolean));
49
- if (lines.length > 500) lines.splice(0, lines.length - 500);
50
- };
51
- child.stdout.on('data', collect);
52
- child.stderr.on('data', collect);
53
- child.on('error', reject);
54
- child.on('close', (code) => {
55
- const tail = lines.slice(-TAIL_LINES).join('\n');
56
- if (code === 0) resolvePromise(tail);
57
- else reject(new Error(`\`npm ${args.join(' ')}\` failed (exit code ${code}):\n${tail}`));
58
- });
59
- });
60
- }
61
-
62
- /**
63
- * Runs one CLI command in `dir` and returns what it printed. Commands run one at a time: they use the process's
64
- * working directory.
65
- */
66
- export function createRunner({ root = process.cwd(), cli = main } = {}) {
67
- let queue = Promise.resolve();
68
- return function run(argv, { dir, force = false } = {}) {
69
- const job = queue.then(async () => {
70
- const cwd = resolve(root, dir ?? '.');
71
- const inside = relative(root, cwd);
72
- if (inside.startsWith('..') || isAbsolute(inside)) return { ok: false, text: `"${dir}" is outside the workspace (${root}).` };
73
- if (!existsSync(cwd)) return { ok: false, text: `"${dir}" doesn't exist.` };
74
-
75
- const out = [];
76
- const log = (line = '') => out.push(String(line));
77
- const previous = process.cwd();
78
- process.chdir(cwd);
79
- try {
80
- const code = await cli([...argv, '--env', 'dev'], {
81
- log,
82
- clientNames: false,
83
- // No terminal: a question cancels, unless the user said to go on (force). The assistant sees it.
84
- confirm: async (question) => {
85
- log(`? ${question}`);
86
- return force;
87
- },
88
- runBuild: async ({ basePath, cwd: buildDir = cwd }) => {
89
- const tail = await runQuiet(['run', 'build'], { cwd: buildDir, env: { ...process.env, HAWK_APP_BASE_PATH: basePath } });
90
- log(tail);
91
- },
92
- runInstall: async ({ cwd: installDir }) => log(await runQuiet(['install', '--no-audit', '--no-fund'], { cwd: installDir })),
93
- });
94
- return { ok: code === 0, text: out.join('\n') };
95
- } catch (error) {
96
- out.push(`✖ ${error.message ?? error}`);
97
- if (/Cancelled/.test(error.message ?? '') && !force) {
98
- out.push('(The CLI would have asked the user. Ask them, and call again with force: true only if they say to go on.)');
99
- }
100
- return { ok: false, text: out.join('\n') };
101
- } finally {
102
- process.chdir(previous);
103
- }
104
- });
105
- queue = job.catch(() => {});
106
- return job;
107
- };
108
- }
109
-
110
- const result = ({ ok, text }) => ({
111
- content: [{ type: 'text', text: text.length > MAX_OUTPUT ? `${text.slice(0, MAX_OUTPUT)}\n… (output cut)` : text || '(no output)' }],
112
- ...(ok ? {} : { isError: true }),
113
- });
114
-
115
- const dirParam = z.string().optional().describe("The app's folder (the one with hawk-app.json), relative to the workspace. Default: the workspace itself.");
116
-
117
- /** The app's slug from hawk-app.json in `dir`, if there is one. */
118
- function slugIn(root, dir) {
119
- try {
120
- return JSON.parse(readFileSync(join(resolve(root, dir ?? '.'), 'hawk-app.json'), 'utf8')).slug;
121
- } catch {
122
- return undefined;
123
- }
124
- }
125
-
126
- /** The dev platform with the saved login, as the CLI resolves it in `dir` (for clients_add, which talks to it directly). */
127
- function devPlatform(dir) {
128
- const { url, key } = resolveSettings({ flagEnv: 'dev', dir });
129
- return platformClient({ url, key });
130
- }
131
-
132
- /** How long the person has to pick a client in the form (the SDK's default is a minute). */
133
- const PICK_TIMEOUT_MS = 5 * 60_000;
134
-
135
- /**
136
- * A platform error without its detail, which can name a client (e.g. the "did you mean" of an unknown id): the status,
137
- * title and code. Errors before any answer (not logged in, unreachable) name no client and are kept whole.
138
- */
139
- const withoutNames = (error) => (error.status ? `${error.status} ${error.data?.title ?? 'error'}${error.data?.code ? ` (${error.data.code})` : ''}` : error.message ?? String(error));
140
-
141
- /** What apps may call, from the dev platform's /meta/rules: shared by analytics_reports and hawksearch_api. */
142
- const platformRules = createPlatformRules();
143
-
144
- /** The MCP server with every tool registered (not connected). */
145
- export function createMcpServer({
146
- root = process.cwd(),
147
- run = createRunner({ root }),
148
- platform = devPlatform,
149
- reports = createReportCatalog({ rules: platformRules }),
150
- hawksearch = createHawkSearchReference({ rules: platformRules }),
151
- version = '0.0.0',
152
- } = {}) {
153
- const server = new McpServer({ name: 'hawk-apps', version }, { instructions: INSTRUCTIONS });
154
- const readOnly = { readOnlyHint: true, openWorldHint: false };
155
-
156
- server.registerTool(
157
- 'docs',
158
- {
159
- title: 'Hawk Apps documentation',
160
- description:
161
- 'The bridge (every app.* call, its scope, options, limits and error codes), the CLI, the hawk-app.json schema and the showcase app (working code for every feature). Without arguments: the list of sections. With query: the best matching sections. With section: one section in full.',
162
- inputSchema: {
163
- query: z.string().optional().describe('Words to look for, e.g. "data.query partitions" or "functions secrets".'),
164
- section: z.string().optional().describe('A section title from the list or an earlier result, e.g. "bridge: Functions: `functions:invoke`".'),
165
- offset: z.number().int().min(0).optional().describe('For a long section: where to go on reading (the earlier result says).'),
166
- dir: dirParam,
167
- },
168
- annotations: readOnly,
169
- },
170
- async ({ query, section, offset, dir }) => result({ ok: true, text: searchDocs({ appDir: resolve(root, dir ?? '.'), query, section, offset }) }),
171
- );
172
-
173
- server.registerTool(
174
- 'ui_components',
175
- {
176
- title: 'UI kit components',
177
- description:
178
- "The Hawk Apps UI kit's parts for this app's template, to build screens from instead of writing your own: React + MUI apps (next-mui) the provider, components and theme; every other app the stylesheet classes and custom elements (<hawk-kpi>, <hawk-data-table>, <hawk-bar-list>, <hawk-column-chart>). With component: only what's said about it. Links the live gallery.",
179
- inputSchema: {
180
- component: z.string().optional().describe('A component, element or class, e.g. "DataTable", "hawk-kpi" or "hawk-notice".'),
181
- flavour: z.enum(['mui', 'elements']).optional().describe("Default: the app's (mui for next-mui, else elements)."),
182
- dir: dirParam,
183
- },
184
- annotations: readOnly,
185
- },
186
- async ({ component, flavour, dir }) => result(describeUi({ appDir: resolve(root, dir ?? '.'), component, flavour })),
187
- );
188
-
189
- server.registerTool(
190
- 'analytics_reports',
191
- {
192
- title: 'Analytics reports',
193
- description:
194
- "The HawkSearch Analytics API's reports, which app.analytics.report(name, request) calls (scope reports:read), read from its developer portal. Without arguments: every report with a one-line summary. With report: that report's description, columns, filters and having fields, and an example call. No client data.",
195
- inputSchema: { report: z.string().optional().describe('A report name from the list, e.g. "assistant-prompts"; "" for the ad-hoc / natural-language root.') },
196
- annotations: { ...readOnly, openWorldHint: true },
197
- },
198
- async ({ report }) => result(await reports({ report })),
199
- );
200
-
201
- server.registerTool(
202
- 'hawksearch_api',
203
- {
204
- title: 'HawkSearch API reference',
205
- description:
206
- "HawkSearch's own API reference (developerdocs.hawksearch.com), with what apps can call of it: app.dashboard.*, app.hawksearch.search/autocomplete, recommendations and index. Without arguments: every API's operation groups and what apps can do with them. With api: that API's operations. With operation (an id from the list, or words): its method and path, parameters, request and response fields, the bridge call and its scope, or why apps can't call it. No client data.",
207
- inputSchema: {
208
- api: z.enum(Object.keys(APIS)).optional().describe('Only this API.'),
209
- operation: z.string().optional().describe('An operation id from the list (e.g. "synonym_post_synonym"), or words to find one ("update redirect").'),
210
- offset: z.number().int().min(0).optional().describe('For a long operation: where to read on from (the previous answer says).'),
211
- },
212
- annotations: { ...readOnly, openWorldHint: true },
213
- },
214
- async ({ api, operation, offset }) => result(await hawksearch({ api, operation, offset })),
215
- );
216
-
217
- server.registerTool(
218
- 'validate',
219
- {
220
- title: 'Validate the app',
221
- description:
222
- 'Checks hawk-app.json, queries/ and functions/ like deploy does, and the project (the bridge and its version, a build script). build: true also builds the app as deploy does (base path /<slug>/v0) and fails on HTML URLs outside the base path.',
223
- inputSchema: { build: z.boolean().optional(), folder: z.string().optional().describe("The build's output folder; default: the template's (dist, out, public)."), dir: dirParam },
224
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
225
- },
226
- async ({ build, folder, dir }) => result(await run(['validate', ...(build ? ['--build', ...(folder ? [folder] : [])] : [])], { dir })),
227
- );
228
-
229
- server.registerTool(
230
- 'status',
231
- {
232
- title: 'App status on dev',
233
- description: 'Where the app stands on the dev platform: the released version (default), the newest version, settings waiting for review, the approval request, the aliases, and the automated review checks approvers will see.',
234
- inputSchema: { dir: dirParam },
235
- annotations: { ...readOnly, openWorldHint: true },
236
- },
237
- async ({ dir }) => result(await run(['status'], { dir })),
238
- );
239
-
240
- server.registerTool(
241
- 'doctor',
242
- {
243
- title: 'Check the setup',
244
- description: "Checks everything a developer needs, with what to fix: Node, the login and the dev platform, hawk-app.json, the project (bridge, build script), scopes the code uses that hawk-app.json lacks, the template, the app on the platform and the MCP config.",
245
- inputSchema: { dir: dirParam },
246
- annotations: { ...readOnly, openWorldHint: true },
247
- },
248
- async ({ dir }) => result(await run(['doctor'], { dir })),
249
- );
250
-
251
- server.registerTool(
252
- 'versions',
253
- {
254
- title: 'App versions on dev',
255
- description: "Every version on the dev platform with its status (draft, waiting, approved, rejected) and the aliases on it. Clients get their alias's version (default without one); users see it only when it's approved.",
256
- inputSchema: { dir: dirParam },
257
- annotations: { ...readOnly, openWorldHint: true },
258
- },
259
- async ({ dir }) => result(await run(['versions'], { dir })),
260
- );
261
-
262
- server.registerTool(
263
- 'why',
264
- {
265
- title: 'Why a client sees (or not) the app on dev',
266
- description: "What a client's users see of the app on the dev platform, and why: the client list, the client's alias and that version's status. Takes the client id (e.g. e3141).",
267
- inputSchema: { client: z.string().describe('The client id, e.g. e3141.'), dir: dirParam },
268
- annotations: { ...readOnly, openWorldHint: true },
269
- },
270
- async ({ client, dir }) => result(await run(['why', client], { dir })),
271
- );
272
-
273
- server.registerTool(
274
- 'data_tables',
275
- {
276
- title: 'Analytics tables',
277
- description: 'The analytics tables app queries (queries/*.sql, scope data:read) can use, including whether this app may use the sensitive ones. Names only, no data.',
278
- inputSchema: { dir: dirParam },
279
- annotations: { ...readOnly, openWorldHint: true },
280
- },
281
- async ({ dir }) => {
282
- const slug = slugIn(root, dir);
283
- return result(await run(['data', 'tables', ...(slug ? ['--app', slug] : [])], { dir }));
284
- },
285
- );
286
-
287
- server.registerTool(
288
- 'data_describe',
289
- {
290
- title: 'Table columns',
291
- description: "An analytics table's columns and types, for writing queries/*.sql. No data.",
292
- inputSchema: { table: z.string().describe('A table name from data_tables.'), dir: dirParam },
293
- annotations: { ...readOnly, openWorldHint: true },
294
- },
295
- async ({ table, dir }) => result(await run(['data', 'describe', table], { dir })),
296
- );
297
-
298
- server.registerTool(
299
- 'logs',
300
- {
301
- title: 'Function logs on dev',
302
- description: "A function's logs on the dev platform (functions/<name>.ts).",
303
- inputSchema: {
304
- function: z.string().describe('The function name.'),
305
- since: z.string().optional().describe('15m, 1h, 1d or an ISO time; default 1h.'),
306
- dir: dirParam,
307
- },
308
- annotations: { ...readOnly, openWorldHint: true },
309
- },
310
- async ({ function: name, since, dir }) => result(await run(['logs', name, ...(since ? ['--since', since] : [])], { dir })),
311
- );
312
-
313
- server.registerTool(
314
- 'errors',
315
- {
316
- title: 'App errors on dev',
317
- description: "Errors in the app's pages on the dev platform (uncaught errors, unhandled rejections, app.reportError), grouped by kind, the most frequent first, with versions, clients and the top stack frames.",
318
- inputSchema: { days: z.number().int().min(1).max(30).optional().describe('Days back; default 7.'), dir: dirParam },
319
- annotations: { ...readOnly, openWorldHint: true },
320
- },
321
- async ({ days, dir }) => result(await run(['errors', ...(days ? ['--days', String(days)] : [])], { dir })),
322
- );
323
-
324
- server.registerTool(
325
- 'usage',
326
- {
327
- title: 'App usage on dev',
328
- description: 'Opens of the app and of its views, distinct users and clients per day on the dev platform (Bridgeline staff counted apart).',
329
- inputSchema: { days: z.number().int().min(1).max(400).optional().describe('Days back; default 30.'), dir: dirParam },
330
- annotations: { ...readOnly, openWorldHint: true },
331
- },
332
- async ({ days, dir }) => result(await run(['usage', ...(days ? ['--days', String(days)] : [])], { dir })),
333
- );
334
-
335
- server.registerTool(
336
- 'init',
337
- {
338
- title: 'Make a project a Hawk App',
339
- description: 'For a project without hawk-app.json: writes it (refusing a slug that is already an app), plus AGENTS.md and the MCP settings. Then add the bridge and the base path as AGENTS.md says.',
340
- inputSchema: { slug: z.string().optional(), name: z.string().optional(), dir: dirParam },
341
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
342
- },
343
- async ({ slug, name, dir }) => result(await run(['init', ...(slug ? ['--slug', slug] : []), ...(name ? ['--name', name] : [])], { dir })),
344
- );
345
-
346
- server.registerTool(
347
- 'upgrade',
348
- {
349
- title: 'Upgrade to the template',
350
- description:
351
- "Shows how the app differs from the CLI's template (package versions, unedited config files, AGENTS.md). apply: true changes those files and runs npm install. channel: 'latest' moves the bridge and CLI from dev builds to the released versions, 'dev' onto the dev builds (only when the user asks).",
352
- inputSchema: { apply: z.boolean().optional(), channel: z.enum(['latest', 'dev']).optional(), dir: dirParam },
353
- annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
354
- },
355
- async ({ apply, channel, dir }) => result(await run(['upgrade', ...(channel ? ['--channel', channel] : []), ...(apply ? ['--yes'] : [])], { dir })),
356
- );
357
-
358
- server.registerTool(
359
- 'deploy',
360
- {
361
- title: 'Deploy a version to dev',
362
- description:
363
- "Builds the app (npm run build with HAWK_APP_BASE_PATH) and uploads it as a new version on the dev platform, with hawk-app.json, queries/ and functions/. Runs npm audit first. alias: deploys off the main line and points that alias at the new version (only Bridgeline staff in its clients see it). dryRun: true only shows what hawk-app.json would change, and deploys nothing. Deploy only when the user asks (a dry run is fine any time).",
364
- inputSchema: {
365
- folder: z.string().optional().describe("The build output folder; default: the template's (dist, out, public)."),
366
- alias: z.string().optional().describe('An alias, e.g. staging or preview-my-branch: lowercase letters, digits and dashes.'),
367
- dryRun: z.boolean().optional().describe('Only show what hawk-app.json would change.'),
368
- noBuild: z.boolean().optional().describe('Upload the folder as it is (plain HTML/JS).'),
369
- force: z.boolean().optional().describe("Go on despite high or critical vulnerabilities, or a teammate's last change: only after the user says so."),
370
- dir: dirParam,
371
- },
372
- annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
373
- },
374
- async ({ folder, alias, dryRun, noBuild, force, dir }) =>
375
- result(await run(['deploy', ...(folder ? [folder] : []), ...(alias ? ['--alias', alias] : []), ...(dryRun ? ['--dry-run'] : []), ...(noBuild ? ['--no-build'] : [])], { dir, force })),
376
- );
377
-
378
- server.registerTool(
379
- 'clients',
380
- {
381
- title: "The app's clients on dev",
382
- description: "Which clients have the app on the dev platform (+ has it, - opted out), by id, and its visibility. Client names aren't shown.",
383
- inputSchema: { dir: dirParam },
384
- annotations: { ...readOnly, openWorldHint: true },
385
- },
386
- async ({ dir }) => result(await run(['clients'], { dir })),
387
- );
388
-
389
- server.registerTool(
390
- 'clients_add',
391
- {
392
- title: 'Give a client the app on dev',
393
- description:
394
- 'Gives a client the app on the dev platform, at once (with visibility allowList, only listed clients have the app). Pass the user\'s words for the client ("OTC", "Outdoor Demo" or an id): the tool finds the matches and asks the user to pick one in a form. You hear only whether a client was added, never the names.',
395
- inputSchema: {
396
- client: z.string().describe('The client as the user named it, e.g. "OTC".'),
397
- alias: z.string().optional().describe("The alias the client follows (the version it gets), e.g. \"review\"; \"default\" for the default. On an alias whose version isn't approved only Bridgeline staff in the client see the app."),
398
- dir: dirParam,
399
- },
400
- annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
401
- },
402
- async ({ client: words, alias, dir }) => {
403
- const wanted = words.trim();
404
- const slug = slugIn(root, dir);
405
- if (!slug) return result({ ok: false, text: `No hawk-app.json in ${dir ?? 'the workspace'}: run this in the app's folder (dir).` });
406
- if (wanted.length < 2) return result({ ok: false, text: "Ask the user for at least 2 characters of the client's name or id." });
407
- const terminal = `In a terminal, in the app's folder: npx hawk-app clients add --pick "${wanted.replace(/"/g, '')}"${alias ? ` --alias ${alias}` : ''} --env dev`;
408
- if (!server.server.getClientCapabilities()?.elicitation) {
409
- return result({ ok: false, text: `This assistant can't show the user a form to pick the client, and client names aren't shared with you. Ask the user to run this themselves (it lists the matches for them to pick from):\n${terminal}` });
410
- }
411
-
412
- let devApi;
413
- let found;
414
- try {
415
- devApi = platform(resolve(root, dir ?? '.'));
416
- found = await devApi.searchClients(wanted);
417
- } catch (error) {
418
- return result({ ok: false, text: `✖ ${withoutNames(error)}` });
419
- }
420
- if (!found.clients?.length) {
421
- return result({ ok: false, text: 'No client matches what the user typed. Ask them for another part of the name, or the id the dashboard shows (e.g. e3141).' });
422
- }
423
-
424
- let answer;
425
- try {
426
- answer = await server.server.elicitInput(
427
- {
428
- message: `Which client should get ${slug} on dev?${found.more ? " These are the best matches for what you typed: cancel and use more of the name if yours isn't here." : ''}`,
429
- requestedSchema: {
430
- type: 'object',
431
- properties: {
432
- client: { type: 'string', title: 'Client', oneOf: found.clients.map((c) => ({ const: c.id, title: c.name ? `${c.name} (${c.id})` : c.id })) },
433
- },
434
- required: ['client'],
435
- },
436
- },
437
- { timeout: PICK_TIMEOUT_MS },
438
- );
439
- } catch {
440
- // Not shown, not answered in time, or answered with something other than the choices (the SDK checks).
441
- return result({ ok: false, text: `The form to pick the client didn't get an answer. Nothing was changed. Ask the user to try again, or to run:\n${terminal}` });
442
- }
443
- const id = answer.action === 'accept' ? answer.content?.client : undefined;
444
- if (!found.clients.some((c) => c.id === id)) {
445
- return result({ ok: false, text: 'The user cancelled: no client was added. Ask them what they want instead.' });
446
- }
447
-
448
- try {
449
- await devApi.setClient(slug, id, { enabled: true, ...(alias ? { alias } : {}) });
450
- } catch (error) {
451
- if (error.data?.code === 'approver_required') return result({ ok: false, text: "The client's users see the app now: only an approver can change what it gets (the app's Rollout tab in the Hawk Analytics portal, or HawkSearch). Nothing was changed." });
452
- return result({ ok: false, text: `✖ The client wasn't added: ${withoutNames(error)}` });
453
- }
454
- return result({ ok: true, text: `✔ Added the client the user picked to ${slug} on dev${alias ? ` on ${alias}` : ''}. It applies now (recorded in the app's history). The user saw its name in the form; it isn't shared here.` });
455
- },
456
- );
457
-
458
- registerPrompts(server);
459
- return server;
460
- }
461
-
462
- /**
463
- * Prompts: ready-made tasks that clients list where developers look (Claude Code shows them as slash commands), so
464
- * the tools are found without reading about them. Each one tells the assistant which tools to use, in what order.
465
- */
466
- const PROMPTS = {
467
- 'check-before-submit': {
468
- title: 'Check before submitting for approval',
469
- description: 'Validates the app (with a build check), reads its status, review checks and errors on dev, and explains how to fix anything an approver would have to force approve.',
470
- text: (dir) => `Check this Hawk App${where(dir)} before it's submitted for approval in HawkSearch.
471
- 1. Run the hawk-apps \`validate\` tool with build: true${dirArg(dir)}. Fix what it reports in the code (ask me before changing hawk-app.json scopes or externalOrigins).
472
- 2. Run \`status\`${dirArg(dir)}, and \`deploy\` with dryRun: true to see what hawk-app.json would change. If the app isn't on the platform or its newest version is older than the code, say so: I decide when to deploy.
473
- 3. For each review check that fails or needs attention (bundle, dependencies, accessibility, external origins, queries, functions, external Lambdas), explain what an approver will see and how to fix it, using \`docs\` where it helps.
474
- 4. Run \`errors\`${dirArg(dir)} and fix the errors the deployed versions threw, the most frequent first.
475
- End with a short list: ready to submit, or what's left to do.`,
476
- },
477
- 'add-feature': {
478
- title: 'Add a feature',
479
- description: 'Builds a feature the Hawk Apps way: finds the right bridge calls, scopes and examples in the docs, makes the change, and validates it.',
480
- args: { feature: z.string().describe('What to build, e.g. "a chart of searches per day for the selected date range".') },
481
- text: (dir, { feature }) => `Add this feature to the Hawk App${where(dir)}: ${feature}
482
- 1. Read AGENTS.md. Use the hawk-apps \`docs\` tool to find the bridge calls it needs (their options, limits and error codes) and a working example in the showcase sections. Prefer what the bridge offers over anything custom.
483
- 2. Tell me which scopes, analytics reports (check them with \`analytics_reports\`), HawkSearch API calls (check them with \`hawksearch_api\`), queries (queries/*.sql, check tables and columns with \`data_tables\` and \`data_describe\`) or functions it needs, and anything that makes the review harder (elevated scopes, new outside origins), before changing hawk-app.json.
484
- 3. Make the change in the app's style. Handle a client without analytics and the errors the docs list.
485
- 4. Run \`validate\`${dirArg(dir)} and fix what it reports. Don't deploy unless I ask.`,
486
- },
487
- 'deploy-to-client': {
488
- title: 'Deploy to a client',
489
- description: 'Deploys a new version to dev and gives a client the app: you pick the client from the matches for its name.',
490
- args: { client: z.string().describe('The client\'s name or id, e.g. "OTC" or "Outdoor Demo".') },
491
- text: (dir, { client }) => `Deploy this Hawk App${where(dir)} to the client "${client}" on dev.
492
- 1. Run the hawk-apps \`deploy\` tool${dirArg(dir)}. If it asks a question, ask me.
493
- 2. Run \`clients_add\` with client "${client}"${dirArg(dir)}. It asks me to pick the exact client; if it says it can't, tell me the command it gives.
494
- 3. Run \`status\`${dirArg(dir)}.
495
- End with where I'll find it: the HawkSearch dev dashboard, with the client I picked selected, on the Apps page.`,
496
- },
497
- 'explain-app': {
498
- title: 'Explain this app',
499
- description: 'Summarizes what the app does, the data and scopes it uses, and where it stands in review on dev.',
500
- text: (dir) => `Explain this Hawk App${where(dir)} to me.
501
- 1. Read hawk-app.json, AGENTS.md and the main source files: what does the app do, for whom, and which bridge calls, queries and functions does it use?
502
- 2. Which scopes does it ask for, and are any unused or missing for what the code calls?
503
- 3. Run the hawk-apps \`status\` tool${dirArg(dir)}: what's released, what's waiting for approval, and what do the review checks say?
504
- Keep it short: a paragraph, then bullets.`,
505
- },
506
- };
507
-
508
- const where = (dir) => (dir ? ` in ${dir}` : '');
509
- const dirArg = (dir) => (dir ? ` (dir: "${dir}")` : '');
510
-
511
- function registerPrompts(server) {
512
- for (const [name, prompt] of Object.entries(PROMPTS)) {
513
- server.registerPrompt(
514
- name,
515
- {
516
- title: prompt.title,
517
- description: prompt.description,
518
- argsSchema: { ...prompt.args, dir: z.string().optional().describe("The app's folder, when the workspace has several apps.") },
519
- },
520
- ({ dir, ...args }) => ({
521
- description: prompt.description,
522
- messages: [{ role: 'user', content: { type: 'text', text: prompt.text(dir?.trim() || undefined, args) } }],
523
- }),
524
- );
525
- }
526
- }
527
-
528
- /** `hawk-app mcp`: serves the tools over stdio until the client closes the connection. */
529
- export async function serveMcp({ version } = {}) {
530
- // stdout belongs to the protocol: anything else printed by mistake goes to stderr.
531
- console.log = (...args) => console.error(...args);
532
- const server = createMcpServer({ version });
533
- const closed = new Promise((resolveClosed) => {
534
- server.server.onclose = resolveClosed;
535
- });
536
- await server.connect(new StdioServerTransport());
537
- await closed;
538
- return 0;
539
- }
1
+ // `hawk-app mcp`: a local MCP server (stdio) for AI coding assistants. Its tools run the CLI's own commands, so they
2
+ // can't drift from it, with these limits:
3
+ // - Always the dev platform (--env dev): prod, approvals, secrets and logins stay with people. Clients can be added
4
+ // by name ("deploy to OTC"), never removed.
5
+ // - No client names: outputs show client ids only. clients_add asks the person to pick the client in a form their
6
+ // assistant shows (MCP elicitation); the answer comes back to this server, and the assistant hears only that a
7
+ // client was added. Without forms, it sends the person to `hawk-app clients add --pick` in a terminal.
8
+ // - No query results: `data run` would put client rows in the assistant's context. Table and column names only.
9
+ // - The publisher key never passes through a tool: the commands use the saved login, as the CLI does.
10
+ // - deploy asks nothing: the assistant's client asks the user before calling it (destructive hint),
11
+ // and a question the CLI would ask (a teammate's app, serious vulnerabilities) cancels unless `force` is set.
12
+ // stdout is the protocol: everything the commands print is captured and returned, and builds run with piped output.
13
+ import { spawn } from 'node:child_process';
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { isAbsolute, join, relative, resolve } from 'node:path';
16
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
17
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
18
+ import { z } from 'zod';
19
+ import { main } from './cli.mjs';
20
+ import { resolveSettings } from './env.mjs';
21
+ import { searchDocs } from './mcp-docs.mjs';
22
+ import { describeUi } from './mcp-ui.mjs';
23
+ import { APIS, createHawkSearchReference } from './mcp-hawksearch.mjs';
24
+ import { createReportCatalog } from './mcp-reports.mjs';
25
+ import { createPlatformRules } from './mcp-rules.mjs';
26
+ import { platformClient } from './platform.mjs';
27
+
28
+ const MAX_OUTPUT = 20_000;
29
+ const TAIL_LINES = 40;
30
+
31
+ const INSTRUCTIONS = `Tools for building Hawk Apps: static web apps that run inside the HawkSearch dashboard and get their context and data through the bridge (@bridgeline-digital/hawk-app-bridge).
32
+ - Read the app's AGENTS.md first. Before using a bridge call, look it up with \`docs\` (options, limits, error codes); the showcase sections are working examples of every feature.
33
+ - Before building a screen, \`ui_components\` lists the UI kit's components for this app's template (Spark's look): use them instead of writing your own.
34
+ - For analytics data, \`analytics_reports\` lists the reports app.analytics.report() can call, and gives one report's columns and filters: check it before writing a query.
35
+ - For HawkSearch's own APIs (dashboard, search, recommendations, indexing), \`hawksearch_api\` gives an operation's fields, the bridge call and its scope, and says what apps can't call: check it before writing one.
36
+ - \`validate\` after changing hawk-app.json, queries/ or functions/, and \`validate\` with build=true after changing the build setup.
37
+ - \`deploy\` changes the app on the dev platform: only when the user asks (dryRun: true only shows what hawk-app.json would change). It never touches prod.
38
+ - \`doctor\` says what's wrong with the setup; \`versions\` and \`why\` explain which version a client gets.
39
+ - \`status\` shows the review checks approvers will see; fix failed ones before the user submits the app for approval.
40
+ - To give a client the app ("deploy to OTC", "give it to Outdoor Demo"), call \`clients_add\` with the user's words for the client: it asks the user to pick the exact client. \`deploy\` first if they asked for a new version. Client names aren't shared with you; don't ask the user to type them into the chat.`;
41
+
42
+ /** `npm run <script>` without inheriting stdout (the protocol): resolves with the output's last lines, rejects on failure. */
43
+ function runQuiet(args, { cwd, env = process.env }) {
44
+ return new Promise((resolvePromise, reject) => {
45
+ const child = spawn('npm', args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' });
46
+ const lines = [];
47
+ const collect = (chunk) => {
48
+ lines.push(...chunk.toString('utf8').split(/\r?\n/).filter(Boolean));
49
+ if (lines.length > 500) lines.splice(0, lines.length - 500);
50
+ };
51
+ child.stdout.on('data', collect);
52
+ child.stderr.on('data', collect);
53
+ child.on('error', reject);
54
+ child.on('close', (code) => {
55
+ const tail = lines.slice(-TAIL_LINES).join('\n');
56
+ if (code === 0) resolvePromise(tail);
57
+ else reject(new Error(`\`npm ${args.join(' ')}\` failed (exit code ${code}):\n${tail}`));
58
+ });
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Runs one CLI command in `dir` and returns what it printed. Commands run one at a time: they use the process's
64
+ * working directory.
65
+ */
66
+ export function createRunner({ root = process.cwd(), cli = main } = {}) {
67
+ let queue = Promise.resolve();
68
+ return function run(argv, { dir, force = false } = {}) {
69
+ const job = queue.then(async () => {
70
+ const cwd = resolve(root, dir ?? '.');
71
+ const inside = relative(root, cwd);
72
+ if (inside.startsWith('..') || isAbsolute(inside)) return { ok: false, text: `"${dir}" is outside the workspace (${root}).` };
73
+ if (!existsSync(cwd)) return { ok: false, text: `"${dir}" doesn't exist.` };
74
+
75
+ const out = [];
76
+ const log = (line = '') => out.push(String(line));
77
+ const previous = process.cwd();
78
+ process.chdir(cwd);
79
+ try {
80
+ const code = await cli([...argv, '--env', 'dev'], {
81
+ log,
82
+ clientNames: false,
83
+ // No terminal: a question cancels, unless the user said to go on (force). The assistant sees it.
84
+ confirm: async (question) => {
85
+ log(`? ${question}`);
86
+ return force;
87
+ },
88
+ runBuild: async ({ basePath, cwd: buildDir = cwd }) => {
89
+ const tail = await runQuiet(['run', 'build'], { cwd: buildDir, env: { ...process.env, HAWK_APP_BASE_PATH: basePath } });
90
+ log(tail);
91
+ },
92
+ runInstall: async ({ cwd: installDir }) => log(await runQuiet(['install', '--no-audit', '--no-fund'], { cwd: installDir })),
93
+ });
94
+ return { ok: code === 0, text: out.join('\n') };
95
+ } catch (error) {
96
+ out.push(`✖ ${error.message ?? error}`);
97
+ if (/Cancelled/.test(error.message ?? '') && !force) {
98
+ out.push('(The CLI would have asked the user. Ask them, and call again with force: true only if they say to go on.)');
99
+ }
100
+ return { ok: false, text: out.join('\n') };
101
+ } finally {
102
+ process.chdir(previous);
103
+ }
104
+ });
105
+ queue = job.catch(() => {});
106
+ return job;
107
+ };
108
+ }
109
+
110
+ const result = ({ ok, text }) => ({
111
+ content: [{ type: 'text', text: text.length > MAX_OUTPUT ? `${text.slice(0, MAX_OUTPUT)}\n… (output cut)` : text || '(no output)' }],
112
+ ...(ok ? {} : { isError: true }),
113
+ });
114
+
115
+ const dirParam = z.string().optional().describe("The app's folder (the one with hawk-app.json), relative to the workspace. Default: the workspace itself.");
116
+
117
+ /** The app's slug from hawk-app.json in `dir`, if there is one. */
118
+ function slugIn(root, dir) {
119
+ try {
120
+ return JSON.parse(readFileSync(join(resolve(root, dir ?? '.'), 'hawk-app.json'), 'utf8')).slug;
121
+ } catch {
122
+ return undefined;
123
+ }
124
+ }
125
+
126
+ /** The dev platform with the saved login, as the CLI resolves it in `dir` (for clients_add, which talks to it directly). */
127
+ function devPlatform(dir) {
128
+ const { url, key } = resolveSettings({ flagEnv: 'dev', dir });
129
+ return platformClient({ url, key });
130
+ }
131
+
132
+ /** How long the person has to pick a client in the form (the SDK's default is a minute). */
133
+ const PICK_TIMEOUT_MS = 5 * 60_000;
134
+
135
+ /**
136
+ * A platform error without its detail, which can name a client (e.g. the "did you mean" of an unknown id): the status,
137
+ * title and code. Errors before any answer (not logged in, unreachable) name no client and are kept whole.
138
+ */
139
+ const withoutNames = (error) => (error.status ? `${error.status} ${error.data?.title ?? 'error'}${error.data?.code ? ` (${error.data.code})` : ''}` : error.message ?? String(error));
140
+
141
+ /** What apps may call, from the dev platform's /meta/rules: shared by analytics_reports and hawksearch_api. */
142
+ const platformRules = createPlatformRules();
143
+
144
+ /** The MCP server with every tool registered (not connected). */
145
+ export function createMcpServer({
146
+ root = process.cwd(),
147
+ run = createRunner({ root }),
148
+ platform = devPlatform,
149
+ reports = createReportCatalog({ rules: platformRules }),
150
+ hawksearch = createHawkSearchReference({ rules: platformRules }),
151
+ version = '0.0.0',
152
+ } = {}) {
153
+ const server = new McpServer({ name: 'hawk-apps', version }, { instructions: INSTRUCTIONS });
154
+ const readOnly = { readOnlyHint: true, openWorldHint: false };
155
+
156
+ server.registerTool(
157
+ 'docs',
158
+ {
159
+ title: 'Hawk Apps documentation',
160
+ description:
161
+ 'The bridge (every app.* call, its scope, options, limits and error codes), the CLI, the hawk-app.json schema and the showcase app (working code for every feature). Without arguments: the list of sections. With query: the best matching sections. With section: one section in full.',
162
+ inputSchema: {
163
+ query: z.string().optional().describe('Words to look for, e.g. "data.query partitions" or "functions secrets".'),
164
+ section: z.string().optional().describe('A section title from the list or an earlier result, e.g. "bridge: Functions: `functions:invoke`".'),
165
+ offset: z.number().int().min(0).optional().describe('For a long section: where to go on reading (the earlier result says).'),
166
+ dir: dirParam,
167
+ },
168
+ annotations: readOnly,
169
+ },
170
+ async ({ query, section, offset, dir }) => result({ ok: true, text: searchDocs({ appDir: resolve(root, dir ?? '.'), query, section, offset }) }),
171
+ );
172
+
173
+ server.registerTool(
174
+ 'ui_components',
175
+ {
176
+ title: 'UI kit components',
177
+ description:
178
+ "The Hawk Apps UI kit's parts for this app's template, to build screens from instead of writing your own: React + MUI apps (next-mui) the provider, components and theme; every other app the stylesheet classes and custom elements (<hawk-kpi>, <hawk-data-table>, <hawk-bar-list>, <hawk-column-chart>). With component: only what's said about it. Links the live gallery.",
179
+ inputSchema: {
180
+ component: z.string().optional().describe('A component, element or class, e.g. "DataTable", "hawk-kpi" or "hawk-notice".'),
181
+ flavour: z.enum(['mui', 'elements']).optional().describe("Default: the app's (mui for next-mui, else elements)."),
182
+ dir: dirParam,
183
+ },
184
+ annotations: readOnly,
185
+ },
186
+ async ({ component, flavour, dir }) => result(describeUi({ appDir: resolve(root, dir ?? '.'), component, flavour })),
187
+ );
188
+
189
+ server.registerTool(
190
+ 'analytics_reports',
191
+ {
192
+ title: 'Analytics reports',
193
+ description:
194
+ "The HawkSearch Analytics API's reports, which app.analytics.report(name, request) calls (scope reports:read), read from its developer portal. Without arguments: every report with a one-line summary. With report: that report's description, columns, filters and having fields, and an example call. No client data.",
195
+ inputSchema: { report: z.string().optional().describe('A report name from the list, e.g. "assistant-prompts"; "" for the ad-hoc / natural-language root.') },
196
+ annotations: { ...readOnly, openWorldHint: true },
197
+ },
198
+ async ({ report }) => result(await reports({ report })),
199
+ );
200
+
201
+ server.registerTool(
202
+ 'hawksearch_api',
203
+ {
204
+ title: 'HawkSearch API reference',
205
+ description:
206
+ "HawkSearch's own API reference (developerdocs.hawksearch.com), with what apps can call of it: app.dashboard.*, app.hawksearch.search/autocomplete, recommendations and index. Without arguments: every API's operation groups and what apps can do with them. With api: that API's operations. With operation (an id from the list, or words): its method and path, parameters, request and response fields, the bridge call and its scope, or why apps can't call it. No client data.",
207
+ inputSchema: {
208
+ api: z.enum(Object.keys(APIS)).optional().describe('Only this API.'),
209
+ operation: z.string().optional().describe('An operation id from the list (e.g. "synonym_post_synonym"), or words to find one ("update redirect").'),
210
+ offset: z.number().int().min(0).optional().describe('For a long operation: where to read on from (the previous answer says).'),
211
+ },
212
+ annotations: { ...readOnly, openWorldHint: true },
213
+ },
214
+ async ({ api, operation, offset }) => result(await hawksearch({ api, operation, offset })),
215
+ );
216
+
217
+ server.registerTool(
218
+ 'validate',
219
+ {
220
+ title: 'Validate the app',
221
+ description:
222
+ 'Checks hawk-app.json, queries/ and functions/ like deploy does, and the project (the bridge and its version, a build script). build: true also builds the app as deploy does (base path /<slug>/v0) and fails on HTML URLs outside the base path.',
223
+ inputSchema: { build: z.boolean().optional(), folder: z.string().optional().describe("The build's output folder; default: the template's (dist, out, public)."), dir: dirParam },
224
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
225
+ },
226
+ async ({ build, folder, dir }) => result(await run(['validate', ...(build ? ['--build', ...(folder ? [folder] : [])] : [])], { dir })),
227
+ );
228
+
229
+ server.registerTool(
230
+ 'status',
231
+ {
232
+ title: 'App status on dev',
233
+ description: 'Where the app stands on the dev platform: the released version (default), the newest version, settings waiting for review, the approval request, the aliases, and the automated review checks approvers will see.',
234
+ inputSchema: { dir: dirParam },
235
+ annotations: { ...readOnly, openWorldHint: true },
236
+ },
237
+ async ({ dir }) => result(await run(['status'], { dir })),
238
+ );
239
+
240
+ server.registerTool(
241
+ 'doctor',
242
+ {
243
+ title: 'Check the setup',
244
+ description: "Checks everything a developer needs, with what to fix: Node, the login and the dev platform, hawk-app.json, the project (bridge, build script), scopes the code uses that hawk-app.json lacks, the template, the app on the platform and the MCP config.",
245
+ inputSchema: { dir: dirParam },
246
+ annotations: { ...readOnly, openWorldHint: true },
247
+ },
248
+ async ({ dir }) => result(await run(['doctor'], { dir })),
249
+ );
250
+
251
+ server.registerTool(
252
+ 'versions',
253
+ {
254
+ title: 'App versions on dev',
255
+ description: "Every version on the dev platform with its status (draft, waiting, approved, rejected) and the aliases on it. Clients get their alias's version (default without one); users see it only when it's approved.",
256
+ inputSchema: { dir: dirParam },
257
+ annotations: { ...readOnly, openWorldHint: true },
258
+ },
259
+ async ({ dir }) => result(await run(['versions'], { dir })),
260
+ );
261
+
262
+ server.registerTool(
263
+ 'why',
264
+ {
265
+ title: 'Why a client sees (or not) the app on dev',
266
+ description: "What a client's users see of the app on the dev platform, and why: the client list, the client's alias and that version's status. Takes the client id (e.g. e3141).",
267
+ inputSchema: { client: z.string().describe('The client id, e.g. e3141.'), dir: dirParam },
268
+ annotations: { ...readOnly, openWorldHint: true },
269
+ },
270
+ async ({ client, dir }) => result(await run(['why', client], { dir })),
271
+ );
272
+
273
+ server.registerTool(
274
+ 'data_tables',
275
+ {
276
+ title: 'Analytics tables',
277
+ description: 'The analytics tables app queries (queries/*.sql, scope data:read) can use, including whether this app may use the sensitive ones. Names only, no data.',
278
+ inputSchema: { dir: dirParam },
279
+ annotations: { ...readOnly, openWorldHint: true },
280
+ },
281
+ async ({ dir }) => {
282
+ const slug = slugIn(root, dir);
283
+ return result(await run(['data', 'tables', ...(slug ? ['--app', slug] : [])], { dir }));
284
+ },
285
+ );
286
+
287
+ server.registerTool(
288
+ 'data_describe',
289
+ {
290
+ title: 'Table columns',
291
+ description: "An analytics table's columns and types, for writing queries/*.sql. No data.",
292
+ inputSchema: { table: z.string().describe('A table name from data_tables.'), dir: dirParam },
293
+ annotations: { ...readOnly, openWorldHint: true },
294
+ },
295
+ async ({ table, dir }) => result(await run(['data', 'describe', table], { dir })),
296
+ );
297
+
298
+ server.registerTool(
299
+ 'logs',
300
+ {
301
+ title: 'Function logs on dev',
302
+ description: "A function's logs on the dev platform (functions/<name>.ts).",
303
+ inputSchema: {
304
+ function: z.string().describe('The function name.'),
305
+ since: z.string().optional().describe('15m, 1h, 1d or an ISO time; default 1h.'),
306
+ dir: dirParam,
307
+ },
308
+ annotations: { ...readOnly, openWorldHint: true },
309
+ },
310
+ async ({ function: name, since, dir }) => result(await run(['logs', name, ...(since ? ['--since', since] : [])], { dir })),
311
+ );
312
+
313
+ server.registerTool(
314
+ 'errors',
315
+ {
316
+ title: 'App errors on dev',
317
+ description: "Errors in the app's pages on the dev platform (uncaught errors, unhandled rejections, app.reportError), grouped by kind, the most frequent first, with versions, clients and the top stack frames.",
318
+ inputSchema: { days: z.number().int().min(1).max(30).optional().describe('Days back; default 7.'), dir: dirParam },
319
+ annotations: { ...readOnly, openWorldHint: true },
320
+ },
321
+ async ({ days, dir }) => result(await run(['errors', ...(days ? ['--days', String(days)] : [])], { dir })),
322
+ );
323
+
324
+ server.registerTool(
325
+ 'usage',
326
+ {
327
+ title: 'App usage on dev',
328
+ description: 'Opens of the app and of its views, distinct users and clients per day on the dev platform (Bridgeline staff counted apart).',
329
+ inputSchema: { days: z.number().int().min(1).max(400).optional().describe('Days back; default 30.'), dir: dirParam },
330
+ annotations: { ...readOnly, openWorldHint: true },
331
+ },
332
+ async ({ days, dir }) => result(await run(['usage', ...(days ? ['--days', String(days)] : [])], { dir })),
333
+ );
334
+
335
+ server.registerTool(
336
+ 'init',
337
+ {
338
+ title: 'Make a project a Hawk App',
339
+ description: 'For a project without hawk-app.json: writes it (refusing a slug that is already an app), plus AGENTS.md and the MCP settings. Then add the bridge and the base path as AGENTS.md says.',
340
+ inputSchema: { slug: z.string().optional(), name: z.string().optional(), dir: dirParam },
341
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
342
+ },
343
+ async ({ slug, name, dir }) => result(await run(['init', ...(slug ? ['--slug', slug] : []), ...(name ? ['--name', name] : [])], { dir })),
344
+ );
345
+
346
+ server.registerTool(
347
+ 'upgrade',
348
+ {
349
+ title: 'Upgrade to the template',
350
+ description:
351
+ "Shows how the app differs from the CLI's template (package versions, unedited config files, AGENTS.md). apply: true changes those files and runs npm install. channel: 'latest' moves the bridge and CLI from dev builds to the released versions, 'dev' onto the dev builds (only when the user asks).",
352
+ inputSchema: { apply: z.boolean().optional(), channel: z.enum(['latest', 'dev']).optional(), dir: dirParam },
353
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
354
+ },
355
+ async ({ apply, channel, dir }) => result(await run(['upgrade', ...(channel ? ['--channel', channel] : []), ...(apply ? ['--yes'] : [])], { dir })),
356
+ );
357
+
358
+ server.registerTool(
359
+ 'deploy',
360
+ {
361
+ title: 'Deploy a version to dev',
362
+ description:
363
+ "Builds the app (npm run build with HAWK_APP_BASE_PATH) and uploads it as a new version on the dev platform, with hawk-app.json, queries/ and functions/. Runs npm audit first. alias: deploys off the main line and points that alias at the new version (only Bridgeline staff in its clients see it). dryRun: true only shows what hawk-app.json would change, and deploys nothing. Deploy only when the user asks (a dry run is fine any time).",
364
+ inputSchema: {
365
+ folder: z.string().optional().describe("The build output folder; default: the template's (dist, out, public)."),
366
+ alias: z.string().optional().describe('An alias, e.g. staging or preview-my-branch: lowercase letters, digits and dashes.'),
367
+ dryRun: z.boolean().optional().describe('Only show what hawk-app.json would change.'),
368
+ noBuild: z.boolean().optional().describe('Upload the folder as it is (plain HTML/JS).'),
369
+ force: z.boolean().optional().describe("Go on despite high or critical vulnerabilities, or a teammate's last change: only after the user says so."),
370
+ dir: dirParam,
371
+ },
372
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
373
+ },
374
+ async ({ folder, alias, dryRun, noBuild, force, dir }) =>
375
+ result(await run(['deploy', ...(folder ? [folder] : []), ...(alias ? ['--alias', alias] : []), ...(dryRun ? ['--dry-run'] : []), ...(noBuild ? ['--no-build'] : [])], { dir, force })),
376
+ );
377
+
378
+ server.registerTool(
379
+ 'clients',
380
+ {
381
+ title: "The app's clients on dev",
382
+ description: "Which clients have the app on the dev platform (+ has it, - opted out), by id, and its visibility. Client names aren't shown.",
383
+ inputSchema: { dir: dirParam },
384
+ annotations: { ...readOnly, openWorldHint: true },
385
+ },
386
+ async ({ dir }) => result(await run(['clients'], { dir })),
387
+ );
388
+
389
+ server.registerTool(
390
+ 'clients_add',
391
+ {
392
+ title: 'Give a client the app on dev',
393
+ description:
394
+ 'Gives a client the app on the dev platform, at once (with visibility allowList, only listed clients have the app). Pass the user\'s words for the client ("OTC", "Outdoor Demo" or an id): the tool finds the matches and asks the user to pick one in a form. You hear only whether a client was added, never the names.',
395
+ inputSchema: {
396
+ client: z.string().describe('The client as the user named it, e.g. "OTC".'),
397
+ alias: z.string().optional().describe("The alias the client follows (the version it gets), e.g. \"review\"; \"default\" for the default. On an alias whose version isn't approved only Bridgeline staff in the client see the app."),
398
+ dir: dirParam,
399
+ },
400
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
401
+ },
402
+ async ({ client: words, alias, dir }) => {
403
+ const wanted = words.trim();
404
+ const slug = slugIn(root, dir);
405
+ if (!slug) return result({ ok: false, text: `No hawk-app.json in ${dir ?? 'the workspace'}: run this in the app's folder (dir).` });
406
+ if (wanted.length < 2) return result({ ok: false, text: "Ask the user for at least 2 characters of the client's name or id." });
407
+ const terminal = `In a terminal, in the app's folder: npx hawk-app clients add --pick "${wanted.replace(/"/g, '')}"${alias ? ` --alias ${alias}` : ''} --env dev`;
408
+ if (!server.server.getClientCapabilities()?.elicitation) {
409
+ return result({ ok: false, text: `This assistant can't show the user a form to pick the client, and client names aren't shared with you. Ask the user to run this themselves (it lists the matches for them to pick from):\n${terminal}` });
410
+ }
411
+
412
+ let devApi;
413
+ let found;
414
+ try {
415
+ devApi = platform(resolve(root, dir ?? '.'));
416
+ found = await devApi.searchClients(wanted);
417
+ } catch (error) {
418
+ return result({ ok: false, text: `✖ ${withoutNames(error)}` });
419
+ }
420
+ if (!found.clients?.length) {
421
+ return result({ ok: false, text: 'No client matches what the user typed. Ask them for another part of the name, or the id the dashboard shows (e.g. e3141).' });
422
+ }
423
+
424
+ let answer;
425
+ try {
426
+ answer = await server.server.elicitInput(
427
+ {
428
+ message: `Which client should get ${slug} on dev?${found.more ? " These are the best matches for what you typed: cancel and use more of the name if yours isn't here." : ''}`,
429
+ requestedSchema: {
430
+ type: 'object',
431
+ properties: {
432
+ client: { type: 'string', title: 'Client', oneOf: found.clients.map((c) => ({ const: c.id, title: c.name ? `${c.name} (${c.id})` : c.id })) },
433
+ },
434
+ required: ['client'],
435
+ },
436
+ },
437
+ { timeout: PICK_TIMEOUT_MS },
438
+ );
439
+ } catch {
440
+ // Not shown, not answered in time, or answered with something other than the choices (the SDK checks).
441
+ return result({ ok: false, text: `The form to pick the client didn't get an answer. Nothing was changed. Ask the user to try again, or to run:\n${terminal}` });
442
+ }
443
+ const id = answer.action === 'accept' ? answer.content?.client : undefined;
444
+ if (!found.clients.some((c) => c.id === id)) {
445
+ return result({ ok: false, text: 'The user cancelled: no client was added. Ask them what they want instead.' });
446
+ }
447
+
448
+ try {
449
+ await devApi.setClient(slug, id, { enabled: true, ...(alias ? { alias } : {}) });
450
+ } catch (error) {
451
+ if (error.data?.code === 'approver_required') return result({ ok: false, text: "The client's users see the app now: only an approver can change what it gets (the app's Rollout tab in the Hawk Analytics portal, or HawkSearch). Nothing was changed." });
452
+ return result({ ok: false, text: `✖ The client wasn't added: ${withoutNames(error)}` });
453
+ }
454
+ return result({ ok: true, text: `✔ Added the client the user picked to ${slug} on dev${alias ? ` on ${alias}` : ''}. It applies now (recorded in the app's history). The user saw its name in the form; it isn't shared here.` });
455
+ },
456
+ );
457
+
458
+ registerPrompts(server);
459
+ return server;
460
+ }
461
+
462
+ /**
463
+ * Prompts: ready-made tasks that clients list where developers look (Claude Code shows them as slash commands), so
464
+ * the tools are found without reading about them. Each one tells the assistant which tools to use, in what order.
465
+ */
466
+ const PROMPTS = {
467
+ 'check-before-submit': {
468
+ title: 'Check before submitting for approval',
469
+ description: 'Validates the app (with a build check), reads its status, review checks and errors on dev, and explains how to fix anything an approver would have to force approve.',
470
+ text: (dir) => `Check this Hawk App${where(dir)} before it's submitted for approval in HawkSearch.
471
+ 1. Run the hawk-apps \`validate\` tool with build: true${dirArg(dir)}. Fix what it reports in the code (ask me before changing hawk-app.json scopes or externalOrigins).
472
+ 2. Run \`status\`${dirArg(dir)}, and \`deploy\` with dryRun: true to see what hawk-app.json would change. If the app isn't on the platform or its newest version is older than the code, say so: I decide when to deploy.
473
+ 3. For each review check that fails or needs attention (bundle, dependencies, accessibility, external origins, queries, functions, external Lambdas), explain what an approver will see and how to fix it, using \`docs\` where it helps.
474
+ 4. Run \`errors\`${dirArg(dir)} and fix the errors the deployed versions threw, the most frequent first.
475
+ End with a short list: ready to submit, or what's left to do.`,
476
+ },
477
+ 'add-feature': {
478
+ title: 'Add a feature',
479
+ description: 'Builds a feature the Hawk Apps way: finds the right bridge calls, scopes and examples in the docs, makes the change, and validates it.',
480
+ args: { feature: z.string().describe('What to build, e.g. "a chart of searches per day for the selected date range".') },
481
+ text: (dir, { feature }) => `Add this feature to the Hawk App${where(dir)}: ${feature}
482
+ 1. Read AGENTS.md. Use the hawk-apps \`docs\` tool to find the bridge calls it needs (their options, limits and error codes) and a working example in the showcase sections. Prefer what the bridge offers over anything custom.
483
+ 2. Tell me which scopes, analytics reports (check them with \`analytics_reports\`), HawkSearch API calls (check them with \`hawksearch_api\`), queries (queries/*.sql, check tables and columns with \`data_tables\` and \`data_describe\`) or functions it needs, and anything that makes the review harder (elevated scopes, new outside origins), before changing hawk-app.json.
484
+ 3. Make the change in the app's style. Handle a client without analytics and the errors the docs list.
485
+ 4. Run \`validate\`${dirArg(dir)} and fix what it reports. Don't deploy unless I ask.`,
486
+ },
487
+ 'deploy-to-client': {
488
+ title: 'Deploy to a client',
489
+ description: 'Deploys a new version to dev and gives a client the app: you pick the client from the matches for its name.',
490
+ args: { client: z.string().describe('The client\'s name or id, e.g. "OTC" or "Outdoor Demo".') },
491
+ text: (dir, { client }) => `Deploy this Hawk App${where(dir)} to the client "${client}" on dev.
492
+ 1. Run the hawk-apps \`deploy\` tool${dirArg(dir)}. If it asks a question, ask me.
493
+ 2. Run \`clients_add\` with client "${client}"${dirArg(dir)}. It asks me to pick the exact client; if it says it can't, tell me the command it gives.
494
+ 3. Run \`status\`${dirArg(dir)}.
495
+ End with where I'll find it: the HawkSearch dev dashboard, with the client I picked selected, on the Apps page.`,
496
+ },
497
+ 'explain-app': {
498
+ title: 'Explain this app',
499
+ description: 'Summarizes what the app does, the data and scopes it uses, and where it stands in review on dev.',
500
+ text: (dir) => `Explain this Hawk App${where(dir)} to me.
501
+ 1. Read hawk-app.json, AGENTS.md and the main source files: what does the app do, for whom, and which bridge calls, queries and functions does it use?
502
+ 2. Which scopes does it ask for, and are any unused or missing for what the code calls?
503
+ 3. Run the hawk-apps \`status\` tool${dirArg(dir)}: what's released, what's waiting for approval, and what do the review checks say?
504
+ Keep it short: a paragraph, then bullets.`,
505
+ },
506
+ };
507
+
508
+ const where = (dir) => (dir ? ` in ${dir}` : '');
509
+ const dirArg = (dir) => (dir ? ` (dir: "${dir}")` : '');
510
+
511
+ function registerPrompts(server) {
512
+ for (const [name, prompt] of Object.entries(PROMPTS)) {
513
+ server.registerPrompt(
514
+ name,
515
+ {
516
+ title: prompt.title,
517
+ description: prompt.description,
518
+ argsSchema: { ...prompt.args, dir: z.string().optional().describe("The app's folder, when the workspace has several apps.") },
519
+ },
520
+ ({ dir, ...args }) => ({
521
+ description: prompt.description,
522
+ messages: [{ role: 'user', content: { type: 'text', text: prompt.text(dir?.trim() || undefined, args) } }],
523
+ }),
524
+ );
525
+ }
526
+ }
527
+
528
+ /** `hawk-app mcp`: serves the tools over stdio until the client closes the connection. */
529
+ export async function serveMcp({ version } = {}) {
530
+ // stdout belongs to the protocol: anything else printed by mistake goes to stderr.
531
+ console.log = (...args) => console.error(...args);
532
+ const server = createMcpServer({ version });
533
+ const closed = new Promise((resolveClosed) => {
534
+ server.server.onclose = resolveClosed;
535
+ });
536
+ await server.connect(new StdioServerTransport());
537
+ await closed;
538
+ return 0;
539
+ }